# 考勤类模块的 workday 边界与「派生字段不入表」模式

**日期**：2026-04-29
**触发**：site-attendance 加「今日累计在岗时长」时遇到的两个跨模块决策
**适用**：所有考勤/出勤/工时类模块（site-attendance / meeting-attendance / 其它日历类业务）

## 决策一：localDate = 工作日，不是日历日（5am 边界）

### 问题
原 `getLocalDate(timezone)` 直接用 `toLocaleDateString` 取签到点时区下的日历日。带来的隐患：

晚班保安 23:00 签到、次日 03:00 签退 → 两条事件 `localDate` 不同（前一天和后一天）→ 单日聚合时拆成两条 DailySummary，时长无法跨日配对，业务上人是连续在岗的但报表里看不出来。

### 决策
把 `localDate` 解释为「工作日」：窗口 = `[当地 05:00, 次日 05:00)`，凌晨 0–4 点的事件归到前一天。实现：

```ts
export const WORKDAY_START_HOUR = 5;
export function getLocalDate(timezone: string, date?: Date): string {
  const d = date ?? new Date();
  const shifted = new Date(d.getTime() - WORKDAY_START_HOUR * 60 * 60 * 1000);
  return shifted.toLocaleDateString('en-CA', { timeZone: timezone });
}
```

### 关键认识
- 这不是"凑合方案"，而是行业惯例：考勤系统的"一天"本来就跟天文日不重合（航空业 04:00、餐饮业 06:00）
- 5 点是经验值：覆盖 95% 晚班场景，又不会让"早起 4:30 签到的早班"跨到前一天
- 字段名继续叫 `localDate` 不变，但**语义变了** — 文档里要明写「工作日」二字
- 历史数据按旧规则切分，迁移点的凌晨段统计可能有偏差，UAT/生产数据迁移由部署时人工评估
- 极端反例：04:30 签到、05:30 签退 → 两条事件在不同 workday 不会配对。视为业务上不应发生的情况，不主动处理

### 应用范围
- ✅ site-attendance（已落地）
- ⚠️ meeting-attendance：会议有明确开始/结束时间，不需要 workday 概念，**不要套用**
- 🤔 后续做考勤报表/工时统计模块时再评估

## 决策二：能算出来的字段不存表（派生字段）

### 问题
本次需求是"今日累计在岗时长"。第一反应是给 `SiteDailySummary` 加 `todayDurationSeconds` 字段，签到时增量更新。

### 决策
**不加列、不入表**，由服务层在响应时基于当天 `AttendanceEvent` 时序流动态计算：

```ts
private computeTodayDurationSeconds(events, now = new Date()): number {
  // 按 timestamp asc 配对 CHECK_IN/CHECK_OUT 累加；末段未配对算到 now
}
```

三处接口（`getTodayEvents` / `checkin` / `guestCheckin` / 管理端 `getTodaySummary`）的响应里塞这个字段。

### 关键认识
- **能算出来的不存** = 单一事实源（事件流），避免双写不一致
- 历史事件如果回填或修正，存表方案要刷新所有受影响的 DailySummary，派生方案天然正确
- 性能成本：每次 checkin/today-events 多 1 次事件流查询。小规模无感，大规模再上 Redis 缓存（YAGNI）
- 文档里在数据模型那节单列「派生字段（不入库）」小节，让后人知道"这字段是哪儿来的"

### 反向边界（什么时候应该入表）
- 字段需要支持**复杂查询**（按时长 > N 排序、聚合统计）→ 索引/聚合需要存
- 计算成本极高（涉及全表扫描或多次远程调用）→ 缓存到表里，加 invalidation 逻辑
- 跨多个聚合根/事务边界 → 物化视图或冗余字段更稳
- 这三条都不满足时，**默认不存**

## L1 测试如何处理"时间相关"逻辑

### 问题
集成测试里要验证「1 小时前签到 + 30 分钟前签退 → 时长 = 30 分钟」。直接调 API 触发 CHECK_IN/CHECK_OUT 受 30s 防重保护和真实时间限制，不能跑出多段累加场景。

### 解法
直接用 `prisma.siteAttendanceEvent.create` 注入带历史 timestamp 的事件，绕过 service 层的 30s 保护和定位检查。再调真实 API（`GET today-events`）读响应。
- 用 `getLocalDate(cp.timezone, now)` 算 localDate（保证 workday 一致）
- 多段场景需要手动 upsert `SiteDailySummary`（admin 端 today-summary 依赖它判断 totalCheckedIn）
- 容差用 `±5s` 处理"now"的不确定性

### 教训
**契约稳定性比"端到端真实"更重要**。L1 集成测试的目标是验证 controller → service → repo → DB 的契约，不是模拟真实时间。注入历史数据是合法手段，写在测试 helper 里能复用。

## 关联文件
- `backend/src/modules/site-attendance/utils/geo.util.ts` — `getLocalDate` + `WORKDAY_START_HOUR`
- `docs/modules/site-attendance/06-data-model.md` — workday 规则 + 派生字段约定
- `testing/backend/integration/site-attendance/checkin-duration.api.test.ts` — 注入历史事件 + 5am 边界测试范例
