PAYATHON 2026

MySQL double join 中 SUM 重复计算 payment

支付老李

结论

不要先把 invoice_part 和 payment 这两个一对多表同时连接到 invoice,再执行 SUM()。应当先按 invoice_id 分别汇总发票明细和付款记录,然后把两组汇总结果连接到 invoice。

这样,无论一张发票有多少条明细、多少笔付款,每项金额都只会计算一次。

重复计算的原因

假设某张发票有:

  • 2 条 invoice_part
  • 1 条 payment

连接后会得到 2 × 1 = 2 行,同一笔付款也就出现了两次。

如果有 2 条明细和 3 笔付款,连接结果会变成 2 × 3 = 6 行。此时,付款金额和明细金额都会被重复计算。

因此,问题不在 SUM(),而是聚合之前已经出现了类似多对多关系的行扩张。

推荐方案:分别聚合后再连接

SELECT
    x.id,
    x.ref_id,
    x.start_date,
    x.regard,
    x.project_code,
    COALESCE(ip.price_ex, 0) AS price_ex,
    COALESCE(ip.price_inc, 0) - COALESCE(p.paid_amount, 0) AS price_open
FROM invoice AS x
LEFT JOIN (
    SELECT
        invoice_id,
        SUM(
            price * amount * (100 - discount) / 100
        ) AS price_ex,
        SUM(
            price * amount * (100 - discount) * (100 + tax) / 10000
        ) AS price_inc
    FROM invoice_part
    GROUP BY invoice_id
) AS ip
    ON ip.invoice_id = x.id
LEFT JOIN (
    SELECT
        invoice_id,
        SUM(amount) AS paid_amount
    FROM payment
    GROUP BY invoice_id
) AS p
    ON p.invoice_id = x.id;

连接之前,两个派生表都已经汇总成每张发票一行:

  • ip 计算发票的未税金额和含税金额。
  • p 计算已付款总额。
  • 最外层查询执行的是一对一连接,不会产生重复行。
  • 外层查询不再需要 GROUP BY x.id。

COALESCE(..., 0) 用于处理没有明细或付款记录的发票,也可以使用 IFNULL():

IFNULL(ip.price_inc, 0) - IFNULL(p.paid_amount, 0)

使用 CTE 的写法

如果 MySQL 版本支持 CTE,可以把同一逻辑写成下面这样:

WITH invoice_totals AS (
    SELECT
        invoice_id,
        SUM(
            price * amount * (100 - discount) / 100
        ) AS price_ex,
        SUM(
            price * amount * (100 - discount) * (100 + tax) / 10000
        ) AS price_inc
    FROM invoice_part
    GROUP BY invoice_id
),
payment_totals AS (
    SELECT
        invoice_id,
        SUM(amount) AS paid_amount
    FROM payment
    GROUP BY invoice_id
)
SELECT
    x.id,
    x.ref_id,
    x.start_date,
    x.regard,
    x.project_code,
    COALESCE(it.price_ex, 0) AS price_ex,
    COALESCE(it.price_inc, 0) - COALESCE(pt.paid_amount, 0) AS price_open
FROM invoice AS x
LEFT JOIN invoice_totals AS it
    ON it.invoice_id = x.id
LEFT JOIN payment_totals AS pt
    ON pt.invoice_id = x.id;

不要使用 SUM(DISTINCT p.amount)

下面的写法看起来能消除重复,其实并不可靠:

SUM(DISTINCT p.amount)

DISTINCT 区分的是金额值,不是付款记录。假如同一张发票有两笔金额相同的付款,例如两笔都是 100.00,SUM(DISTINCT p.amount) 只会计算一次,导致结果少算。

付款记录是否唯一应通过其主键判断。即使使用付款 ID,也无法直接借助 SUM(DISTINCT ...) 安全地汇总金额。因此,按 invoice_id 预先聚合仍然是最稳妥的做法。

Zend Framework 查询构造思路

具体方法名取决于 Zend Framework 的版本,但查询结构相同:先构造两个聚合子查询,再通过 LEFT JOIN 连接。示意代码如下:

$invoiceParts = $db->select()
    ->from(
        array('ip' => 'invoice_part'),
        array(
            'invoice_id',
            'price_ex' => new Zend_Db_Expr(
                'SUM(ip.price * ip.amount * (100 - ip.discount) / 100)'
            ),
            'price_inc' => new Zend_Db_Expr(
                'SUM(ip.price * ip.amount * (100 - ip.discount) * (100 + ip.tax) / 10000)'
            ),
        )
    )
    ->group('ip.invoice_id');

$payments = $db->select()
    ->from(
        array('p' => 'payment'),
        array(
            'invoice_id',
            'paid_amount' => new Zend_Db_Expr('SUM(p.amount)'),
        )
    )
    ->group('p.invoice_id');

$stmt = $db->select()
    ->from(
        array('x' => 'invoice'),
        array(
            'id',
            'ref_id',
            'start_date',
            'regard',
            'project_code',
        )
    )
    ->joinLeft(
        array('ip' => $invoiceParts),
        'ip.invoice_id = x.id',
        array(
            'price_ex' => new Zend_Db_Expr('COALESCE(ip.price_ex, 0)'),
            'price_open' => new Zend_Db_Expr(
                'COALESCE(ip.price_inc, 0) - COALESCE(p.paid_amount, 0)'
            ),
        )
    )
    ->joinLeft(
        array('p' => $payments),
        'p.invoice_id = x.id',
        array()
    );

如果所用 Zend 版本对子查询连接语法有不同要求,需要按该版本调整构造方式。查询原则不变:先分别执行 GROUP BY invoice_id,再连接聚合结果。

注意事项

  • 如果 tax 是 invoice_part 的字段,应明确写成 ip.tax,避免与其他表中的同名字段混淆。
  • 如果税率属于发票表,应按照业务规则改用 x.tax,并相应调整聚合位置。
  • 金额字段建议使用 DECIMAL,不要使用 FLOAT 或 DOUBLE,以免产生不必要的舍入误差。
  • 如果折扣、税率或金额字段可能为 NULL,需要先确定其业务含义,再用 COALESCE() 设置默认值。
  • 建议为两个明细表的外键建立索引:
CREATE INDEX idx_invoice_part_invoice_id
    ON invoice_part (invoice_id);

CREATE INDEX idx_payment_invoice_id
    ON payment (invoice_id);

多个一对多关系需要同时汇总时,应先分别聚合到父表粒度,再执行连接。

备注:内容仅供参考。