PHP 计算账单周期内的折扣适用百分比
支付小周
结论
先算出 invoice 日期范围与 discount 日期范围重叠了多少天,再除以当前 invoice cycle 的总天数:
适用比例 = 重叠天数 ÷ 账单周期总天数
applyDiscount() 返回一个 0.0 到 1.0 的系数。百分比折扣和固定金额 credit 都可以乘以这个系数。
如果首尾日期都计入有效期,那么 2023-03-10 至 2023-06-10 一共是 93 天,不是 90 天。按题目给出的日期,3 月共有 22 个重叠日,所以适用比例为 22 / 31 ≈ 70.97%,并非约 66%。
实现代码
下面的实现将四个日期的当天都计入日期范围:
<?php
function applyDiscount(
string $firstDay,
string $lastDay,
string $startDisc,
string $endDisc
): float {
$timezone = new DateTimeZone('UTC');
$invoiceStart = new DateTimeImmutable($firstDay, $timezone);
$invoiceEnd = new DateTimeImmutable($lastDay, $timezone);
$discountStart = new DateTimeImmutable($startDisc, $timezone);
$discountEnd = new DateTimeImmutable($endDisc, $timezone);
if ($invoiceStart > $invoiceEnd) {
throw new InvalidArgumentException(
'$firstDay must not be later than $lastDay'
);
}
if ($discountStart > $discountEnd) {
throw new InvalidArgumentException(
'$startDisc must not be later than $endDisc'
);
}
// 两个日期范围没有交集。
if ($discountEnd < $invoiceStart || $discountStart > $invoiceEnd) {
return 0.0;
}
// 交集的开始日期取两个开始日期中较晚的一个。
$overlapStart = $invoiceStart > $discountStart
? $invoiceStart
: $discountStart;
// 交集的结束日期取两个结束日期中较早的一个。
$overlapEnd = $invoiceEnd < $discountEnd
? $invoiceEnd
: $discountEnd;
// DateTime::diff() 不包含结束当天,因此需要加 1。
$invoiceDays = $invoiceStart->diff($invoiceEnd)->days + 1;
$overlapDays = $overlapStart->diff($overlapEnd)->days + 1;
return $overlapDays / $invoiceDays;
}
计算结果
$discountStart = '2023-03-10';
$discountEnd = '2023-06-10';
$cycles = [
['2023-03-01', '2023-03-31'],
['2023-04-01', '2023-04-30'],
['2023-05-01', '2023-05-31'],
['2023-06-01', '2023-06-30'],
];
foreach ($cycles as [$firstDay, $lastDay]) {
$factor = applyDiscount(
$firstDay,
$lastDay,
$discountStart,
$discountEnd
);
printf(
"%s 至 %s:%.4f%%\n",
$firstDay,
$lastDay,
$factor * 100
);
}
输出结果如下:
2023-03-01 至 2023-03-31:70.9677%
2023-04-01 至 2023-04-30:100.0000%
2023-05-01 至 2023-05-31:100.0000%
2023-06-01 至 2023-06-30:33.3333%
各周期的计算明细为:
| Invoice cycle | 重叠天数 | 周期天数 | 适用比例 |
|---|---|---|---|
| 2023-03-01 至 2023-03-31 | 22 | 31 | 70.9677% |
| 2023-04-01 至 2023-04-30 | 30 | 30 | 100% |
| 2023-05-01 至 2023-05-31 | 31 | 31 | 100% |
| 2023-06-01 至 2023-06-30 | 10 | 30 | 33.3333% |
应用于百分比折扣
假设原始折扣率为 50%,先调用 applyDiscount() 算出当前周期的适用系数,再乘以 0.50:
$factor = applyDiscount(
'2023-03-01',
'2023-03-31',
'2023-03-10',
'2023-06-10'
);
$originalDiscountRate = 0.50;
$effectiveDiscountRate = $originalDiscountRate * $factor;
printf("%.4f%%\n", $effectiveDiscountRate * 100);
3 月的实际折扣率是:
50% × 22 / 31 ≈ 35.4839%
如果账单金额为 $300,可以这样计算:
$invoiceAmount = 300.00;
$discountAmount = $invoiceAmount * $effectiveDiscountRate;
$amountDue = $invoiceAmount - $discountAmount;
应用于固定金额 credit
如果一个完整的 invoice cycle 可获得 $100 credit,也按相同方式乘以适用系数:
$factor = applyDiscount(
'2023-03-01',
'2023-03-31',
'2023-03-10',
'2023-06-10'
);
$fullCycleCredit = 100.00;
$appliedCredit = $fullCycleCredit * $factor;
echo number_format($appliedCredit, 2);
3 月应抵扣:
$100 × 22 / 31 ≈ $70.97
这里的 $100 指“每个完整 invoice cycle 的 credit”。如果 $100 是整个 90 天折扣期的 credit 总额,就不能让每个账单周期分别乘以这个系数,否则会造成重复抵扣。此时应按整个折扣期的总天数分摊。
90 天结束日期的处理
如果折扣从 2023-03-10 开始,且开始当天算第 1 天,那么第 90 天是 2023-06-07:
$discountStart = new DateTimeImmutable(
'2023-03-10',
new DateTimeZone('UTC')
);
$discountEnd = $discountStart->modify('+89 days');
echo $discountEnd->format('Y-m-d'); // 2023-06-07
这里使用 +89 days,因为开始日期本身已经是第 1 天。如果业务规则使用不包含结束日期的半开区间,可以改用 +90 days。同时,applyDiscount() 也必须改为不计算结束当天,不能混用两种日期规则。
注意事项
- 先明确开始日期和结束日期是否计入有效期。上面的实现采用首尾日期都包含在内的规则。
applyDiscount()最好返回0.0到1.0的系数,不要直接返回乘以100后的数值,这样更方便计算折扣和 credit。- 等金额最终入账时再舍入,例如使用
round($amount, 2),不要提前舍入适用比例。 - 真实货币结算应优先使用整数分或高精度十进制运算,以免浮点误差不断累积。
- 折扣日期与账单周期完全没有交集时返回
0.0,完全覆盖账单周期时返回1.0。
备注:内容仅供参考。
