PAYATHON 2026

如何使用 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 在正常情况下运行一次。部署、人工补跑或服务器重启都可能导致任务重复执行。

至少需要两层保护:

  1. 在 invoice 上设置 (ShopID, PeriodStart, PeriodEnd) 唯一约束,防止同一店铺在同一账期生成多张发票。
  2. 在 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,并在计算时显式转换时区。
  • 店铺数量较多时应分批处理,每个店铺或每张发票使用独立事务,避免一次异常导致所有店铺回滚。
  • 正式财务发票通常还涉及税率、税额、币种、发票号码、抬头、付款状态和审计记录,不能只依赖当前三个表。

备注:内容仅供参考。