如何设计每两周自动生成 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 生成流程
每次任务按以下顺序执行:
- 计算需要结算的
period_start和period_end。 - 查找该账期内已完成且尚未开票的订单。
- 按
user_id分组。 - 为每个用户取得或创建唯一的 Invoice。
- 将订单关联到对应的 Invoice。
- 根据实际关联成功的订单重新计算 Invoice 总额。
- 在同一个数据库事务中提交这些操作。
订单状态应使用含义明确的值,例如 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。同时保留手动重跑入口,但不要依赖人工修改订单状态来完成正常结算。
备注:内容仅供参考。
