PHP Arrays:多个数组金额的结算运算
支付阿杰
结论
这类结算用不着嵌套多层 foreach,也不必设置额外的状态标志。分别用两个索引指向当前的 Invoice 和 Payment 即可:
- 比较双方的未结算金额,取较小值;
- 将这个金额记为一条结算明细;
- 从 Invoice 和 Payment 的余额中分别扣除;
- 如果某一方的余额变为 0,就将对应索引移到下一条;
- 重复处理,直到其中一个数组遍历完毕。
金额最好先转换成以“分”为单位的整数,以免 PHP 浮点数运算产生精度误差。
实现代码
<?php
$invoices = array(
array("id" => 1, "amt" => 2000.00),
array("id" => 2, "amt" => 3000.00),
array("id" => 3, "amt" => 4000.00)
);
$payments = array(
array("id" => 11111, "amt" => 500.00),
array("id" => 22222, "amt" => 3000.00),
array("id" => 33333, "amt" => 4500.00),
array("id" => 44444, "amt" => 1500.00)
);
/**
* 将金额转换为分。
*/
function toCents($amount)
{
return (int) round($amount * 100);
}
/**
* 将分转换为金额字符串。
*/
function formatCents($cents)
{
return number_format($cents / 100, 2, '.', '');
}
// 创建工作数据,避免修改原始数组。
$invoiceBalances = array_map(function ($invoice) {
return array(
'id' => $invoice['id'],
'remaining' => toCents($invoice['amt'])
);
}, $invoices);
$paymentBalances = array_map(function ($payment) {
return array(
'id' => $payment['id'],
'remaining' => toCents($payment['amt'])
);
}, $payments);
$allocations = array();
$invoiceIndex = 0;
$paymentIndex = 0;
while (
$invoiceIndex < count($invoiceBalances) &&
$paymentIndex < count($paymentBalances)
) {
$invoice =& $invoiceBalances[$invoiceIndex];
$payment =& $paymentBalances[$paymentIndex];
// 本次最多只能结算双方余额中的较小值。
$settledAmount = min(
$invoice['remaining'],
$payment['remaining']
);
$invoice['remaining'] -= $settledAmount;
$payment['remaining'] -= $settledAmount;
$allocations[] = array(
'invoice_id' => $invoice['id'],
'payment_id' => $payment['id'],
'settled' => $settledAmount,
'invoice_remaining' => $invoice['remaining'],
'payment_remaining' => $payment['remaining']
);
// 当前 Invoice 已结清,处理下一张。
if ($invoice['remaining'] === 0) {
$invoiceIndex++;
}
// 当前 Payment 已用完,处理下一笔。
if ($payment['remaining'] === 0) {
$paymentIndex++;
}
unset($invoice, $payment);
}
// 输出每次结算的明细。
foreach ($allocations as $row) {
echo sprintf(
"Invoice #%s 使用 Payment #%s 结算 %s;Invoice 余额 %s;Payment 余额 %s\n",
$row['invoice_id'],
$row['payment_id'],
formatCents($row['settled']),
formatCents($row['invoice_remaining']),
formatCents($row['payment_remaining'])
);
}
// 输出仍未结清的 Invoice。
foreach ($invoiceBalances as $invoice) {
if ($invoice['remaining'] > 0) {
echo sprintf(
"Invoice #%s 尚未结算:%s\n",
$invoice['id'],
formatCents($invoice['remaining'])
);
}
}
// 输出尚未分配的 Payment。
foreach ($paymentBalances as $payment) {
if ($payment['remaining'] > 0) {
echo sprintf(
"Payment #%s 尚未分配:%s\n",
$payment['id'],
formatCents($payment['remaining'])
);
}
}
运行结果
按照数组中的顺序结算后,会得到以下结果:
Invoice #1 使用 Payment #11111 结算 500.00;Invoice 余额 1500.00;Payment 余额 0.00
Invoice #1 使用 Payment #22222 结算 1500.00;Invoice 余额 0.00;Payment 余额 1500.00
Invoice #2 使用 Payment #22222 结算 1500.00;Invoice 余额 1500.00;Payment 余额 0.00
Invoice #2 使用 Payment #33333 结算 1500.00;Invoice 余额 0.00;Payment 余额 3000.00
Invoice #3 使用 Payment #33333 结算 3000.00;Invoice 余额 1000.00;Payment 余额 0.00
Invoice #3 使用 Payment #44444 结算 1000.00;Invoice 余额 0.00;Payment 余额 500.00
Payment #44444 尚未分配:500.00
三张 Invoice 合计 9000.00,四笔 Payment 合计 9500.00。所有 Invoice 结清后,Payment #44444 还剩 500.00。
为什么两个索引就足够
每轮循环只会出现三种结果:
- Invoice 余额较多,当前 Payment 用完,接着处理下一笔 Payment;
- Payment 余额较多,当前 Invoice 结清,剩余 Payment 接着结算下一张 Invoice;
- 两边余额相等,Invoice 和 Payment 同时处理完,两个索引一起向后移动。
当前元素的 remaining 已经记录了完整的结算状态,所以不用再维护 $flag、$setBal、$paymBal 等相互依赖的变量。分支少了,也更容易避免重复扣款或遗漏金额。
注意事项
不要直接使用浮点数累计金额
float 不能精确表示所有十进制小数。金额连续加减后,可能得到类似 0.009999999 的结果。如果金额固定保留两位小数,先转换成“分”,再用整数计算会更可靠。
如果系统要处理两位以上的小数、不同币种的精度,或者数值很大的金额,可以使用字符串金额和 BCMath,同时明确舍入规则。
明确结算顺序
这段代码严格按照两个数组当前的排列顺序进行结算。如果业务规则是“最早到期的 Invoice 优先”或“最早到账的 Payment 优先”,需要先按日期及稳定的次级字段排序,然后再分配金额。
保存分配明细
实际业务中,不能只更新余额。$allocations 中的每条记录都保存了一笔 Payment 与一张 Invoice 的对应关系,可以写入结算明细表,供后续查询、撤销和审计使用。
写入数据库时,应在同一个事务中新增明细并更新双方余额,同时做好并发控制,避免同一笔 Payment 被重复结算。
备注:内容仅供参考。
