Python 如何获取每月26日至次月25日的时区感知账期?
结论
如果账期边界按 UTC 定义,就不要先创建本地时间,再交给 Django 转换。直接构造带 UTC tzinfo 的 datetime:
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from django.utils import timezone as django_timezone
def get_invoice_period(
reference_date: datetime | None = None,
) -> tuple[datetime, datetime]:
if reference_date is None:
reference_date = django_timezone.now()
if django_timezone.is_naive(reference_date):
raise ValueError("reference_date 必须是 timezone-aware datetime")
reference_date = reference_date.astimezone(timezone.utc)
current_month = reference_date.replace(
day=1,
hour=0,
minute=0,
second=0,
microsecond=0,
)
start = (current_month - relativedelta(months=1)).replace(
day=26,
hour=0,
minute=0,
second=0,
microsecond=0,
)
end = current_month.replace(
day=25,
hour=23,
minute=59,
second=59,
microsecond=0,
)
return start, end
传入 2025-07-23 所在月份的 UTC 时间,结果如下:
(
datetime(2025, 6, 26, 0, 0, 0, tzinfo=timezone.utc),
datetime(2025, 7, 25, 23, 59, 59, tzinfo=timezone.utc),
)
保存到 Django 数据库后,对应的值是:
2025-06-26T00:00:00Z
2025-07-25T23:59:59Z
为什么会出现时间偏移
datetime.now() 默认返回 naive datetime,也就是不包含时区信息的时间:
from datetime import datetime
value = datetime.now()
print(value.tzinfo) # None
启用 USE_TZ = True 后,如果保存的是 naive datetime,Django 通常会把它理解为项目默认时区中的时间,再转换为 UTC。
例如,某个时间被解释为 UTC−03
:2025-06-26 00:00:00-03:00
转换为 UTC 后会变成:
2025-06-26 03:00:00+00:00
所以,出现 03:00:00Z 不一定是 Django 计算错误,更可能是输入时间的时区含义与预期不一致。
前面的实现先调用:
reference_date.astimezone(timezone.utc)
这样可以把参考时间统一转换为 UTC。后续调用 replace() 时会保留 UTC 时区,不会再意外触发本地时区换算。
使用示例
from datetime import datetime, timezone
reference_date = datetime(
2025,
7,
23,
12,
30,
tzinfo=timezone.utc,
)
start, end = get_invoice_period(reference_date)
print(start.isoformat())
print(end.isoformat())
输出:
2025-06-26T00:00:00+00:00
2025-07-25T23:59:59+00:00
+00:00 和 Z 表示同一个 UTC 时刻,只是字符串格式不同。
如果账期按本地时区定义
这里需要先明确账期的业务含义:
- 如果“26 日午夜”指 UTC 午夜,就使用前面的 UTC 实现。
- 如果它指上海、纽约等业务所在地的午夜,那么保存到数据库后 UTC 时间发生变化是正常的。
例如,上海时间 2025-06-26 00:00:00+08:00 对应:
2025-06-25T16:00:00Z
如果账期按业务时区计算,可以显式传入该时区:
from datetime import datetime
from zoneinfo import ZoneInfo
from dateutil.relativedelta import relativedelta
from django.utils import timezone
def get_invoice_period_local(
reference_date: datetime | None = None,
billing_tz: ZoneInfo = ZoneInfo("Asia/Shanghai"),
) -> tuple[datetime, datetime]:
if reference_date is None:
reference_date = timezone.now()
if timezone.is_naive(reference_date):
raise ValueError("reference_date 必须是 timezone-aware datetime")
local_reference = reference_date.astimezone(billing_tz)
current_month = local_reference.replace(
day=1,
hour=0,
minute=0,
second=0,
microsecond=0,
)
start = (current_month - relativedelta(months=1)).replace(
day=26,
)
end = current_month.replace(
day=25,
hour=23,
minute=59,
second=59,
)
return start, end
函数返回的仍然是 timezone-aware datetime。Django 保存时会将其转换为 UTC,读取后则可以通过 timezone.localtime() 转回业务时区显示。
更稳妥的结束时间表示
数据库中的 DateTimeField 通常支持微秒。如果把账期结束时间固定为 23:59:59,那么这一秒内的以下时间都会超出结束边界:
23:59:59.000001
23:59:59.500000
23:59:59.999999
查询账期数据时,使用左闭右开区间更可靠:
[start, next_period_start)
也就是从本期 26 日午夜开始,一直到下个月 26 日午夜之前:
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from django.utils import timezone as django_timezone
def get_invoice_period_bounds(
reference_date: datetime | None = None,
) -> tuple[datetime, datetime]:
if reference_date is None:
reference_date = django_timezone.now()
if django_timezone.is_naive(reference_date):
raise ValueError("reference_date 必须是 timezone-aware datetime")
reference_date = reference_date.astimezone(timezone.utc)
current_month = reference_date.replace(
day=1,
hour=0,
minute=0,
second=0,
microsecond=0,
)
start = (current_month - relativedelta(months=1)).replace(day=26)
end_exclusive = current_month.replace(day=26)
return start, end_exclusive
对应的 Django 查询可以写成:
InvoiceEvent.objects.filter(
created_at__gte=start,
created_at__lt=end_exclusive,
)
如果接口规范要求返回“当前月 25 日 23:59
”,使用第一个函数即可。如果这些时间用于数据库范围查询,左闭右开区间更不容易漏掉数据。注意事项
- 不要混用
datetime.now()和 Django 的时区机制。启用时区支持后,优先使用django.utils.timezone.now()。 - 不要通过简单增减固定小时数来处理时区。
- naive datetime 没有足够的信息表明它代表 UTC 还是本地时间。最好直接拒绝,不要静默猜测。
datetime.timezone.utc是 Python 标准库提供的 UTC 时区,通常不需要额外依赖pytz。replace(tzinfo=...)只会附加时区标签,并不等同于时区转换。转换已有的 aware datetime 应使用astimezone()。- 必须先确定账期月份按 UTC 日期还是业务所在地日期计算。两种规则在月初和月末附近可能得出不同的账期。
备注:内容仅供参考。
