PAYATHON 2026

PHP Arrays:多个数组金额的结算运算

支付阿杰

结论

这类结算用不着嵌套多层 foreach,也不必设置额外的状态标志。分别用两个索引指向当前的 Invoice 和 Payment 即可:

  1. 比较双方的未结算金额,取较小值;
  2. 将这个金额记为一条结算明细;
  3. 从 Invoice 和 Payment 的余额中分别扣除;
  4. 如果某一方的余额变为 0,就将对应索引移到下一条;
  5. 重复处理,直到其中一个数组遍历完毕。

金额最好先转换成以“分”为单位的整数,以免 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 被重复结算。

备注:内容仅供参考。