如何使用 Cron Jobs / PHP 每半月自动生成发票?
结论
可以用 Cron Job 定时启动 PHP 脚本,但 Cron 只负责按时触发任务,具体的开票操作应通过数据库事务完成。
这里的周期是每月两次,并非严格的每 14 天:
- 每月 1 日至 15 日
- 每月 16 日至月末
可以在每月 16 日生成当月上半月的发票,在下月 1 日生成上月下半月的发票。程序还必须保证幂等性,即使 Cron 重复执行,也不能重复创建发票或关联订单。
完善发票表结构
现有 invoice 表无法判断某个店铺在特定账期内是否已经开票。建议至少增加店铺、账期起止时间、生成时间和唯一约束:
ALTER TABLE invoice
ADD COLUMN ShopID BIGINT NOT NULL,
ADD COLUMN PeriodStart DATETIME NOT NULL,
ADD COLUMN PeriodEnd DATETIME NOT NULL,
ADD COLUMN CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN Total DECIMAL(12, 2) NOT NULL DEFAULT 0,
ADD UNIQUE KEY uq_invoice_period (ShopID, PeriodStart, PeriodEnd);
ALTER TABLE invoiceorder
ADD UNIQUE KEY uq_invoiceorder_order (OrderID);
以上设计假设一笔订单只能进入一张结算发票。如果允许撤销后重新开票,就不能简单地为 OrderID 设置唯一约束,而要增加发票版本或冲销记录。
如果业务需要按”店铺 + 客户”分别开票,还应在 invoice 中增加 CustomerID,并把唯一约束改为:
UNIQUE KEY uq_invoice_period (
ShopID,
CustomerID,
PeriodStart,
PeriodEnd
)
使用左闭右开的时间范围
不要使用以下写法:
OrderDate BETWEEN '2026-03-01' AND '2026-03-15'
如果 OrderDate 包含具体时间,这个条件会漏掉 3 月 15 日白天产生的订单。使用左闭右开的区间更可靠:
OrderDate >= '2026-03-01 00:00:00'
AND OrderDate < '2026-03-16 00:00:00'
两个账期可以表示为:
- 3 月上半月:
[2026-03-01 00:00:00, 2026-03-16 00:00:00) - 3 月下半月:
[2026-03-16 00:00:00, 2026-04-01 00:00:00)
这样就不必单独判断某个月有 28、29、30 还是 31 天。
PHP 开票示例
以下示例使用 PDO 和 MySQL。它假设只有 Status = 'completed' 的订单能够结算,并为指定 ShopID 的一个账期生成一张发票。
<?php
declare(strict_types=1);
function generateInvoice(
PDO $pdo,
int $shopId,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd
): ?int {
$start = $periodStart->format('Y-m-d H:i:s');
$end = $periodEnd->format('Y-m-d H:i:s');
$pdo->beginTransaction();
try {
// 先锁定符合条件且尚未开票的订单。
$orderStmt = $pdo->prepare(
'SELECT o.OrderID, o.Total
FROM tbl_order AS o
WHERE o.ShopID = :shop_id
AND o.Status = :status
AND o.OrderDate >= :period_start
AND o.OrderDate < :period_end
AND NOT EXISTS (
SELECT 1
FROM invoiceorder AS io
WHERE io.OrderID = o.OrderID
)
FOR UPDATE'
);
$orderStmt->execute([
'shop_id' => $shopId,
'status' => 'completed',
'period_start' => $start,
'period_end' => $end,
]);
$orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC);
// 没有可结算订单时不生成空发票。
if ($orders === []) {
$pdo->commit();
return null;
}
$total = '0.00';
foreach ($orders as $order) {
$total = bcadd($total, (string) $order['Total'], 2);
}
$invoiceStmt = $pdo->prepare(
'INSERT INTO invoice (
ShopID,
PeriodStart,
PeriodEnd,
InvoiceDate,
InvoiceStatus,
Total
) VALUES (
:shop_id,
:period_start,
:period_end,
:invoice_date,
:invoice_status,
:total
)'
);
$invoiceStmt->execute([
'shop_id' => $shopId,
'period_start' => $start,
'period_end' => $end,
'invoice_date' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
'invoice_status' => 'generated',
'total' => $total,
]);
$invoiceId = (int) $pdo->lastInsertId();
$linkStmt = $pdo->prepare(
'INSERT INTO invoiceorder (InvoiceID, OrderID)
VALUES (:invoice_id, :order_id)'
);
foreach ($orders as $order) {
$linkStmt->execute([
'invoice_id' => $invoiceId,
'order_id' => (int) $order['OrderID'],
]);
}
$pdo->commit();
return $invoiceId;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
示例通过 bcadd() 累加金额,因此需要启用 PHP BCMath 扩展。也可以让数据库通过 SUM(Total) 直接计算总额,但仍要保存这张发票包含的订单明细,不能只保留汇总金额。
自动计算刚结束的账期
Cron 在每月 1 日和 16 日运行时,可以按下面的方式计算刚结束的账期:
<?php
declare(strict_types=1);
$timezone = new DateTimeZone('Asia/Shanghai');
$now = new DateTimeImmutable('now', $timezone);
if ((int) $now->format('j') === 16) {
// 16 日生成当月 1 日至 15 日的发票。
$periodStart = $now->modify('first day of this month')->setTime(0, 0);
$periodEnd = $periodStart->modify('+15 days');
} elseif ((int) $now->format('j') === 1) {
// 1 日生成上月 16 日至月末的发票。
$periodEnd = $now->modify('first day of this month')->setTime(0, 0);
$periodStart = $periodEnd
->modify('first day of previous month')
->modify('+15 days');
} else {
exit(0);
}
$invoiceId = generateInvoice(
$pdo,
5,
$periodStart,
$periodEnd
);
例如,脚本在 3 月 16 日运行时会得到:
PeriodStart = 2026-03-01 00:00:00
PeriodEnd = 2026-03-16 00:00:00
也就是说,程序会为 ShopID = 5 选择 3 月 1 日至 15 日的订单。
配置 Cron Job
假设脚本位于 /var/www/billing/generate-invoices.php,可以在服务器的 crontab 中加入:
5 0 1,16 * * /usr/bin/php /var/www/billing/generate-invoices.php >> /var/log/invoice-cron.log 2>&1
任务会在每月 1 日和 16 日的 00:05 执行。配置时需要确认以下事项:
/usr/bin/php是服务器上实际的 PHP CLI 路径。- Cron 使用的时区与业务账期一致。
- 执行 Cron 的系统用户有权读取脚本,并具备必要的数据库权限。
- 不要把数据库密码直接写进 crontab。
- 脚本执行失败时要记录日志并发送告警。
防止重复开票
不能只依赖 Cron 在正常情况下运行一次。部署、人工补跑或服务器重启都可能导致任务重复执行。
至少需要两层保护:
- 在
invoice上设置(ShopID, PeriodStart, PeriodEnd)唯一约束,防止同一店铺在同一账期生成多张发票。 - 在
invoiceorder.OrderID上设置唯一约束,防止一笔订单被加入多张发票。
程序还应捕获唯一键冲突。如果对应账期的发票已经存在,可以把本次执行视为成功跳过,不再插入另一张发票。
如果可能有多个任务实例同时运行,还可以使用 MySQL advisory lock:
SELECT GET_LOCK('invoice-generation', 10);
完成处理后释放锁:
SELECT RELEASE_LOCK('invoice-generation');
数据库唯一约束仍需保留,应用锁不能替代数据完整性约束。
更可靠的调度方式
每月 1 日和 16 日直接运行任务,配置起来比较简单。不过,如果服务器当时停机,相应账期就可能被跳过。更稳妥的方案是让 Cron 每天运行一次:
15 1 * * * /usr/bin/php /var/www/billing/generate-invoices.php >> /var/log/invoice-cron.log 2>&1
脚本每次运行时检查:
- 哪些账期已经结束;
- 哪些店铺还没有生成对应账期的发票;
- 是否有符合结算条件且尚未开票的订单。
即使某天执行失败,第二天也可以自动补跑。订单量较大,或者系统对重试、监控和并发控制有更高要求时,可以改用 Laravel Scheduler、Symfony Scheduler、消息队列或专门的作业调度平台。无论选择哪种调度方式,开票规则和数据库幂等约束都不变。
其他注意事项
- 需要明确按
OrderDate、付款时间还是订单完成时间划分账期。结算时通常不能只看下单时间。 - 已取消、已退款或未付款的订单不能只凭日期加入发票,还要检查明确的可结算状态。
- 订单开票后不宜直接修改金额。金额调整通常要通过贷项、冲销或补差记录处理。
- 如果订单可能延迟变为已完成状态,需要规定它应进入原账期还是下一账期。
- 数据库和 PHP 应使用相同的业务时区,也可以统一存储 UTC,并在计算时显式转换时区。
- 店铺数量较多时应分批处理,每个店铺或每张发票使用独立事务,避免一次异常导致所有店铺回滚。
- 正式财务发票通常还涉及税率、税额、币种、发票号码、抬头、付款状态和审计记录,不能只依赖当前三个表。
备注:内容仅供参考。
