PAYATHON 2026

如何设计每两周自动生成 Invoice 的方案?

支付小周

结论

建议采用“定时结算 + 可重复执行的幂等任务”,不要依赖后台人工修改 order.status 来触发开票。

这里的周期不是严格的“每 14 天”,而是按自然月划分为两个半月账期:

  • 每月 1 日 00
    至 16 日 00
    ,覆盖当月 1 至 15 日。
  • 每月 16 日 00
    至下月 1 日 00
    ,覆盖当月 16 日至月底。

任务可以安排在每月 1 日和 16 日运行,也可以每天运行一次,自动补齐尚未生成的账期。每天运行更可靠,某次调度失败后,程序还能自行补跑。

Invoice 通常应在账期结束后生成。例如,6 月 1 至 15 日的 Invoice 应在 6 月 16 日生成,不应在 6 月 11 日提前结算尚未结束的账期。

先调整数据模型

如果 Invoice 按用户生成,invoice 表必须保存 user_id。仅凭 invoice_date 判断 Invoice 是否存在,会把不同用户的订单误合并到同一张 Invoice。

建议将表设计为:

CREATE TABLE invoice (
    invoice_id     BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id        BIGINT NOT NULL,
    period_start   DATETIME NOT NULL,
    period_end     DATETIME NOT NULL,
    invoice_date   DATETIME NOT NULL,
    total          DECIMAL(18, 2) NOT NULL DEFAULT 0,
    status         VARCHAR(20) NOT NULL DEFAULT 'Unpaid',

    CONSTRAINT uq_invoice_period
        UNIQUE (user_id, period_start, period_end)
);

CREATE TABLE `order` (
    order_id       BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id        BIGINT NOT NULL,
    total          DECIMAL(18, 2) NOT NULL,
    status         VARCHAR(20) NOT NULL,
    order_date     DATETIME NOT NULL,
    invoice_id     BIGINT NULL,

    CONSTRAINT fk_order_invoice
        FOREIGN KEY (invoice_id) REFERENCES invoice(invoice_id)
);

CREATE INDEX idx_order_invoice_generation
    ON `order` (order_date, status, invoice_id, user_id);

需要注意以下几点:

  • 尚未开票时,invoice_id 应为 NULL,不应默认设为 0。0 不是有效外键,还容易让查询语义变得含糊。
  • 金额应使用 DECIMAL,不要使用 FLOAT 或 DOUBLE。
  • UNIQUE (user_id, period_start, period_end) 是防止重复生成 Invoice 的最后一道保障。
  • 如果一张订单只能归属一张 Invoice,把 invoice_id 放在订单表中是合理的。如果以后允许拆分订单或部分开票,则应改用 invoice_item 或 invoice_order 关联表。

使用左闭右开的时间范围

不要使用“日期小于等于当月 15 日 23:59

”这类条件,否则可能遗漏带毫秒或微秒的记录。

应使用左闭右开的范围:

order_date >= :period_start
AND order_date < :period_end

例如:

2026-06-01 00:00:00 <= order_date < 2026-06-16 00:00:00
2026-06-16 00:00:00 <= order_date < 2026-07-01 00:00:00

这样就不必单独处理每个月有 28、29、30 还是 31 天。

账期计算

下面的 PHP 示例会返回当前时刻之前最近一个已经结束的账期:

<?php

function previousClosedPeriod(DateTimeImmutable $now): array
{
    $timezone = $now->getTimezone();
    $monthStart = $now->modify('first day of this month')->setTime(0, 0);
    $monthMiddle = $monthStart->modify('+15 days'); // 当月 16 日 00:00

    if ($now >= $monthMiddle) {
        return [
            'start' => $monthStart,
            'end'   => $monthMiddle,
        ];
    }

    $previousMonthStart = $monthStart->modify('-1 month');
    $previousMonthMiddle = $previousMonthStart->modify('+15 days');

    return [
        'start' => $previousMonthMiddle,
        'end'   => $monthStart,
    ];
}

时间计算和数据库存储必须使用统一的时区。较稳妥的做法是让数据库统一存储 UTC,显示数据和计算业务账期时,再明确转换为业务时区。

Invoice 生成流程

每次任务按以下顺序执行:

  1. 计算需要结算的 period_start 和 period_end。
  2. 查找该账期内已完成且尚未开票的订单。
  3. 按 user_id 分组。
  4. 为每个用户取得或创建唯一的 Invoice。
  5. 将订单关联到对应的 Invoice。
  6. 根据实际关联成功的订单重新计算 Invoice 总额。
  7. 在同一个数据库事务中提交这些操作。

订单状态应使用含义明确的值,例如 Completed、Cancelled,不要使用语义不清的 status = 1。

候选用户可以这样查询:

SELECT DISTINCT user_id
FROM `order`
WHERE order_date >= :period_start
  AND order_date < :period_end
  AND status = 'Completed'
  AND invoice_id IS NULL;

然后针对每个用户,在事务中完成开票:

START TRANSACTION;

INSERT INTO invoice (
    user_id,
    period_start,
    period_end,
    invoice_date,
    total,
    status
)
VALUES (
    :user_id,
    :period_start,
    :period_end,
    :invoice_date,
    0,
    'Unpaid'
)
ON DUPLICATE KEY UPDATE
    invoice_id = LAST_INSERT_ID(invoice_id);

SET @invoice_id = LAST_INSERT_ID();

UPDATE `order`
SET invoice_id = @invoice_id
WHERE user_id = :user_id
  AND order_date >= :period_start
  AND order_date < :period_end
  AND status = 'Completed'
  AND invoice_id IS NULL;

UPDATE invoice
SET total = (
    SELECT COALESCE(SUM(o.total), 0)
    FROM `order` AS o
    WHERE o.invoice_id = @invoice_id
)
WHERE invoice_id = @invoice_id;

COMMIT;

这是 MySQL 风格的示例。其他数据库需要使用相应的 UPSERT 语法,例如 PostgreSQL 的 INSERT ... ON CONFLICT。

invoice_id IS NULL 这个条件不能省略。即使任务重复运行,它也不会把已经归属其他 Invoice 的订单重新关联。

调度方式

任务可以安排在每月 1 日和 16 日凌晨运行:

5 0 1,16 * * /usr/bin/php /path/to/generate-invoices.php

更可靠的方式是每天运行一次:

5 0 * * * /usr/bin/php /path/to/generate-invoices.php

程序每次检查所有已经结束但尚未完整结算的账期。即使服务器在某月 16 日宕机,恢复后也能补生成 Invoice。

Cron 只负责触发任务。避免重复开票和漏单,主要依靠以下机制:

  • 数据库唯一约束;
  • 事务;
  • invoice_id IS NULL 条件;
  • 可重复执行的账期任务;
  • 失败日志和告警。

并发和失败处理

如果多个开票任务实例可能同时运行,应增加任务锁,避免两个进程处理同一个账期。可以使用数据库 advisory lock、独立的结算任务表,或者对任务记录执行 SELECT ... FOR UPDATE。

还要处理以下异常情况:

  • Invoice 已经插入,但程序在更新订单前崩溃;
  • 部分订单已经关联,Invoice 总额仍是旧值;
  • 两个任务同时为同一用户和同一账期创建 Invoice。

事务可以解决前两类问题,唯一约束则用于阻止重复生成 Invoice。任务重跑时,根据已经关联的订单重新计算 total,便可修复中间状态。

迟到订单和订单修改

账期关闭规则需要事先确定。例如,一笔属于 6 月 1 至 15 日的订单直到 6 月 18 日才变为 Completed,可以选择以下处理方式:

  • 重新运行原账期,把订单追加到尚未付款的 Invoice;
  • 把订单放入下一张 Invoice;
  • 如果原 Invoice 已经确认,则生成补充 Invoice 或调整单。

已经支付、发送或进入会计流程的 Invoice 通常不应直接修改,此时更适合生成补充单、贷项单或调整记录。

订单开票后,其金额和用户归属也不应随意修改。如果业务允许修改,应保留 Invoice 明细快照,不能只根据当前的 order.total 推导历史金额。

是否需要手动方案

后台手动执行可以作为补发、重试和运营修正的入口,但不应成为正常开票流程。自动任务和后台按钮可以调用同一个开票服务:

generateInvoices($periodStart, $periodEnd);

自动任务负责日常运行,后台仅在任务失败或需要补账时供人工重跑。生成逻辑具有幂等性,因此多次执行同一账期也不会创建重复的 Invoice。

推荐按自然月半周期划分账期,在账期结束后自动运行任务,并通过数据库唯一约束、事务和幂等更新,为每个用户创建 Invoice。同时保留手动重跑入口,但不要依赖人工修改订单状态来完成正常结算。

备注:内容仅供参考。