介绍
巡回展览在纽约的 Harbor Tickets 和柏林的 Berlin Pop-Up 售票。每个场馆都在自己的本地午夜结束一个销售日。时钟调整当天,本地一天可能持续 23 或 25 小时。Harbor 的两张收据都显示 01:30,但不同的 UTC 偏移表明它们对应两个不同的实际时间点,也代表两张不同的票。
你将保存可复用的 PostgreSQL 查询,计算本地日窗口和完整票据明细,再交付真实 CSV 日结报告。你需要掌握基本终端导航、PostgreSQL 连接和 SELECT/WHERE。日期时间类型、命名时区、查询模板、连接和导出语法会在使用前讲解。PostgreSQL、原始合成票据数据及 Terminal 均已准备好;无需 Python 或前端开发。
在 /home/labex/project/day-close 中创建或编辑 windows.sql、daily-close.sql 和要求的 close-YYYY-MM-DD.csv 报告。保留原始表、场馆 ID、名称和时区,以及票据 ID、时间点、金额、工作人员备注及提供的工具。免费票仍要计为一张票;价格或钟表读数相同的不同收据也必须分别保留。这是简化的票务日结练习,不涉及税费、退款、支付服务商结算或会计保证。
所有源时间点都带有明确的 UTC 或偏移信息。我们使用固定历史日期和已知的本地午夜;解析不带偏移、存在歧义的钟表读数不在本练习范围内。运行时标签和输出保持英文。
构建正确的本地日窗口
本步骤中,你将把各场馆请求的日历日期转换为两个真实边界时间点,而不假定本地一天总是 24 小时。
从 Terminal 的普通 shell 开始。./ 运行当前目录中提供的程序。db 工具打开只读 PostgreSQL 连接;desk 会运行保存的查询并显示真实结果。先以纽约时间检查两张收据,再读取场馆设置:
here-document 将带引号的 SQL 标记与结尾 SQL 之间的各行送给 db。结束标记必须独占一行。SQL 语句以分号结束。代码块结束时连接关闭;其中的 SET TIME ZONE 只影响这个连接。下一次 ./db 会在 UTC 下开始新连接。WHERE ... IN (...) 保留所列票据 ID,ORDER BY 按 ID 排列显示内容。
cd /home/labex/project/day-close
./db <<'SQL'
SET TIME ZONE 'America/New_York';
SELECT ticket_id, sold_at, amount_cents
FROM tickets
WHERE ticket_id IN ('FOLD-1', 'FOLD-2')
ORDER BY ticket_id;
SELECT venue_id, venue_name, time_zone FROM venues ORDER BY venue_id;
SQL
收据显示 2024-11-03 01:30:00-04 和 2024-11-03 01:30:00-05。这些偏移表示当地钟表读数分别落后于公共时间基准 UTC 四小时和五小时。钟表读数相同,但第二个时间点比第一个晚一小时。保留两个 ID,即使两张票都是 500 分。
sold_at 的 PostgreSQL 类型为 timestamptz,是 timestamp with time zone 的简称。它表示时间点,并按连接的 TimeZone 显示,不保留原始时区名称或原始书写偏移。因此,每个场馆还单独存有 IANA 时区名称,如 America/New_York,由其针对具体日期的规则决定偏移。固定偏移不能替代该名称。
在新的 UTC 连接中读取相同存储时间点。这里 -c 运行完整的带引号 SQL 字符串后退出:
./db -c "SELECT ticket_id, sold_at FROM tickets WHERE ticket_id IN ('FOLD-1','FOLD-2') ORDER BY ticket_id;"
值分别为带 +00 偏移的 05:30 和 06:30。改变显示时区并没有改变票据。
date 是日历标签。timestamp without time zone 是钟表读数,还不是一个在全球唯一确定的时间点。PostgreSQL 的 ::type 将值转换为指定类型。将日期转换为 timestamp,得到当天 00:00。对这个本地时间戳应用 AT TIME ZONE,会按命名的场馆时区解释它,并返回 timestamptz 时间点。日期加整数 1 表示下一个日历日期。
对于 Harbor 的 11 月 3 日销售日,分别转换每个午夜:
./db <<'SQL'
SELECT '2024-11-03'::date::timestamp AT TIME ZONE 'America/New_York' AS opens_at,
('2024-11-03'::date + 1)::timestamp AT TIME ZONE 'America/New_York' AS closes_at;
SQL
UTC 开始时间为 11 月 3 日 04:00,结束时间为 11 月 4 日 05:00,实际经过 25 小时。3 月 10 日的春季调整则只有 23 小时。在开始时间上固定加 24 小时,两天都会得到错误结束时间。时区偏移未必是整小时,有些时钟调整也只有半小时;应使用两个午夜各自的实际规则,而不是硬编码偏移或时长。
为所有场馆保存可复用查询。WITH windows AS (...) 给中间查询命名,称为公用表表达式。v 是 venues 的简短表别名。AS 为计算列指定输出名称。这让本地日历计算与显示格式分开。
:'trading_date' 是工作台为每个请求日期提供的 psql 变量。冒号要求替换,单引号让 psql 将值引用为 SQL 字符串。将该值转换为 date,而不是在查询中固定写入某一天。
在最终 SELECT 中,AT TIME ZONE 'UTC' 反向工作:将 timestamptz 时间点转换为 UTC 钟表表示。to_char 对其格式化。YYYY-MM-DD 表示年、月、日,HH24 表示 24 小时制,MI 表示分钟,SS 表示秒。格式中带双引号的 T 和 Z 是字面标签。Z 表示 UTC,所以添加该标签前必须先转换到 UTC;单独打印 Z 不会转换任何时间。
时间点相减得到实际经过的时间间隔。EXTRACT(EPOCH FROM ...) 读取总秒数,除以 3600 得到经过小时数,必要时包含小数小时。只读取小时分量无法得到完整时长。
cat > windows.sql 写入下面的完整查询文件。带引号的 SQL 标记防止 shell 展开正文。粘贴整个代码块,并让结束标记独占一行:
cat > windows.sql <<'SQL'
WITH windows AS (
SELECT v.venue_id, v.venue_name, v.time_zone,
:'trading_date'::date AS trading_date,
:'trading_date'::date::timestamp AT TIME ZONE v.time_zone AS opens_at,
(:'trading_date'::date + 1)::timestamp AT TIME ZONE v.time_zone AS closes_at
FROM venues AS v
)
SELECT venue_id, venue_name, time_zone, trading_date,
to_char(opens_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS opens_utc,
to_char(closes_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS closes_utc,
EXTRACT(EPOCH FROM (closes_at - opens_at)) / 3600 AS hours
FROM windows
ORDER BY venue_id;
SQL
用工作台向同一文件提供不同营业日期。--zone 改变本次调用的连接显示时区,不改变场馆日期:
./desk windows 2024-11-03
./desk windows 2024-03-10
./desk windows 2024-11-03 --zone Asia/Tokyo
输出列依次为 venue_id、venue_name、time_zone、trading_date、opens_utc、closes_utc 和 hours。Harbor 的时长分别为 25 和 23 小时;Berlin 在这两个日期均为 24 小时。--zone 改变数据库连接的显示时区,不改变请求的场馆日期。明确的 UTC 边界保持相同。
文件名或命令输错时,读取错误,用 ls 检查实际文件,再重试修正后的命令。Day close error 是执行错误,不是成功的无销售报告。修复查询时,修改保存的 SQL 后重新运行即可,无需改变原始票据数据。读取实际窗口后,再运行步骤检查。
保留场馆当天的每张票据
本步骤中,你将用本地日边界选出每张符合条件的原始票据,恰好一次,同时保留没有销售的场馆。
继续使用 Terminal 和 /home/labex/project/day-close。保留 windows.sql。日窗口是半开区间:包含开始时间点,排除结束时间点。下一个午夜的收据属于下一天。SQL 比较使用 >= 表示大于或等于,< 表示严格小于。BETWEEN 包含两个端点,因此会把结束时的收据计入相邻两天。
对于刚观察的 Harbor 窗口,使用明确的 UTC 边界字面值检查实际票据。这里 -c 运行引号中的 SQL 后退出;每个字面值的 +00 都明确表示 UTC:
./db -c "SELECT ticket_id, sold_at, amount_cents FROM tickets WHERE venue_id='HARBOR' AND sold_at >= '2024-11-03 04:00:00+00'::timestamptz AND sold_at < '2024-11-04 05:00:00+00'::timestamptz ORDER BY sold_at,ticket_id;"
六张票据属于这个本地日。两张时钟重复时段的收据都保留;免费入场票的 amount_cents 为 0。金额为零不代表没有票。结束午夜的 NEXT 收据不属于本日;紧邻其前的 LATE 收据属于本日,包括其微秒部分。不要通过减去一秒来近似排他性结束边界。
将窗口 CTE 扩展为可复用明细查询。连接关联两个来源的行。w 表示一个窗口行,t 表示一张票据。即使没有票据满足 ON 条件,LEFT JOIN 也保留窗口,此时票据列为 NULL,表示没有匹配值。将时间条件放在 ON 中;移到最终 WHERE 会移除空场馆。场馆 ID 条件防止一个场馆收到另一个场馆的收据。
每张真实票据生成一行,保留未改变的 ID、UTC 时间点和整数分金额。空场馆生成一行,在 CSV 表示中票据 ID、销售时间点和金额为空。这是空场馆行,不是零价票。每行都保留场馆标识、名称、时区和日期。
输出列依次为 venue_id、venue_name、time_zone、trading_date、opens_utc、closes_utc、ticket_id、sold_utc、amount_cents。UTC 字段必须通过 Z 或等效零偏移明确标识 UTC。真实票据的完整时间点必须保留;格式模式中的 US 保留六位微秒数字。空销售时间点经过 to_char 仍为空值。结果行顺序可以不同,但每个所需行和值都必须保留。
写入完整查询。它使用你已练习过的同一日期参数和两个午夜转换:
cat > daily-close.sql <<'SQL'
WITH windows AS (
SELECT v.venue_id, v.venue_name, v.time_zone,
:'trading_date'::date AS trading_date,
:'trading_date'::date::timestamp AT TIME ZONE v.time_zone AS opens_at,
(:'trading_date'::date + 1)::timestamp AT TIME ZONE v.time_zone AS closes_at
FROM venues AS v
)
SELECT w.venue_id, w.venue_name, w.time_zone, w.trading_date,
to_char(w.opens_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS opens_utc,
to_char(w.closes_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS closes_utc,
t.ticket_id,
to_char(t.sold_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS sold_utc,
t.amount_cents
FROM windows AS w
LEFT JOIN tickets AS t
ON t.venue_id = w.venue_id
AND t.sold_at >= w.opens_at
AND t.sold_at < w.closes_at
ORDER BY w.venue_id, t.sold_at, t.ticket_id;
SQL
工作台的 close 操作原样运行保存的 SQL,显示实际行,以及仅从返回行推导的汇总。它不会补入缺失票据、选择正确边界或修正错误价格。汇总在金额为 0 时仍会计数真实票据 ID。请求秋季、春季及无销售日期:
./desk close 2024-11-03
./desk close 2024-03-10
./desk close 2024-07-01
11 月 3 日,Harbor 有六张票,总计 4750 分;Berlin 有四张,总计 3750 分。3 月 10 日,Harbor 有四张,总计 3750 分,Berlin 仍显示为零张票、零分。7 月 1 日每个场馆各有一条空场馆行,两者汇总都为零。SQL 错误与这些有效的无销售行不同。
相同钟表值、相同价格和重复场馆名称都不是标识规则。保留原始票据和场馆 ID;不要用 DISTINCT 合并不同收据,也不要仅以总额替代明细。检查行和总额后,再运行步骤检查。
交付含义保持一致的报告
本步骤中,你将把实际日结查询行导出为两个真实 CSV 文件,并确认连接显示时区不会让销售归入另一个场馆日期。
继续使用 Terminal 和 day-close 目录。保留两个 SQL 文件。--zone 为这一次工作台调用选择 PostgreSQL 连接显示 TimeZone,不改变场馆存储的时区、营业日期或源票据。在另外两个连接时区下比较同一个已结束销售日:
./desk close 2024-11-03 --zone Asia/Tokyo
./desk close 2024-11-03 --zone America/Los_Angeles
两组输出仍包含相同原始票据、场馆日期、明确的 UTC 窗口和总额。如果查询直接将 sold_at 转换为 date,或将本地钟表格式化后标成 Z,改变连接时区可能暴露错误。应修正查询,而不是改变全局数据库设置或重写源时间。
CSV 保存表头和分隔行;引号保护包含逗号的值。工作台的 export 操作将原生 PostgreSQL CSV 输出写入本工作区的 close-YYYY-MM-DD.csv。文件已存在时,只替换该日期的报告。写入前没有参考计算来修复查询。read 操作只解析保存的文件,并从实际行推导显示的汇总;它不查询数据库,也绝不从来源补齐遗漏行。
导出两个指定日期,再读取实际文件。源数据库不可用时,你仍可保留并重新打开这些报告:
./desk export 2024-11-03
./desk export 2024-03-10
ls
./desk read close-2024-11-03.csv
./desk read close-2024-03-10.csv
两个文件保留指定的九列明细。计数和总额应与你检查的真实查询结果一致:Harbor/Berlin 在秋季分别为 6/4750 和 4/3750,春季分别为 4/3750 和 0/0。重复 01:30 的两个 ID 都保留在秋季文件中,免费票仍是一条真实记录。截图或手工输入的总额无法保留这些事实。
保留 windows.sql、daily-close.sql、两个要求的报告,以及原始数据库和工具。修正查询不会自动更新旧文件:明确地再次导出该日期,然后读取保存的文件检查。如果报告文件名输错,用 ls 并按真实名称重试,不要改变数据库。确认保存的行后,再运行最终检查。
总结
你区分了日历标签、钟表读数、时间点和 IANA 时区规则,分别转换了两个本地午夜,用半开窗口让每张原始票据恰好保留一次,保留了无销售场馆,并导出了带明确 UTC 时间点的真实报告。保存的查询在会话 TimeZone 改变时仍然有效,文件读取工具也只显示实际保存的行。这是简化的历史票务日结,不是支付结算,也不是任意歧义时间解析。



