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);
多个一对多关系需要同时汇总时,应先分别聚合到父表粒度,再执行连接。
备注:内容仅供参考。
