寻找用于确定 fiscal months 的现有 Python module
数据小许
结论
推荐使用第三方库 python-dateutil,主要用到的 API 是 dateutil.relativedelta.relativedelta。
它可以正确处理每个月天数不同的情况,也符合题目中的截断规则:如果目标月份没有 29、30 或 31 日,日期就取该月最后一天。这个库负责日历运算,但不直接提供“判断订阅账期”的单一函数。账期编号和日期归属还需要用少量业务代码计算。
fiscalyear 这类库主要用于企业统一的财年和财季,不适合处理每位用户从各自订阅日期开始计算的滚动 billing month。
安装
python -m pip install python-dateutil
完整用法
每个账期边界都要从原始订阅日期计算,不要在上一个边界的基础上反复增加一个月。否则会出现 January 31 → February 28 → March 28 这样的日期漂移。
from datetime import date
from dateutil.relativedelta import relativedelta
def billing_boundary(subscription_date: date, month_index: int) -> date:
"""返回第 month_index 个账期边界;0 表示订阅开始日期。"""
if month_index < 0:
raise ValueError("month_index must be non-negative")
return subscription_date + relativedelta(
months=month_index,
day=subscription_date.day,
)
def billing_period(subscription_date: date, session_date: date):
"""
返回 (账期编号, 开始日期, 结束日期)。
账期编号从 1 开始,日期区间采用 [start, end),
即包含 start,但不包含 end。
"""
if session_date < subscription_date:
raise ValueError("session_date is before the subscription date")
month_index = (
(session_date.year - subscription_date.year) * 12
+ session_date.month
- subscription_date.month
)
if session_date < billing_boundary(subscription_date, month_index):
month_index -= 1
start = billing_boundary(subscription_date, month_index)
end = billing_boundary(subscription_date, month_index + 1)
return month_index + 1, start, end
例如:
subscription_date = date(2011, 1, 13)
print(billing_period(subscription_date, date(2011, 1, 20)))
# (1, datetime.date(2011, 1, 13), datetime.date(2011, 2, 13))
print(billing_period(subscription_date, date(2011, 2, 10)))
# (1, datetime.date(2011, 1, 13), datetime.date(2011, 2, 13))
print(billing_period(subscription_date, date(2011, 2, 15)))
# (2, datetime.date(2011, 2, 13), datetime.date(2011, 3, 13))
月末订阅日期也能正确处理:
subscription_date = date(2011, 1, 31)
print(billing_boundary(subscription_date, 1))
# 2011-02-28
print(billing_boundary(subscription_date, 2))
# 2011-03-31
print(billing_boundary(subscription_date, 3))
# 2011-04-30
这些结果符合 min(subscription_day, days_in_target_month) 规则,三月的边界仍然是 March 31,不会漂移到 March 28。
注意事项
账期适合定义为半开区间 [start, end)。假设当前账期的结束日期是 2011-02-13,发生在这个边界上的 session 应归入从 2011-02-13 开始的下一个账期。这样,相邻账期既不会重叠,也不会出现空隙。
如果业务精确到时间,而不只是日期,还需要明确:
- 账期采用哪个时区;
- cutoff 是当天
00:00,还是订阅发生的具体时刻; - 夏令时切换时,如何处理不存在或重复的本地时间。
python-dateutil 可以处理月份运算,时区规则则需要由业务定义。通常可以先根据本地日历日期确定账期边界,再把边界转换为带有明确时区的 datetime。数据库中的 session 时间统一保存为 UTC。
备注:内容仅供参考。
