SQL query 计算当前月与上月 billing usage
结论
仅凭现有的 created_at 和 deleted_at,无法可靠判断某段 usage 是否已经开过 invoice。建议增加 billed_through_at,记录每条 offering 已计费到的时间点。
计算时采用左闭右开的区间 [usage_start, usage_end):
- 首次开票:从
created_at计算到下个月 1 日。 - 后续开票:从
billed_through_at计算到再下个月 1 日。 - 开票成功后,更新
billed_through_at。 - 不要使用
endOfMonth()或+ 1 hour,以免产生月末微秒、重复计费等边界问题。
表结构调整
ALTER TABLE offerings
ADD COLUMN billed_through_at DATETIME NULL;
这个字段表示该 offering 在 invoice 成功生成后已经计费到哪个时间点,但不包含该时间点。
生产环境中,更完整的做法是增加 invoice_items 表,保存每次计费的区间,并为 (offering_id, period_start, period_end) 添加唯一约束,防止任务重试时重复收费。如果只需要较简单的实现,也可以使用 billed_through_at。
SQL 示例
假设 :invoice_start 是 invoice 所属月份的第一天,例如:
2022-08-01 00:00:00
:invoice_end 是下个月第一天:
2022-09-01 00:00:00
SQL 可以写成:
WITH billing_ranges AS (
SELECT
o.id,
o.name,
o.total_price,
CASE
WHEN o.billed_through_at IS NULL
THEN o.created_at
ELSE GREATEST(o.billed_through_at, :invoice_start)
END AS usage_start,
LEAST(
COALESCE(o.deleted_at, :invoice_end),
:invoice_end
) AS usage_end
FROM offerings AS o
WHERE o.created_at < :invoice_start
AND (
o.billed_through_at IS NULL
OR o.billed_through_at < :invoice_end
)
),
calculated_usage AS (
SELECT
id,
name,
total_price,
usage_start,
usage_end,
GREATEST(
TIMESTAMPDIFF(HOUR, usage_start, usage_end),
0
) AS usage_hour
FROM billing_ranges
)
SELECT
id,
name,
total_price,
usage_start,
usage_end,
usage_hour,
usage_hour * total_price AS usage_price
FROM calculated_usage
WHERE usage_end > usage_start;
这段 SQL 基于以下假设:
total_price是每小时价格。- 月中创建的 offering 在下个月 1 日首次开票。
deleted_at之前的时间可以正常计费。- 不足一小时的部分由
TIMESTAMPDIFF(HOUR, ...)截断。如果需要向上取整,应另外调整计费公式。
计算过程
假设 offering 创建于:
2022-07-15 00:00:00
第一次开票时间是 2022 年 8 月 1 日:
usage_start = 2022-07-15 00:00:00
usage_end = 2022-09-01 00:00:00
这个区间包括:
- 7 月 15 日到 8 月 1 日的补计 usage。
- 8 月 1 日到 9 月 1 日的 advance usage。
invoice 成功后,将该 offering 更新为:
UPDATE offerings
SET billed_through_at = :invoice_end
WHERE id = :offering_id;
此时:
billed_through_at = 2022-09-01 00:00:00
到 2022 年 9 月 1 日再次开票时:
usage_start = 2022-09-01 00:00:00
usage_end = 2022-10-01 00:00:00
这样就不会重复计算 7 月或 8 月的 usage。
生成 invoice、写入 invoice 明细和更新 billed_through_at 应在同一个数据库事务中完成。只有 invoice 成功写入数据库后,才能推进 billed_through_at。
Carbon 日期参数
结束时间不要使用 endOfMonth()。直接取下个月第一天,更容易保证各个区间连续且互不重叠:
$invoiceStart = now()->startOfMonth();
$invoiceEnd = $invoiceStart->copy()->addMonth();
绑定 SQL 参数时:
[
'invoice_start' => $invoiceStart->toDateTimeString(),
'invoice_end' => $invoiceEnd->toDateTimeString(),
]
这里使用的区间是:
[2022-08-01 00:00:00, 2022-09-01 00:00:00)
而不是:
2022-08-01 00:00:00 到 2022-08-31 23:59:59
因此不需要原查询中的 + 1。加入 + 1 后,首尾相连的两个 invoice 可能会包含同一个小时。
需要修正的日期示例
问题中的日期有几处不一致:
- 数据记录中写的是
2022-08-15,却又要求在2022-08-01为它开票,时间顺序不成立。按照预期描述,创建时间应为2022-07-15,或者首次 invoice 应在2022-09-01生成。 - 2 月没有 30 日。
- 9 月没有 31 日。
统一使用“当月第一天到下个月第一天”的半开区间后,就不需要为不同月份的天数或闰年编写特殊逻辑。
另外,从 2022-07-15 00:00:00 到 2022-08-01 00:00:00 实际是 408 小时,不是 360 小时。如果起始时间是 2022-07-15 12:00:00,则为 396 小时。最终金额应以数据库中的准确时间戳和明确的小时取整规则为准。
备注:内容仅供参考。
