# 报表恰好差一天：排查客户数据里的时区、夏令时和日期格式

> 仪表盘上的收入恰好差了一天，原因很少出在公式上。多半是某个时间戳从头到尾没人标过时区。

原文: https://fdetimes.net/zh/guides/report-off-by-one-day-time-zones/

周一早上，客户发来一张截图。你刚部署的仪表盘上，周五的收入比他们收银系统里的数字低，周六的又偏高。一周总数仍然对得上，只是一部分深夜订单被挤到了第二天。

遇到这类 bug，很多人的第一反应是打开 SQL 查公式。公式通常没问题。问题在于，像 `03/04/2024 23:30` 这样的字符串先后经过了三个系统，每个系统都猜了一次它属于哪个时区。

对前线部署工程师（FDE）来说，这种情况并不少见。客户的真实数据，你往往是第一个接触的人：企业资源计划（ERP）系统的导出文件、另一个大洲服务器上的日志、有人手工录入的表格。

一开始就把时间处理对，客户会信任你的数字。处理错了，之后每次开会都会绕着一个问题转：“这个数为什么不对？”

## 时间戳为什么会有歧义？

以字符串 `01/11/06` 为例。按日-月-年读，是 2006 年 11 月 1 日；按美国的月-日-年顺序读，是 2006 年 1 月 11 日。

按年-月-日读，则是 2001 年 11 月 6 日。没有哪个库能替你猜对，因为这是导出文件那个人的习惯，而不是数据本身的属性。

格式弄清之后，下一个问题是时区。Python 文档把不带时区的 datetime 称为“naive”：它携带的信息不足以让它相对其他时间戳明确定位。

它表示的是 UTC 还是本地时间，由程序自己决定。Python 还建议用带时区的“aware”datetime 表示 UTC，因为很多方法会把 naive datetime 当作本地时间处理。这也是 `utcnow()` 在 3.12 版本中被弃用的原因。

JavaScript 更难预料。据 MDN 介绍，字符串不带偏移量时，纯日期形式按 UTC 解析，日期加时间的形式却按本地时间解析。MDN 承认这是一个由来已久的规范错误，与 ISO 8601 不一致，但一改就会破坏 Web 兼容性，所以没法修。

```js
new Date("2024-03-01")        // UTC 午夜
new Date("2024-03-01T00:00")  // 运行机器本地时间的午夜
```

在负偏移时区（比如美国任何地方）的浏览器里，第一行会显示为 2 月 29 日晚上。仪表盘上的日期选择器往前错一天，原因就这么简单。

## 时间戳应该带上哪些信息？

数据要没有歧义，每个时间戳需要三样东西：绝对时刻、事件发生地的时区名，以及报表对“一天”的约定。缺任何一样，bug 迟早会出现。

绝对时刻应以 UTC 存储。以字符串交换时，用带 `Z` 后缀的 ISO 8601 格式，例如 `2024-03-05T05:30:00Z`，谁都不用猜。

关于 PostgreSQL，有一点要记住：`timestamp with time zone` 类型在内部以 UTC 存值，并不保留原始时区。如果以后需要知道一笔订单是在哪家门店、按谁的时钟下的，就得把时区另存一列。

这一列应存 Area/Location 形式的 IANA 时区名，例如 `America/Chicago` 或 `Asia/Ho_Chi_Minh`，而不是缩写。IANA tz 文档就举过 CST 的例子：它在中国是一个意思，在北美是另一个意思。

`-06:00` 这样的固定偏移量也不安全，因为各国政府将来可能修改时间规则。存时区名，并保持 tzdata 包为最新，规则变化时系统才能算对。

**要点:** 时刻按 UTC 存，时区名单独存，只在生成报表时才换算成“哪一天”。

## 套用到芝加哥的一家连锁门店

假设客户是芝加哥的一家连锁门店。他们的收银系统导出一份 CSV，其中 `order_time` 列的格式是 `03/04/2024 23:30`，不带时区。

第一件事不是写代码，而是问客户两个问题：文件里的顺序是月/日还是日/月？文件里的时间是门店时间还是服务器时间？

假设客户确认是月/日，且是门店时间。

```python
from datetime import datetime
from zoneinfo import ZoneInfo

STORE_TZ = "America/Chicago"   # 客户确认的 IANA 名称，不是 "CST"

def parse_order_time(raw: str) -> datetime:
    naive = datetime.strptime(raw, "%m/%d/%Y %H:%M")  # 月日年，已与客户确认
    local = naive.replace(tzinfo=ZoneInfo(STORE_TZ))   # naive -> aware
    return local.astimezone(ZoneInfo("UTC"))           # 以 UTC 存储
```

写入表时，`order_ts` 为 `timestamptz` 类型，`source_tz` 为文本列，存 `America/Chicago`。日报先换算成本地时间，再切出日期：

```sql
SELECT date(order_ts AT TIME ZONE source_tz) AS business_day,
       sum(amount)
FROM orders
GROUP BY 1;
```

开头截图里的 bug，通常来自按 `date(order_ts)` 分组，也就是按 UTC 日分组。芝加哥周五晚上 23:30 下的订单，在 UTC 里已经是周六。改一行 `GROUP BY`，收入就回到了正确的那一天。

## 夏令时结束时重复的那一小时

有一种情况要单独处理。PostgreSQL 文档指出，由于夏令时（DST），一个时区的偏移量在一年中会变化，所以不带日期的时间几乎没有意义。夏令时结束的那个晚上，有一段本地时间会出现两次。两笔都记为 `01:30` 的订单，实际可能相隔一小时。

Python 用 `fold` 属性处理这种情况：`fold=0` 使用切换前的偏移量，`fold=1` 使用切换后的偏移量。上面的 `strptime` 函数得到的始终是 `fold=0`。

如果客户的文件里有交易流水号或递增的日志记录，可以据此判断哪些行属于第二次出现的那一小时，并设置 `naive.replace(fold=1, ...)`。

如果没有，至少要把落在这段时间里的行标出来，并告诉客户。

夏令时也会影响工作安排。越南全年使用 UTC+7，不实行夏令时。

团队按越南时间工作、客户在美国或欧洲，时差就会随季节变化。所以“在客户时间早上 8 点运行”的 cron 任务，必须按客户的时区名配置，而不是算一次偏移量就不管了。

## 第一次需求调研会上要问的五个问题

芝加哥例子里的两个问题只是开始。上面所有的歧义，归结起来就是五个问题。尽早问清楚，比客户截完图再去修仪表盘便宜得多。

| 问客户的问题 | 为什么重要 |
|---|---|
| 文件里的日期顺序是日月年、月日年还是年月日？ | `01/11/06` 有三种读法，没有哪个库能替你猜 |
| 文件里的时间是门店时间、服务器时间还是 UTC？ | naive datetime 不会告诉你它属于哪个时区 |
| 每个地点使用哪个 IANA 时区？ | CST 这类缩写有歧义；固定偏移量在规则变化时会出错 |
| 报表里的“一天”在什么时候结束，按谁的时钟？ | 对深夜订单来说，UTC 日和门店日不是同一天 |
| 夏令时结束的那晚，数据里有没有流水号来区分重复的那一小时？ | 同一个本地时间可能对应两个真实时刻 |

把答案记在交接文档里，写在对应的列名旁边。之后接手项目的人就不用再问一遍。

## 常见错误，以及怎样把它变成一个可讲的故事

有几种错误反复出现：让库去猜日/月顺序；调用 `datetime.now()` 或 `utcnow()`，以为拿到的就是 UTC；在时区列里存 `CST` 或 `-06:00`；在前端把 `YYYY-MM-DD` 字符串直接传给 `new Date()`。

还有：报表按 UTC 日分组，客户却按门店日看数字。带着这些错误的代码在你的机器上跑得好好的，只有碰到另一个时区的真实数据才会出问题。

学得最快的办法，是亲手复现开头那张截图。建一张 `orders` 表，放几十笔 `America/Chicago` 的假订单，其中一些在周五晚上 23:00 之后，一些在夏令时结束的那晚。先用 `date(order_ts)` 跑报表，记下周五少了多少，再换成 `AT TIME ZONE source_tz` 对比一次。

这个练习能给你一个完整的故事：现象、假设、问客户的问题、修掉的那一行代码。

在真实数据上做过一遍之后，在简历上写一句具体的话，比如“将来自多个时区的时间戳列统一为 UTC 并附 IANA 时区名，修复了报表差一天的问题”，面试时也按这个顺序讲。

下次客户问某个数字为什么恰好差一天，先别急着打开公式。先问：这个时间戳是在哪里生成的，按的是谁的时钟？

**本周动手试试:**

- 打开一份真实的客户 CSV，列出所有时间列，在每列旁边注明格式、时区和来源系统。答不上来的列，加进要问客户的问题清单。
- 在代码库里 grep `new Date("`、`utcnow()` 以及不带 tz 参数的 `datetime.now()`，逐一改成显式带时区的写法。
- 写一个测试：接入一条客户本地时间 23:30 的记录，断言报表把它归到同一天。

## 信源

- [Date - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date)

- [datetime — Basic date and time types (Python 3.15.1 documentation)](https://docs.python.org/3/library/datetime.html)

- [PostgreSQL: Documentation: 18: 8.5. Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html)

- [zoneinfo — IANA time zone support (Python 3.15.1 documentation)](https://docs.python.org/3/library/zoneinfo.html)

- [Theory and pragmatics of the tz code and data](https://data.iana.org/time-zones/tzdb/theory.html)

- [List of date formats by country - Wikipedia](https://en.wikipedia.org/wiki/Date_format_by_country)

- [Time in Vietnam](https://en.wikipedia.org/wiki/Time_in_Vietnam)

- [ISO 8601 - Wikipedia](https://en.wikipedia.org/wiki/ISO_8601)
