PAYATHON 2026

Psql:获取每个 partner 的 invoice 统计与最后付款

支付小周

结论

先分别聚合 invoice 和 payment,再按 partner_id 关联,可以避免明细表直接连接后,同一张 invoice 因对应多笔付款而被重复计算。

如果 min number、min date、max number 和 max date 是四个独立的统计值,可以这样查询:

WITH invoice_stats AS (
    SELECT
        partner_id,
        MIN(number)       AS min_number,
        MIN(invoice_date) AS min_date,
        MAX(number)       AS max_number,
        MAX(invoice_date) AS max_date,
        COUNT(*)          AS invoice_count
    FROM invoice
    GROUP BY partner_id
),
payment_stats AS (
    SELECT
        partner_id,
        MAX(payment_date) AS last_pay
    FROM payment
    GROUP BY partner_id
)
SELECT
    i.partner_id,
    i.min_number,
    i.min_date,
    i.max_number,
    i.max_date,
    i.invoice_count,
    p.last_pay
FROM invoice_stats AS i
LEFT JOIN payment_stats AS p
    ON p.partner_id = i.partner_id
ORDER BY i.partner_id;

为什么要先聚合再关联

一个 partner 通常有多张 invoice,也可能有多笔 payment。如果直接连接两个明细表:

FROM invoice AS i
LEFT JOIN payment AS p
    ON p.partner_id = i.partner_id

每张 invoice 都可能匹配该 partner 的每笔 payment。比如某个 partner 有 10 张 invoice 和 3 笔 payment,连接后可能产生 30 行,COUNT(*) 也会随之被放大。

先分别按 partner_id 聚合后,每个子查询中的一个 partner 只占一行。此时再连接,就不会重复计数。

没有付款记录时的结果

这里使用 LEFT JOIN。只要 partner 有 invoice,就会出现在查询结果中;如果没有付款记录,last_pay 为 NULL。

如果只需要返回至少有一笔付款的 partner,可以把 LEFT JOIN 改成 INNER JOIN:

JOIN payment_stats AS p
    ON p.partner_id = i.partner_id

日期的显示格式

如果 invoice_date 和 payment_date 是 PostgreSQL 的 date 或 timestamp 类型,建议保留原始类型,由应用层负责格式化。

如果必须让 SQL 直接输出 DD-MM-YYYY 格式,可以使用 TO_CHAR:

WITH invoice_stats AS (
    SELECT
        partner_id,
        MIN(number)       AS min_number,
        MIN(invoice_date) AS min_date,
        MAX(number)       AS max_number,
        MAX(invoice_date) AS max_date,
        COUNT(*)          AS invoice_count
    FROM invoice
    GROUP BY partner_id
),
payment_stats AS (
    SELECT
        partner_id,
        MAX(payment_date) AS last_pay
    FROM payment
    GROUP BY partner_id
)
SELECT
    i.partner_id,
    i.min_number,
    TO_CHAR(i.min_date, 'DD-MM-YYYY') AS min_date,
    i.max_number,
    TO_CHAR(i.max_date, 'DD-MM-YYYY') AS max_date,
    i.invoice_count,
    TO_CHAR(p.last_pay, 'DD-MM-YYYY') AS last_pay
FROM invoice_stats AS i
LEFT JOIN payment_stats AS p
    ON p.partner_id = i.partner_id
ORDER BY i.partner_id;

TO_CHAR 返回的是文本。如果后续还要按日期排序、筛选或计算,最好不要在统计查询中提前格式化。

invoice number 与日期是否需要来自同一行

上面的查询分别计算:

MIN(number)
MIN(invoice_date)
MAX(number)
MAX(invoice_date)

这四个值相互独立。因此,min_number 不一定来自日期最早的那张 invoice,max_number 也不一定来自日期最新的那张 invoice。

如果 number 是文本类型,MIN() 和 MAX() 会按字符串排序。只有当编号格式固定,并且年份和序号都补齐位数时,例如 INV-2020-005,字符串顺序通常才能反映编号顺序。

如果需要取得“最早一张 invoice 的编号和日期”以及“最新一张 invoice 的编号和日期”,可以使用 DISTINCT ON:

WITH invoice_counts AS (
    SELECT
        partner_id,
        COUNT(*) AS invoice_count
    FROM invoice
    GROUP BY partner_id
),
first_invoice AS (
    SELECT DISTINCT ON (partner_id)
        partner_id,
        number AS min_number,
        invoice_date AS min_date
    FROM invoice
    ORDER BY partner_id, invoice_date ASC, id ASC
),
last_invoice AS (
    SELECT DISTINCT ON (partner_id)
        partner_id,
        number AS max_number,
        invoice_date AS max_date
    FROM invoice
    ORDER BY partner_id, invoice_date DESC, id DESC
),
payment_stats AS (
    SELECT
        partner_id,
        MAX(payment_date) AS last_pay
    FROM payment
    GROUP BY partner_id
)
SELECT
    c.partner_id,
    f.min_number,
    f.min_date,
    l.max_number,
    l.max_date,
    c.invoice_count,
    p.last_pay
FROM invoice_counts AS c
JOIN first_invoice AS f
    ON f.partner_id = c.partner_id
JOIN last_invoice AS l
    ON l.partner_id = c.partner_id
LEFT JOIN payment_stats AS p
    ON p.partner_id = c.partner_id
ORDER BY c.partner_id;

这里用 id 处理日期相同的情况,确保排序结果稳定。前提是 id 能表示明确的先后顺序。如果业务采用其他排序规则,应换成对应的字段。

索引建议

数据量较大时,可以考虑建立以下索引:

CREATE INDEX ON invoice (partner_id);
CREATE INDEX ON payment (partner_id, payment_date DESC);

如果经常查询每个 partner 最早和最新的 invoice,还可以建立:

CREATE INDEX ON invoice (partner_id, invoice_date, id);

具体选择哪种查询,取决于字段的业务含义。如果需要四个字段各自的最小值和最大值,使用第一种聚合查询;如果编号必须与最早、最晚日期来自同一条记录,则使用第二种查询。

备注:内容仅供参考。