PAYATHON 2026

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 日期还是业务所在地日期计算。两种规则在月初和月末附近可能得出不同的账期。

备注:内容仅供参考。