返回

MySQL应收账款统计:轻松解决凭证支付金额去重难题

mysql

MySQL 计算按日/月/年统计的应收账款(按凭证 ID 去重支付金额)

处理销售数据时,经常需要计算不同时间段(如每日、每月、每年)的应收账款。这听起来挺直接:应收账款 = 总销售额 - 已付金额。但如果你的数据结构像下面这样,事情就稍微复杂了一点:同一个 receipt_id 可能对应多条销售记录(比如一张小票上买了多个商品),而 paid_amount 可能在这些记录里是重复的,或者只记录在其中一条上,但它代表的是 整张小票 的支付金额。

咱们的目标是:计算日/月/年的应收账款,但对于每一个 receipt_id,相关的 paid_amount 在计算总的已付金额时只被算进去 一次

假设我们有这样的 sales 表:

-- 表结构相关的字段
SELECT
    sale_id,         -- 销售记录ID
    total_price,     -- 这条记录的总价 (单项商品或整单?) - 假设是单项
    paid_amount,     -- 已付金额 (可能是整张小票的总支付额,重复记录在每一项上)
    receipt_id,      -- 小票/凭证ID
    sale_date,       -- 销售日期
    bought_price,    -- 成本价
    quantity_sold    -- 销售数量
    -- ... 其他字段 customer_id, shop_id 等
FROM sales;

问题在哪?

如果你直接按日期分组,然后 SUM(total_price) - SUM(paid_amount),那麻烦就来了。如果 receipt_id 'R123' 有三条记录,并且每条记录的 paid_amount 都写的是这张小票的总支付额(比如 50 元),那 SUM(paid_amount) 就会把这 50 元加三次,变成 150 元。这样算出来的应收账款肯定就不对了,会远低于实际值,甚至可能是负数。

用户尝试过的查询(在问题中)用了子查询 (SELECT COALESCE(SUM(paid_amount), 0) FROM sales WHERE receipt_id = s.receipt_id GROUP BY receipt_id) 来试图解决这个问题。这个思路的方向是对的,想要拿到每个 receipt_id 的总支付额。但是,把它放在外层 SUMCASE WHEN 里面,意味着 每一行销售记录 都会触发一次这个子查询。如果一个 receipt_id 有 3 行,这个子查询就会执行 3 次,并且每次都减去 整个凭证 的支付金额。这就导致支付金额被重复减了好几次,结果自然不对。

看下这个 OneCompiler 示例数据。根据这个数据,我们期望的总应收账款是 15,933.00。(稍后我们会讨论这个具体数值可能存在的歧义)。

正确处理的核心思路

关键在于把“计算总销售额”和“计算去重后的总支付额”分开处理,然后再合并结果。

  1. 总销售额(Income/Revenue) :这个比较简单,直接按时间段(日/月/年)把 total_price 加起来就行。
  2. 总成本(Cost) :计算利润时需要,按时间段把 bought_price * quantity_sold 加起来。
  3. 总支付额(Paid Amount) :这是难点。我们需要先找出每个 receipt_id 对应的 唯一 支付金额,并确定这个支付发生的时间点(通常可以用该凭证关联的 sale_date,比如用 MAX(sale_date)),然后 按时间段(日/月/年)把这些唯一的支付金额加起来。
  4. 应收账款(Outstanding) :用第一步的总销售额减去第三步计算出的去重总支付额。
  5. 利润(Profit) :用第一步的总销售额减去第二步的总成本。

解决方案:使用 CTE(公共表表达式)

CTE 能让复杂的查询逻辑更清晰,易于理解和维护。我们可以用几个 CTE 分别处理上面提到的步骤。

先决条件 :假设你的 MySQL 版本支持 CTE(MySQL 8.0+)。如果版本较低,可以用子查询或者临时表实现类似逻辑,但 CTE 通常更推荐。

-- 设置目标年份变量,方便复用和修改
-- 你可以根据需要传入具体年份,或者使用 YEAR(CURDATE()) 获取当前年份
SET @target_year = YEAR(CURDATE());

-- 使用 CTE 来构建查询
WITH DistinctReceiptPayments AS (
    -- 第一步:找出每个 receipt_id 唯一的支付金额和关联日期
    -- 重要假设:表中同一 receipt_id 的多条记录里,paid_amount 值是相同的,
    -- 且代表该 receipt_id 的总支付额。我们用 MAX() 来取那个唯一的支付额。
    -- 同时,用 MAX(sale_date) 来确定这个支付归属到哪个日期进行统计。
    SELECT
        receipt_id,
        MAX(paid_amount) AS unique_paid_amount, -- 每个 receipt 只取一个 paid_amount 值
        MAX(sale_date) AS payment_date         -- 使用此凭证最晚的销售日期作为支付日期
    FROM sales
    WHERE receipt_id IS NOT NULL -- 最好排除 receipt_id 为 NULL 的情况,除非有特殊处理逻辑
    GROUP BY receipt_id          -- 按凭证 ID 分组是关键

    UNION ALL

    -- 处理 receipt_id 为 NULL 的情况(如果它们也需要计算支付额)
    -- 假设每个 receipt_id 为 NULL 的记录都代表一笔独立的支付
    SELECT
        receipt_id, -- 这会是 NULL
        paid_amount AS unique_paid_amount,
        sale_date AS payment_date
    FROM sales
    WHERE receipt_id IS NULL

), PaymentTotalsByPeriod AS (
    -- 第二步:基于上面去重后的支付信息,按日/月/年汇总支付总额
    SELECT
        COALESCE(SUM(CASE WHEN DATE(payment_date) = CURDATE() THEN unique_paid_amount ELSE 0 END), 0) AS total_daily_paid,
        COALESCE(SUM(CASE WHEN YEAR(payment_date) = @target_year AND MONTH(payment_date) = MONTH(CURDATE()) THEN unique_paid_amount ELSE 0 END), 0) AS total_monthly_paid,
        COALESCE(SUM(CASE WHEN YEAR(payment_date) = @target_year THEN unique_paid_amount ELSE 0 END), 0) AS total_yearly_paid
    FROM DistinctReceiptPayments

), SalesTotalsByPeriod AS (
     -- 第三步:直接从原始 sales 表计算日/月/年的总销售额和总成本
    SELECT
        COALESCE(SUM(CASE WHEN DATE(sale_date) = CURDATE() THEN total_price ELSE 0 END), 0) AS total_daily_sales,
        COALESCE(SUM(CASE WHEN DATE(sale_date) = CURDATE() THEN (bought_price * quantity_sold) ELSE 0 END), 0) AS total_daily_cost,

        COALESCE(SUM(CASE WHEN YEAR(sale_date) = @target_year AND MONTH(sale_date) = MONTH(CURDATE()) THEN total_price ELSE 0 END), 0) AS total_monthly_sales,
        COALESCE(SUM(CASE WHEN YEAR(sale_date) = @target_year AND MONTH(sale_date) = MONTH(CURDATE()) THEN (bought_price * quantity_sold) ELSE 0 END), 0) AS total_monthly_cost,

        COALESCE(SUM(CASE WHEN YEAR(sale_date) = @target_year THEN total_price ELSE 0 END), 0) AS total_yearly_sales,
        COALESCE(SUM(CASE WHEN YEAR(sale_date) = @target_year THEN (bought_price * quantity_sold) ELSE 0 END), 0) AS total_yearly_cost
    FROM sales
)
-- 第四步:组合结果,计算最终指标
SELECT
    -- 收入 (Income = Total Sales)
    FORMAT(st.total_daily_sales, 2) AS todays_income,
    FORMAT(st.total_monthly_sales, 2) AS monthly_income,
    FORMAT(st.total_yearly_sales, 2) AS yearly_income,

    -- 利润 (Profit = Sales - Cost)
    FORMAT(st.total_daily_sales - st.total_daily_cost, 2) AS todays_profit,
    FORMAT(st.total_monthly_sales - st.total_monthly_cost, 2) AS monthly_profit,
    FORMAT(st.total_yearly_sales - st.total_yearly_cost, 2) AS yearly_profit,

    -- 应收账款 (Outstanding = Sales - Paid)
    FORMAT(st.total_daily_sales - pt.total_daily_paid, 2) AS todays_outstanding,
    FORMAT(st.total_monthly_sales - pt.total_monthly_paid, 2) AS monthly_outstanding,
    FORMAT(st.total_yearly_sales - pt.total_yearly_paid, 2) AS yearly_outstanding

-- SalesTotalsByPeriod 和 PaymentTotalsByPeriod 都只产生一行结果,
-- 可以用 CROSS JOIN(或者直接逗号分隔)将它们合并成一行输出
FROM SalesTotalsByPeriod st, PaymentTotalsByPeriod pt;

代码解释

  1. DistinctReceiptPayments CTE :

    • 核心任务是为每个 receipt_id 生成一条记录,包含它的唯一支付金额 (unique_paid_amount) 和一个代表性的支付日期 (payment_date)。
    • GROUP BY receipt_id 是关键,确保每个凭证 ID 只出现一次。
    • MAX(paid_amount) 用来从可能重复的记录中选取一个 paid_amount 值。这里基于一个重要假设 :对于同一个 receipt_id,所有行的 paid_amount 要么是 0,要么是相同的、代表该凭证总支付额的数值。如果不是这样(比如 paid_amount 记录的是每次支付的部分金额),这里的逻辑需要调整。
    • MAX(sale_date) 用于确定这个支付应该归属到哪个时间段进行统计。通常一张凭证上的所有交易发生在同一天或很近的时间,用 MAXMIN 都可以。如果一张凭证的交易可能跨越多天,你需要根据业务规则决定支付额应归属到哪天。
    • UNION ALL 部分处理了 receipt_id 为 NULL 的记录,假设这些记录各自独立计算支付额。如果不需要处理 NULL,可以去掉 UNION ALL 及之后的部分,并在主 SELECT 中添加 WHERE receipt_id IS NOT NULL
  2. PaymentTotalsByPeriod CTE :

    • 使用上一步去重后的支付数据 (DistinctReceiptPayments)。
    • 通过 SUM(CASE WHEN ...) 条件聚合,分别计算出当天、当月(指定年份)、当年(指定年份)的总支付金额。
  3. SalesTotalsByPeriod CTE :

    • 直接在原始 sales 表上操作。
    • 同样使用 SUM(CASE WHEN ...),计算出对应的日/月/年的总销售额 (total_price) 和总成本 (bought_price * quantity_sold)。
  4. 最终 SELECT :

    • SalesTotalsByPeriod (包含销售和成本总额) 和 PaymentTotalsByPeriod (包含去重后的支付总额) 的结果合并(因为它们都只生成一行,直接用逗号或 CROSS JOIN 即可)。
    • 计算最终需要的指标:
      • 收入 = 销售总额
      • 利润 = 销售总额 - 成本总额
      • 应收账款 = 销售总额 - 去重后的支付总额
    • 使用 FORMAT(..., 2) 来格式化输出,保留两位小数。
    • 使用 COALESCE(..., 0) 来确保即使某个时间段没有任何销售或支付记录,相关的 SUM 结果是 0 而不是 NULL,避免后续计算出错。

为何这样能行?

这种方法通过 DistinctReceiptPayments CTE,首先就解决了 paid_amount 对每个 receipt_id 只计一次的核心问题。后续的聚合都是基于这个已经去重处理过的数据集 (PaymentTotalsByPeriod) 或原始销售数据 (SalesTotalsByPeriod) 进行的,避免了原始查询中支付金额被重复扣减的问题。逻辑清晰,每个步骤职责单一。

关于测试数据和预期结果的讨论

上面提供的 CTE 查询,在 OneCompiler 示例数据 上运行时,计算出的 yearly_outstanding 结果是 15,923.00。计算过程如下:

  • 总销售额 SUM(total_price) = 20 + 15 + 10000 + 6000 + 25 = 16060.00
  • 去重后的支付额:
    • ReceiptA (MAX(paid_amount)) = 10.00
    • ReceiptB (MAX(paid_amount)) = 100.00
    • ReceiptC (MAX(paid_amount)) = 17.00
    • NULL Receipt ID (MAX(paid_amount) in the NULL group, assuming we process it) = 10.00 (from sale_id 5)
    • 去重后的总支付额 SUM(unique_paid_amount) = 10 + 100 + 17 + 10 = 137.00
  • 应收账款 = 16060.00 - 137.00 = 15923.00

这与问题中提到的期望值 15,933.00 有 10 元的差异。这个差异是怎么来的呢?

16060.00 - 15933.00 = 127.00

查看原始数据中的 paid_amount 列,存在的 不同数值 是 10, 100, 17。它们的和正好是 10 + 100 + 17 = 127

可能 意味着,提问者想要的“去重”逻辑并非“每个 receipt_id 只算一次支付额”,而是“计算总销售额,然后减去在所有相关记录中出现过的 不同支付金额值 的总和”。

如果真的是后一种逻辑(虽然从财务角度看有点奇怪,因为它忽略了凭证的关联性),查询会变成:

-- 警告:这个查询匹配 15,933.00 的结果,但业务逻辑可能不符合“按凭证去重支付”的描述
SET @target_year = YEAR(CURDATE());

WITH PeriodSales AS (
    SELECT
        COALESCE(SUM(CASE WHEN DATE(sale_date) = CURDATE() THEN total_price ELSE 0 END), 0) AS total_daily_sales,
        COALESCE(SUM(CASE WHEN YEAR(sale_date) = @target_year AND MONTH(sale_date) = MONTH(CURDATE()) THEN total_price ELSE 0 END), 0) AS total_monthly_sales,
        COALESCE(SUM(CASE WHEN YEAR(sale_date) = @target_year THEN total_price ELSE 0 END), 0) AS total_yearly_sales,
        -- (此处省略成本和利润计算,它们与销售额的聚合方式相同)
    FROM sales
), DistinctPeriodPayments AS (
    SELECT
       (SELECT COALESCE(SUM(DISTINCT paid_amount), 0) FROM sales WHERE DATE(sale_date) = CURDATE()) as distinct_daily_paid,
       (SELECT COALESCE(SUM(DISTINCT paid_amount), 0) FROM sales WHERE YEAR(sale_date) = @target_year AND MONTH(sale_date) = MONTH(CURDATE())) as distinct_monthly_paid,
       (SELECT COALESCE(SUM(DISTINCT paid_amount), 0) FROM sales WHERE YEAR(sale_date) = @target_year) as distinct_yearly_paid
)
SELECT
    -- ... income and profit calculations ...
    -- Outstanding calculation using SUM(DISTINCT paid_amount values)
    FORMAT(ps.total_daily_sales - dpp.distinct_daily_paid, 2) AS todays_outstanding_alt,
    FORMAT(ps.total_monthly_sales - dpp.distinct_monthly_paid, 2) AS monthly_outstanding_alt,
    FORMAT(ps.total_yearly_sales - dpp.distinct_yearly_paid, 2) AS yearly_outstanding_alt
FROM PeriodSales ps, DistinctPeriodPayments dpp;

这个查询直接计算 SUM(DISTINCT paid_amount),对于示例数据确实得到 16060 - 127 = 15933.00

但是,请仔细确认业务需求! “paid_amount should only be subtracted once for that receipt_id ” 这句话强烈暗示应该使用我们第一个基于 GROUP BY receipt_id 的 CTE 解决方案。它更符合常规的财务逻辑:一笔支付对应一张凭证。如果你的预期确实是 15,933.00,你需要和业务方确认,“去重支付金额”到底是指“按凭证去重”还是“按支付金额的数值本身去重”。

数据完整性 和 假设

我们首选的解决方案依赖于以下假设:

  1. paid_amount 的含义 :假设 sales 表中,对于同一个 receipt_id 的多条记录,非零的 paid_amount 值是相同的,并且代表这张 整个凭证 的总支付金额。
  2. 支付日期关联 :我们使用 MAX(sale_date) 作为该笔支付的统计日期。如果支付可能发生在凭证产生后的某一天,并且记录方式不同,需要调整日期关联逻辑。
  3. receipt_id 的唯一性receipt_id 确实能唯一标识一次交易或一张凭证。NULL 值需要特别考虑处理方式。

如果这些假设不成立,比如 paid_amount 可能是分次支付记录,或者每个销售行项目的 paid_amount 只代表该项目的支付部分,那么计算逻辑需要根本性地改变。最好的情况是有一个单独的 payments 表,清晰地记录每一笔支付及其关联的 receipt_id

性能考量

  • 索引 :确保 sales 表在 receipt_idsale_date 列上有合适的索引,这对于 GROUP BYWHERE 条件过滤至关重要。组合索引 (receipt_id, sale_date, paid_amount) 可能对 DistinctReceiptPayments CTE 有帮助。sale_date 上的索引对 SalesTotalsByPeriod CTE 也很重要。
  • 数据量 :如果 sales 表非常巨大,这个查询(尤其是 DistinctReceiptPayments 部分的 GROUP BY)可能会有性能压力。可以考虑:
    • 定期归档 :如果历史数据不需要实时计算,可以归档旧数据。
    • 预计算/物化视图 :如果性能是主要瓶颈,可以考虑定期(比如每天结束时)运行计算并将结果存储在一个汇总表或物化视图中。
    • 版本兼容性 :对于 MySQL 8.0+,除了 CTE,也可以探索使用窗口函数(如 ROW_NUMBER())来标记每个 receipt_id 的第一行,然后只对标记行的 paid_amount 求和。这有时可能比 GROUP BY 效率更高,但代码可能会稍微复杂一些。

选择哪个方案取决于你的具体业务规则、数据结构和数据量。但通常,清晰地分离“销售聚合”和“去重支付聚合”是解决这类问题的关键。