# Oncall-v2 排班重构计划(定稿)

> 目标:把"班次模板 + 物化未来 + 手动续排"重构为 **「Layer 规则 + 未来实时算 + 过去冻结」**,解锁早晚班 / 周末 / 小时轮 / 跨时区夜班 / 并发在岗,同时保留 override 与 backup,并保证过去可问责。

> **2026-06 更新**:`starts_at` 已从 API 中移除,降级为内部存储列。写入时由 `handoff_at`(交接时刻,缺省继承现有交接网格)和 `next_up_email`(交接后谁接班,缺省取当前值班人的后继)推导:`starts_at = handoff_at - i*period`。这样编辑 people 列表不会因 `slot % n` 重排而打乱未来班表;节假日顺延改为设置 `handoff_at` += 一个周期。Layer 在保存间保持稳定 id(带 id 就地更新)。保存前可调 `POST /layers/preview` 干跑。

---

## 0. 核心决策(已确认)

1. **Layer 模型**取代 segment + anchor + offset。一个 Layer = 有序人池 + 换班周期 + 同时在岗 N + 生效时间窗 + 起点 starts_at + 优先级。
2. **未来实时算**:不物化未来、无 generate、无续排 cron、无地平线。UI 周历实时预览。
3. **on-call 历史(过去落库)**:平时纯动态现算,**不定时写**。只有**编辑规则前**,在同一事务里把"未记的过去 baseline"写进 `RealizedShift`,再改规则。读历史:有记录用记录、没有就现算;**override 永远实时合并**(本来就单独存)。→ 改规则不影响历史,中途/事后 override 也不丢。
4. **override**:一次性、多选、整段替换。**不做"只替换某人"**(要改并发中的一个,就把多人重设一遍)。
5. **backup**:默认 = 上一班的人(实时算);**team 级开关**控制开不开;backup 配置块删除。临时改某格仍用 override。
6. **多层重叠**:高优先级独占(**不做 union**)。
7. **时间**:一律存 Unix 秒;显示 / 输入**钉死 +8 固定偏移**(非 DST 时区),无夏令时 bug。
8. **节假日**:默认 (a) 轮值照常往前走(那周被 override 盖空);要 (b) 顺延,手动改 `starts_at`(+一个周期);**不做顺延按钮**,加说明文字。
9. **并发**:`同时在岗 N`(轮值并发)+ override 多选(临时并发)+ 通知/当前**列全部在岗**。无遗留并发问题。
10. **轮值顺序**:可编辑(拖 People 列表),只影响未来,**当前进行中的班锁定**。
11. **跨时区夜班 DST**:默认手动——一年两次把该 Layer 的时间窗 ±1h。可选(暂不做)预留 per-Layer 时区自动 DST。

---

## 1. 数据库改动

### 新表 `CategoryLayer`
| 字段 | 类型 | 含义 |
|---|---|---|
| id | uint PK | |
| category_id | uint | 所属 team |
| priority | int | 越大越优先(重叠时独占) |
| people | []string(json) | 轮值池,有序 |
| rotate_every_seconds | int64 | 换班周期(周=604800,小时=3600) |
| on_call_at_once | int | 同时在岗人数(默认 1) |
| active_mode | string | `always` \| `window` |
| active_days | []int(json) | window:生效星期 0=日..6=六 |
| active_start_min | int | window:当天生效起(分钟 0..1440) |
| active_end_min | int | window:当天生效止(end<start=跨午夜) |
| starts_at | int64 | 轮值相位起点(Unix 秒) |
| created_at/updated_at | time | |

### 新表 `RealizedShift`(过去冻结,仅 baseline)
| 字段 | 类型 | 含义 |
|---|---|---|
| id | uint PK | |
| category_id | uint | |
| start_time/end_time | int64 | 班次区间 |
| engineer_email | string | 在岗人(并发=多行) |
| is_backup | bool | lane |
| frozen_at | int64 | 冻结时刻 |

> 唯一键建议:(category_id, start_time, end_time, engineer_email, is_backup) 保证幂等。

### `Category` 改动
- 新增 `backup_enabled bool`(默认 false)。
- 废弃 `cycle_anchor_index` / `cycle_anchor_time` / `cycle_shift_steps`(迁移后删列)。

### `ShiftInterval` 改动
- 保留,**仅存 override 行**(source_type=manual-edit, overlay>0)。不再写 cycle-generate 未来 baseline。`is_backup` 保留。

### 删除
- `CategoryCycleSegment`(迁移后)。

---

## 2. 后端逻辑

### 2.1 Layer 求值 `ResolveLayers(categoryID, at) []string`
1. 取该 team 所有 Layer,过滤 `active_when` 覆盖 at 的;
2. 取 priority 最高的一层;
3. 层内:`slot = floor((at - starts_at)/rotate_every)`;`base = slot % len(people)`;取 base 起 `on_call_at_once` 个(环绕);
4. 无覆盖层 → 空。
> 时间窗判断按 +8 固定偏移把 at 换算成"星期 + 当天分钟"再比对。

### 2.2 `ComputeScheduleWindow(categoryID, from, to)`(纯算,不落库)
- 以 Layer 换班边界 + 时间窗边界为切点,逐段 ResolveLayers,合并相邻相同段。
- 供 UI 预览 / "未来谁值班" / notify / freeze sweeper 复用。

### 2.3 effective(过去快照 + 未来实时 + override 实时合并)
`GetEffectiveOverlayScheduleMulti` 改为:
- 过去段(end ≤ now):读 `RealizedShift`。
- 未来段(start ≥ now):`ComputeScheduleWindow`。
- now 接缝:进行中的班按"未来"算。
- **两段结果一律再叠 `ShiftInterval`(override)实时取顶**(高 overlay 赢)。

### 2.4 backup lane
- `backup_enabled=false` → 无 backup。
- `=true` → backup = **前一个班次的 primary**(用 ResolveLayers 在前一段求值)。override 仍可改单格 backup。

### 2.5 on-call 历史落库(编辑时写,无定时任务)
- **不**跑定时 sweeper。值班表平时纯动态现算。
- **改任何 Layer / backup_enabled 之前**(同一事务):用 ComputeScheduleWindow 把该 category"上次记录点 → now"之间**还没记的 baseline** 写入 RealizedShift(幂等),再应用改动。
- 受保留期限制(如最近 1 年),更老的不记(很少查,且未编辑时现算亦正确)。
- 读历史:有 RealizedShift 用之,没有就现算 —— 因为没记录就意味着这段没被编辑过,现算即真相。

### 2.6 notify / current
- 通知与"当前在岗"改为**列全部在岗**(并发都 @);删 `pickEngineerForIndex` 挑一人语义。

### 2.7 删除
- `BuildCycleItemsWindow` 空段 skip、`segmentHasNoOncall`、空段分支;
- `ShiftCategoryCyclePointer` / `UpdateCategoryCycleAnchor` / offset;
- `generate_overlay.go` 地平线 + plan_signature + generate-apply 整套。

---

## 3. API

| 动作 | 接口 |
|---|---|
| 替换 | `cycle/segments` → `layers` CRUD |
| 新增 | `GET schedule/compute?from&to`(实时窗口) |
| 改 | `schedule/window/effective` 内部 → 过去快照+未来实时+override(签名不变) |
| 改 | `notify/preview`、`notify/send` → 列全部在岗 |
| 改 | `cycle/current` → 列全部在岗(可改名 `schedule/current`) |
| 改 | category update 增加 `backup_enabled` |
| 保留 | `overlay/apply type=segment`(多人,不加 replaces_email) |
| 删 | `cycle/anchor`、`cycle/shift` |
| 删 | `generate/preview`、`overlay/apply type=generate` |

---

## 4. 前端

| 动作 | 内容 |
|---|---|
| 新增 | Rotation tab → **Layer 卡片**:拖序 People / 同时在岗 N / 换班频率 / 全天候 or 自定义时间窗 / starts_at / 拖动调优先级 / 增删 |
| 新增 | **周历实时预览**,改即变 |
| 新增 | Team Settings → backup 开关 |
| 新增 | starts_at 旁说明:"+一个周期 = 轮值顺延一轮(春节接班用)";时间窗旁说明 DST 手动 ±1h |
| 改 | 排班大表数据源 → effective;**并发都显示** |
| 改 | "Current Rotation" → 列全部在岗 |
| 保留 | override 卡(多选)、每格 backup Edit(开关开时) |
| 删 | "Rotation Start & Offset" 整卡、"Generate Schedule" 按钮 + 预览 Modal |

---

## 5. 迁移

1. migrate 建 `CategoryLayer` / `RealizedShift`,Category 加 `backup_enabled`。
2. 脚本:每个 team 的现有 segments → 1 个 `always` Layer(people 按 idx 展开,rotate_every 取段周期;段周期不一致的 team 标记人工重配),starts_at 取原 cycle_anchor_time。
3. 历史冻结:过去且 source=cycle-generate 的 ShiftInterval → RealizedShift;清理未来 baseline;override 行原样保留。
4. 切读路径到 §2.3。
5. 跑满一个周期确认后,删废弃列 / 接口 / 代码 / CategoryCycleSegment。

---

## 6. 实施顺序(本分支)

直接做实时模型,不做 P1 临时 cron(避免做了又删)。

1. **DB 模型 + migrate**(CategoryLayer / RealizedShift / backup_enabled)。
2. **Layer 求值 + ComputeScheduleWindow**。
3. **effective 改造**(过去快照 + 未来实时 + override 合并)。
4. **freeze sweeper**。
5. **notify / current 列全部**。
6. **Layer CRUD API + schedule/compute**;删 anchor/shift/generate。
7. **迁移脚本**。
8. **前端 Layer UI + 周历预览**;删旧卡片。
9. 清理废弃代码 / 列。

---

## 7. 待定(不阻塞开工)
- per-Layer 时区(自动 DST)是否预留 → 暂不做,先手动。
- 顺延按钮 → 暂不做,改 starts_at + 说明。
