diff --git a/docs/superpowers/plans/2026-08-20-duty-shift-dict-time.md b/docs/superpowers/plans/2026-08-20-duty-shift-dict-time.md new file mode 100644 index 000000000..13deafcfb --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-duty-shift-dict-time.md @@ -0,0 +1,965 @@ +# 值班排班 · 字典类型 / 工作安排 / 时段 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 值班类型与工作安排改为 `sys_dict` 可扩展;`duty_slot` 增加时段与多选工作安排、独立备注;管理端与公开 H5 同步展示;存量数据可迁移。 + +**Architecture:** 种子字典 `duty_shift_type` / `duty_work_plan`;`duty_slot` 扩 `work_plans`、`start_time`、`end_time`、`cross_day`,原 `remark` 改为真正备注;保存日配置时 upsert 字典项;序列化返回 label / timeLabel;公开抢班校验改为「当日存在该 slot」。 + +**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus;`pypinyin`(已有);pytest + +**Spec:** `docs/superpowers/specs/2026-08-20-duty-shift-dict-time-design.md` + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Create | `backend/scripts/migrate_20260820_duty_shift_dict_time.sql` | 字典种子 + ALTER + 回填 | +| Modify | `backend/app/models/duty_models.py` | slot 新字段;保留 SHIFT_* 预置常量 | +| Modify | `backend/app/schemas/duty.py` | 放宽 shiftType;新增 workPlans / 时段字段 | +| Modify | `backend/app/services/duty_schedule.py` | 序列化、保存、grab、字典 ensure、timeLabel | +| Modify | `backend/app/api/duty.py` | 可选 `POST .../dict/ensure` | +| Modify | `backend/tests/test_duty_schedule.py` | 新行为单测 | +| Modify | `web-admin/src/api/duty.ts` | 类型与 save body | +| Modify | `web-admin/src/api/public-duty.ts` | 公开 slot 类型 | +| Modify | `web-admin/src/views/duty/schedule/index.vue` | 配置弹窗 + 月历统计 | +| Modify | `web-admin/src/views/public/duty/index.vue` | H5 展示时段/工作安排/备注 | +| Sync | `docs/sql/` | 迁移说明或片段备注(按仓库习惯追加脚本即可) | + +--- + +### Task 1: SQL 迁移(字典 + ALTER + 回填) + +**Files:** +- Create: `backend/scripts/migrate_20260820_duty_shift_dict_time.sql` + +- [ ] **Step 1: 写迁移 SQL** + +```sql +-- backend/scripts/migrate_20260820_duty_shift_dict_time.sql +-- 值班排班:字典类型/工作安排 + slot 时段与 work_plans + +SET NAMES utf8mb4; + +-- 1) 字典类型 +INSERT INTO `sys_dict_type` ( + `id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) VALUES +( + 'DUTYDICTTYPE00000000000000000001', + '值班类型', + 'duty_shift_type', + 'shanxia2.0', + 90, + '值班排班班次类型', + '', + '', + NOW(), + '', + NOW(), + 0 +), +( + 'DUTYDICTTYPE00000000000000000002', + '值班工作安排', + 'duty_work_plan', + 'shanxia2.0', + 91, + '值班排班工作安排', + '', + '', + NOW(), + '', + NOW(), + 0 +) +ON DUPLICATE KEY UPDATE `dict_name` = VALUES(`dict_name`); + +-- 若库无基于 id 的 upsert,改用 NOT EXISTS 插入(MySQL 5.7): +-- INSERT INTO ... SELECT ... FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='duty_shift_type' AND tenant_no='shanxia2.0' AND del_flag=0); + +INSERT INTO `sys_dict_data` ( + `id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT * FROM ( + SELECT 'DUTYDICTDATA0000000000000000001' AS id, '早班' AS dict_label, 'morning' AS dict_value, '' AS tag_type, 'duty_shift_type' AS dict_type, 'shanxia2.0' AS tenant_no, 1 AS order_num, NULL AS remark, '' AS create_by, '' AS create_dept_id, NOW() AS create_time, '' AS update_by, NOW() AS update_time, 0 AS del_flag + UNION ALL SELECT 'DUTYDICTDATA0000000000000000002', '晚班', 'evening', '', 'duty_shift_type', 'shanxia2.0', 2, NULL, '', '', NOW(), '', NOW(), 0 + UNION ALL SELECT 'DUTYDICTDATA0000000000000000003', '值班', 'duty', '', 'duty_shift_type', 'shanxia2.0', 3, NULL, '', '', NOW(), '', NOW(), 0 + UNION ALL SELECT 'DUTYDICTDATA0000000000000000004', '配酸', 'peisuan', '', 'duty_work_plan', 'shanxia2.0', 1, NULL, '', '', NOW(), '', NOW(), 0 + UNION ALL SELECT 'DUTYDICTDATA0000000000000000005', '装卸', 'zhuangxie', '', 'duty_work_plan', 'shanxia2.0', 2, NULL, '', '', NOW(), '', NOW(), 0 +) AS t +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_dict_data` d + WHERE d.tenant_no = t.tenant_no AND d.dict_type = t.dict_type AND d.dict_value = t.dict_value AND d.del_flag = 0 +); + +-- 2) duty_slot 扩字段 +ALTER TABLE `duty_slot` + ADD COLUMN `work_plans` varchar(500) DEFAULT NULL COMMENT '工作安排dict_value逗号分隔' AFTER `capacity`, + ADD COLUMN `start_time` time DEFAULT NULL COMMENT '值班开始时间' AFTER `work_plans`, + ADD COLUMN `end_time` time DEFAULT NULL COMMENT '值班结束时间' AFTER `start_time`, + ADD COLUMN `cross_day` smallint(6) NOT NULL DEFAULT 0 COMMENT '结束是否次日0/1' AFTER `end_time`; + +-- 若列已存在会失败:上线前按环境手工跳过或拆成可重复执行片段。 + +-- 3) 回填时段 +UPDATE `duty_slot` +SET `start_time` = '08:00:00', `end_time` = '18:00:00', `cross_day` = 0 +WHERE `del_flag` = 0 AND `shift_type` = 'morning' AND `start_time` IS NULL; + +UPDATE `duty_slot` +SET `start_time` = '18:00:00', `end_time` = '07:00:00', `cross_day` = 1 +WHERE `del_flag` = 0 AND `shift_type` = 'evening' AND `start_time` IS NULL; + +-- 4) 回填工作安排(整段匹配);其它 remark 保留为备注 +UPDATE `duty_slot` +SET `work_plans` = 'peisuan', `remark` = NULL +WHERE `del_flag` = 0 AND TRIM(`remark`) = '配酸' AND (`work_plans` IS NULL OR `work_plans` = ''); + +UPDATE `duty_slot` +SET `work_plans` = 'zhuangxie', `remark` = NULL +WHERE `del_flag` = 0 AND TRIM(`remark`) = '装卸' AND (`work_plans` IS NULL OR `work_plans` = ''); + +UPDATE `duty_slot` +SET `work_plans` = 'peisuan,zhuangxie', `remark` = NULL +WHERE `del_flag` = 0 + AND (TRIM(`remark`) IN ('配酸,装卸', '配酸,装卸', '装卸,配酸', '装卸,配酸')) + AND (`work_plans` IS NULL OR `work_plans` = ''); +``` + +- [ ] **Step 2: 在本地库执行并确认** + +```bash +# 在已配置的 MySQL 上执行(按本地连接替换) +mysql -u... -p... your_db < backend/scripts/migrate_20260820_duty_shift_dict_time.sql +``` + +Expected: 无报错;`SHOW COLUMNS FROM duty_slot` 含新列;字典 options 可查到 5 条预置。 + +- [ ] **Step 3: Commit** + +```bash +git add backend/scripts/migrate_20260820_duty_shift_dict_time.sql +git commit -m "chore(duty): add SQL migration for shift dict and slot time fields" +``` + +--- + +### Task 2: 模型与 Schema + +**Files:** +- Modify: `backend/app/models/duty_models.py` +- Modify: `backend/app/schemas/duty.py` + +- [ ] **Step 1: 扩展 DutySlot 模型** + +在 `DutySlot` 中 `capacity` 之后增加(需 `from sqlalchemy import Time`): + +```python +work_plans = Column(String(500), nullable=True, comment="工作安排dict_value逗号分隔") +start_time = Column(Time, nullable=True, comment="值班开始时间") +end_time = Column(Time, nullable=True, comment="值班结束时间") +cross_day = Column(SmallInteger, nullable=False, default=0, comment="结束是否次日") +``` + +保留 `SHIFT_MORNING` / `SHIFT_EVENING` / `SHIFT_DUTY` / `SHIFT_LABELS` 作为预置默认文案回退。 + +增加常量: + +```python +DICT_SHIFT_TYPE = "duty_shift_type" +DICT_WORK_PLAN = "duty_work_plan" +DEFAULT_SHIFT_TIMES = { + SHIFT_MORNING: ("08:00", "18:00", 0), + SHIFT_EVENING: ("18:00", "07:00", 1), +} +``` + +- [ ] **Step 2: 放宽 Pydantic schema** + +```python +from datetime import date, time +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, Field, field_validator + +LinkStatus = Literal["open", "closed"] +SourceType = Literal["assign", "grab"] + + +class DutyAssignmentIn(BaseModel): + source: SourceType = "assign" + userId: Optional[str] = None + personName: str = "" + + +class DutySlotSaveIn(BaseModel): + shiftType: str = Field(min_length=1, max_length=32) + capacity: int = Field(ge=1, le=99) + workPlans: List[str] = [] + remark: Optional[str] = Field(default=None, max_length=500) + startTime: Optional[str] = Field(default=None, description="HH:mm") + endTime: Optional[str] = Field(default=None, description="HH:mm") + crossDay: bool = False + assignments: List[DutyAssignmentIn] = [] + + @field_validator("workPlans", mode="before") + @classmethod + def _split_work_plans(cls, v): + if v is None: + return [] + if isinstance(v, str): + return [p.strip() for p in v.replace(",", ",").split(",") if p.strip()] + return v + + +class DutyDaySaveBody(BaseModel): + dutyDate: date + slots: List[DutySlotSaveIn] + linkStatus: Optional[LinkStatus] = None + + +class DutyLinkBody(BaseModel): + linkStatus: LinkStatus + + +class DutyGrabBody(BaseModel): + shiftType: str = Field(min_length=1, max_length=32) + userId: str = Field(min_length=1, max_length=64) + + +class DutyDictEnsureBody(BaseModel): + dictType: Literal["duty_shift_type", "duty_work_plan"] + label: str = Field(min_length=1, max_length=100) +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/models/duty_models.py backend/app/schemas/duty.py +git commit -m "feat(duty): extend slot model and schemas for work plans and time" +``` + +--- + +### Task 3: 字典 ensure、时段格式化与序列化(TDD) + +**Files:** +- Modify: `backend/app/services/duty_schedule.py` +- Modify: `backend/tests/test_duty_schedule.py` + +- [ ] **Step 1: 写失败单测(纯函数优先)** + +在 `backend/tests/test_duty_schedule.py` 追加: + +```python +from app.services.duty_schedule import format_time_label, parse_hhmm, join_work_plans, split_work_plans + + +def test_parse_hhmm(): + assert parse_hhmm("08:00").hour == 8 + assert parse_hhmm("18:30").minute == 30 + assert parse_hhmm(None) is None + assert parse_hhmm("") is None + + +def test_format_time_label_same_day(): + assert format_time_label(parse_hhmm("08:00"), parse_hhmm("18:00"), False) == "08:00–18:00" + + +def test_format_time_label_cross_day(): + assert format_time_label(parse_hhmm("18:00"), parse_hhmm("07:00"), True) == "18:00–次日07:00" + + +def test_format_time_label_empty(): + assert format_time_label(None, None, False) is None + + +def test_work_plans_join_split(): + assert join_work_plans(["peisuan", "zhuangxie"]) == "peisuan,zhuangxie" + assert split_work_plans("peisuan,zhuangxie") == ["peisuan", "zhuangxie"] + assert split_work_plans(None) == [] +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py::test_parse_hhmm tests/test_duty_schedule.py::test_format_time_label_same_day tests/test_duty_schedule.py::test_format_time_label_cross_day tests/test_duty_schedule.py::test_format_time_label_empty tests/test_duty_schedule.py::test_work_plans_join_split -v +``` + +Expected: FAIL(import / 未定义) + +- [ ] **Step 3: 实现辅助函数** + +在 `duty_schedule.py` 顶部附近增加: + +```python +from datetime import date, datetime, time +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from pypinyin import Style, lazy_pinyin + +from app.models.duty_models import ( + DEFAULT_SHIFT_TIMES, # 若放在 models + DICT_SHIFT_TYPE, + DICT_WORK_PLAN, + SHIFT_LABELS, + ... +) +from app.models.models import SysDictData, SysRole, SysUser, SysUserRole +def parse_hhmm(value: Optional[str]) -> Optional[time]: + raw = (value or "").strip() + if not raw: + return None + parts = raw.split(":") + if len(parts) < 2: + raise DutyBizError("时间格式须为 HH:mm") + h, m = int(parts[0]), int(parts[1]) + if not (0 <= h <= 23 and 0 <= m <= 59): + raise DutyBizError("时间格式须为 HH:mm") + return time(hour=h, minute=m) + + +def format_hhmm(t: Optional[time]) -> Optional[str]: + if t is None: + return None + return f"{t.hour:02d}:{t.minute:02d}" + + +def format_time_label( + start: Optional[time], end: Optional[time], cross_day: bool +) -> Optional[str]: + if start is None or end is None: + return None + s, e = format_hhmm(start), format_hhmm(end) + if cross_day: + return f"{s}–次日{e}" + return f"{s}–{e}" + + +def split_work_plans(raw: Optional[str]) -> List[str]: + if not raw: + return [] + return [p.strip() for p in str(raw).replace(",", ",").split(",") if p.strip()] + + +def join_work_plans(values: Sequence[str]) -> Optional[str]: + parts = [v.strip() for v in values if (v or "").strip()] + return ",".join(parts) if parts else None + + +def build_dict_value_from_label(label: str) -> str: + text = (label or "").strip() + parts: List[str] = [] + for ch in text: + if "\u4e00" <= ch <= "\u9fff": + pys = lazy_pinyin(ch, style=Style.NORMAL) + if pys and pys[0]: + parts.append(str(pys[0]).lower()) + elif ch.isalnum(): + parts.append(ch.lower()) + base = "".join(parts)[:32] or "item" + return base +``` + +字典 ensure(同 label 复用;同 value 冲突加后缀): + +```python +def ensure_dict_data( + db: Session, + *, + tenant_no: str, + dict_type: str, + label: str, + user_id: str = "", + preferred_value: Optional[str] = None, +) -> Dict[str, str]: + label = (label or "").strip() + if not label: + raise DutyBizError("字典标签不能为空") + if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN): + raise DutyBizError("不支持的字典类型") + + existing_label = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == dict_type, + SysDictData.dict_label == label, + SysDictData.del_flag == 0, + ) + .first() + ) + if existing_label: + return {"label": existing_label.dict_label, "value": existing_label.dict_value} + + value = (preferred_value or "").strip() or build_dict_value_from_label(label) + for _ in range(8): + clash = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == dict_type, + SysDictData.dict_value == value, + SysDictData.del_flag == 0, + ) + .first() + ) + if not clash: + break + value = f"{value[:27]}_{secrets.token_hex(2)}" + else: + raise DutyBizError("无法生成唯一字典值") + + # 勿调用 dict_data_svc.create_dict_data(其内部会 commit,打断 save_day 事务) + from app.core.utils import generate_uuid + + obj = SysDictData( + id=generate_uuid(), + dict_label=label, + dict_value=value, + dict_type=dict_type, + tag_type="", + tenant_no=tenant_no, + order_num=0, + remark=None, + create_by=user_id or None, + create_dept_id="", + create_time=datetime.now(), + update_by=user_id or None, + update_time=datetime.now(), + del_flag=0, + ) + db.add(obj) + db.flush() + return {"label": obj.dict_label, "value": obj.dict_value} + + +def _load_dict_label_map(db: Session, tenant_no: str, dict_type: str) -> Dict[str, str]: + rows = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == dict_type, + SysDictData.del_flag == 0, + ) + .all() + ) + return {r.dict_value: r.dict_label for r in rows} +``` + +- [ ] **Step 4: 跑测通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py::test_parse_hhmm tests/test_duty_schedule.py::test_format_time_label_same_day tests/test_duty_schedule.py::test_format_time_label_cross_day tests/test_duty_schedule.py::test_format_time_label_empty tests/test_duty_schedule.py::test_work_plans_join_split -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/duty_schedule.py backend/tests/test_duty_schedule.py backend/app/models/duty_models.py +git commit -m "feat(duty): add time/work-plan helpers and dict ensure" +``` + +--- + +### Task 4: 序列化、save_day、grab 改造 + +**Files:** +- Modify: `backend/app/services/duty_schedule.py` +- Modify: `backend/app/api/duty.py` +- Modify: `backend/tests/test_duty_schedule.py` + +- [ ] **Step 1: 写 grab 放宽单测** + +```python +def test_grab_allows_custom_shift_type_when_slot_exists(): + """自定义 shift_type 只要当日存在 slot 即可,不必在 SHIFT_LABELS。""" + db = MagicMock() + day = SimpleNamespace( + id="day1", + tenant_no="t1", + link_status=LINK_OPEN, + duty_date=date(2026, 8, 20), + access_token="tok", + del_flag=0, + ) + slot = SimpleNamespace( + id="slot1", + duty_day_id="day1", + shift_type="zhongban", + capacity=2, + del_flag=0, + ) + user = SimpleNamespace( + id="u1", + tenant_no="t1", + nick_name="王五", + user_no="wangwu", + status="1", + del_flag=0, + ) + day_q = MagicMock() + day_q.filter.return_value.with_for_update.return_value.first.return_value = day + slot_q = MagicMock() + slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot + user_q = MagicMock() + user_q.filter.return_value.first.return_value = user + role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID) + role_q = MagicMock() + role_q.filter.return_value.first.return_value = role + ur_q = MagicMock() + ur_q.filter.return_value.first.return_value = SimpleNamespace( + user_id="u1", role_id=DUTY_STAFF_ROLE_ID + ) + + def query_side_effect(model): + name = getattr(model, "__name__", str(model)) + if name == "DutyDay": + return day_q + if name == "DutySlot": + return slot_q + if name == "SysUser": + return user_q + if name == "SysRole": + return role_q + if name == "SysUserRole": + return ur_q + if name == "SysDictData": + dq = MagicMock() + dq.filter.return_value.all.return_value = [] + dq.filter.return_value.first.return_value = None + return dq + aq = MagicMock() + aq.filter.return_value.count.return_value = 0 + aq.filter.return_value.first.return_value = None + return aq + + db.query.side_effect = query_side_effect + result = grab(db, "tok", "zhongban", "u1") + assert result["shiftType"] == "zhongban" + assert result["status"] == "ok" +``` + +- [ ] **Step 2: 跑测应因仍校验 SHIFT_LABELS 失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py::test_grab_allows_custom_shift_type_when_slot_exists -v +``` + +Expected: FAIL match `班次类型无效` 或未实现 + +- [ ] **Step 3: 改造 `_slot_dict` / `save_day` / `grab`** + +`_slot_dict` 签名改为接收 label maps: + +```python +def _slot_dict( + slot: DutySlot, + assignments: Sequence[DutyAssignment], + *, + shift_labels: Dict[str, str], + work_plan_labels: Dict[str, str], +) -> Dict[str, Any]: + active = [a for a in assignments if a.del_flag == 0] + plans = split_work_plans(getattr(slot, "work_plans", None)) + cross = bool(getattr(slot, "cross_day", 0) or 0) + start = getattr(slot, "start_time", None) + end = getattr(slot, "end_time", None) + return { + "id": slot.id, + "shiftType": slot.shift_type, + "shiftLabel": shift_labels.get(slot.shift_type) + or SHIFT_LABELS.get(slot.shift_type, slot.shift_type), + "capacity": slot.capacity, + "taken": len(active), + "workPlans": plans, + "workPlanLabels": [work_plan_labels.get(v, v) for v in plans], + "remark": (slot.remark or "").strip() or None, + "startTime": format_hhmm(start), + "endTime": format_hhmm(end), + "crossDay": cross, + "timeLabel": format_time_label(start, end, cross), + "assignments": [_assignment_dict(a) for a in active], + } +``` + +`_serialize_day` / `_day_dict`:按 `day.tenant_no` 加载两份 label map 再序列化 slots。 + +`save_day` 循环内写入: + +```python +plans = join_work_plans(slot_in.workPlans or []) +# 确保 work plan values 在字典中存在(按 value 查找;不存在则用 value 当 label ensure) +for v in (slot_in.workPlans or []): + row = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == DICT_WORK_PLAN, + SysDictData.dict_value == v, + SysDictData.del_flag == 0, + ) + .first() + ) + if not row: + ensure_dict_data( + db, + tenant_no=tenant_no, + dict_type=DICT_WORK_PLAN, + label=v, + preferred_value=v, + user_id=user_id, + ) + +# shift type:若非预置且字典无 value,则 ensure(label 回退 SHIFT_LABELS 或 value) +if slot_in.shiftType not in SHIFT_LABELS: + row = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == DICT_SHIFT_TYPE, + SysDictData.dict_value == slot_in.shiftType, + SysDictData.del_flag == 0, + ) + .first() + ) + if not row: + ensure_dict_data( + db, + tenant_no=tenant_no, + dict_type=DICT_SHIFT_TYPE, + label=slot_in.shiftType, + preferred_value=slot_in.shiftType, + user_id=user_id, + ) + +start_t = parse_hhmm(slot_in.startTime) +end_t = parse_hhmm(slot_in.endTime) +cross = 1 if slot_in.crossDay else 0 +# 若只填了一端,拒绝 +if (start_t is None) ^ (end_t is None): + raise DutyBizError("值班时间须同时填写开始与结束,或全部留空") + +# create/update DutySlot 时设置 work_plans/start_time/end_time/cross_day/remark +``` + +`grab`:删除 `if shift_type not in SHIFT_LABELS`;slot 不存在仍报「该班次不存在」;`shiftLabel` 用字典 map + `SHIFT_LABELS` 回退。 + +- [ ] **Step 4: API ensure 端点** + +在 `backend/app/api/duty.py`: + +```python +@router.post("/dict/ensure") +def ensure_duty_dict( + body: DutyDictEnsureBody, + db: Session = Depends(get_db), + user=Depends(get_current_user), +): + tenant_no = ... + try: + data = duty_schedule.ensure_dict_data( + db, + tenant_no=tenant_no, + dict_type=body.dictType, + label=body.label, + user_id=user.get("user_id") or "", + ) + db.commit() + return success_response(data) + except DutyBizError as e: + ... +``` + +- [ ] **Step 5: 跑相关单测** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v +``` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/duty_schedule.py backend/app/api/duty.py backend/tests/test_duty_schedule.py +git commit -m "feat(duty): persist work plans and times; relax grab shift types" +``` + +--- + +### Task 5: 前端 API 类型 + +**Files:** +- Modify: `web-admin/src/api/duty.ts` +- Modify: `web-admin/src/api/public-duty.ts` + +- [ ] **Step 1: 更新 `duty.ts`** + +```typescript +export type ShiftType = string // 预置 morning|evening|duty,亦可自定义 dict_value + +export interface DutySlotItem { + id?: string + shiftType: ShiftType + shiftLabel?: string + capacity: number + taken?: number + workPlans?: string[] + workPlanLabels?: string[] + remark?: string | null + startTime?: string | null + endTime?: string | null + crossDay?: boolean + timeLabel?: string | null + assignments: DutyAssignmentItem[] +} + +export function saveDutyDay(data: { + dutyDate: string + slots: Array<{ + shiftType: ShiftType + capacity: number + workPlans?: string[] + remark?: string | null + startTime?: string | null + endTime?: string | null + crossDay?: boolean + assignments: DutyAssignmentItem[] + }> + linkStatus?: LinkStatus +}) { ... } + +export function ensureDutyDict(data: { + dictType: 'duty_shift_type' | 'duty_work_plan' + label: string +}) { + return request.post<{ label: string; value: string }>({ + url: '/web/admin/duty/dict/ensure', + data + }) +} +``` + +- [ ] **Step 2: 更新 `public-duty.ts`** + +`PublicDutySlot` / `PublicDutyGrabResult` 的 `shiftType` 改为 `string`;slot 增加 `workPlans`、`workPlanLabels`、`startTime`、`endTime`、`crossDay`、`timeLabel`;`remark` 语义为备注。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/api/duty.ts web-admin/src/api/public-duty.ts +git commit -m "feat(duty): update frontend API types for dict and time fields" +``` + +--- + +### Task 6: 管理端排班页 + +**Files:** +- Modify: `web-admin/src/views/duty/schedule/index.vue` + +- [ ] **Step 1: 加载字典 options** + +```typescript +import { fetchDictDataOptions } from '@/api/admin' +import { ensureDutyDict, ... } from '@/api/duty' + +const shiftTypeOptions = ref>([]) +const workPlanOptions = ref>([]) + +async function loadDictOptions() { + const [shifts, plans] = await Promise.all([ + fetchDictDataOptions('duty_shift_type'), + fetchDictDataOptions('duty_work_plan') + ]) + // Api.Dict.DictDataOption: { label, value } + shiftTypeOptions.value = (shifts || []).map((x) => ({ label: x.label, value: x.value })) + workPlanOptions.value = (plans || []).map((x) => ({ label: x.label, value: x.value })) +} +``` + +在 `onMounted` 中调用。 + +- [ ] **Step 2: formSlots 结构改造** + +```typescript +type FormSlot = { + shiftType: string + enabled: boolean + capacity: number + workPlans: string[] + remark: string + startTime: string + endTime: string + crossDay: boolean + pendingUserId: string + assignments: DutyAssignmentItem[] +} + +const PRESET_TYPES = ['morning', 'evening', 'duty'] as const + +function defaultTimesFor(shiftType: string) { + if (shiftType === 'morning') return { startTime: '08:00', endTime: '18:00', crossDay: false } + if (shiftType === 'evening') return { startTime: '18:00', endTime: '07:00', crossDay: true } + return { startTime: '', endTime: '', crossDay: false } +} + +function blankFormSlots(): FormSlot[] { + return PRESET_TYPES.map((t) => ({ + shiftType: t, + enabled: t === 'morning' || t === 'evening', + capacity: 1, + workPlans: [], + remark: '', + ...defaultTimesFor(t), + pendingUserId: '', + assignments: [] + })) +} +``` + +打开详情时:按返回 slots 回填;缺失的预置类型补空卡;额外自定义类型追加卡片。 + +- [ ] **Step 3: 弹窗模板字段** + +每条 slot 卡片增加(Fresh Mint,`label-position="top"`): + +- 时段:两个 `el-time-picker` `value-format="HH:mm"`;`crossDay` 可用 checkbox「跨日」,当结束<开始时自动勾选 +- 工作安排:`el-select` multiple filterable allow-create;`@create`/change 时对新建 label 调 `ensureDutyDict({ dictType: 'duty_work_plan', label })`,用返回 `value` 写入 `workPlans` +- 备注:`el-input` textarea(原工作安排输入位改为此) +- 「添加班次」:下拉选未占用的 `shiftTypeOptions`,allow-create → `ensureDutyDict({ dictType: 'duty_shift_type', label })` 后 push 新 FormSlot + +保存 body: + +```typescript +slots: formSlots.value + .filter((s) => s.enabled) + .map((s) => ({ + shiftType: s.shiftType, + capacity: s.capacity, + workPlans: s.workPlans, + remark: s.remark || null, + startTime: s.startTime || null, + endTime: s.endTime || null, + crossDay: !!s.crossDay, + assignments: s.assignments + })) +``` + +- [ ] **Step 4: 月历图例 / 筛选 / 统计** + +- `shiftLabel` 优先用接口或字典 map,回退旧 `SHIFT_LABELS` +- 筛选:预置三项 + 动态「其他」或按字典列出(至少预置三项可点) +- 统计列:morning/evening/duty + `other` 计数 + +CSS:未知类型用 `.dot.other` 中性色。 + +- [ ] **Step 5: 手动点检** + +`pnpm dev` → `/#/duty/schedule`:新建日默认三类型;改时段;多选工作安排并新建;添加自定义类型;保存刷新后仍在。 + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/views/duty/schedule/index.vue +git commit -m "feat(duty): admin schedule UI for dict types, work plans, and time" +``` + +--- + +### Task 7: 公开移动端 H5 同步 + +**Files:** +- Modify: `web-admin/src/views/public/duty/index.vue` + +- [ ] **Step 1: 班次卡片展示** + +在名额行下方: + +```vue +

值班时间:{{ slot.timeLabel }}

+

+ 工作安排:{{ slot.workPlanLabels.join('、') }} +

+

备注:{{ slot.remark }}

+``` + +去掉把 `remark` 当工作安排的旧文案(若仍显示 `工作安排:{{ slot.remark }}` 则替换)。 + +点样式:`:class="dotClass(slot.shiftType)"`,仅 `morning|evening|duty` 用旧色,其它用 `other`。 + +- [ ] **Step 2: 成功页摘要** + +成功态增加时段、工作安排(若接口返回;否则成功后回列表可见)。若 grab 响应暂无这些字段,至少 `shiftLabel` 正确;可选在 grab 响应中带回 `timeLabel` / `workPlanLabels`(与 Task 4 对齐时一并返回)。 + +- [ ] **Step 3: 点检公开页** + +复制某日链接 → 手机宽度或 DevTools:可见时段与工作安排;抢班成功。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/views/public/duty/index.vue +git commit -m "feat(duty): sync public H5 with time, work plans, and remark" +``` + +--- + +### Task 8: 回归与收尾 + +**Files:** +- 视需要:在 `docs/sql/shanxia2.0.sql` 旁注或只保留 scripts 迁移(按仓库惯例,本计划以 `backend/scripts/` 为准) + +- [ ] **Step 1: 后端全量值班测** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v +``` + +Expected: PASS + +- [ ] **Step 2: 前端类型检查(若耗时过长可仅 lint 改动文件)** + +```bash +cd web-admin && pnpm exec eslint "src/views/duty/schedule/index.vue" "src/views/public/duty/index.vue" "src/api/duty.ts" "src/api/public-duty.ts" +``` + +- [ ] **Step 3: 按 spec 验收清单手工过一遍**(见设计文档「测试要点」) + +- [ ] **Step 4: 最终 commit(若有遗漏文档)** + +```bash +git add docs/superpowers/plans/2026-08-20-duty-shift-dict-time.md +git commit -m "docs: add duty shift dict and time implementation plan" +``` + +(若本 plan 文件已在此前单独提交则跳过。) + +--- + +## Spec 覆盖自检 + +| Spec 要求 | Task | +|-----------|------| +| 字典两类 + 预置 | Task 1 | +| slot 扩字段 + 迁移回填 | Task 1 | +| shiftType 存 dict_value / 不改历史编码 | Task 2–4 | +| workPlans 多选 + remark 备注分离 | Task 4、6、7 | +| 早/晚默认时段、值班空 | Task 1 回填 + Task 6 默认 | +| 同日同类型唯一 | 已有 save_day 校验,保持 | +| ensure 字典 / allow-create | Task 3–4、6 | +| 公开 H5 同步 | Task 7 | +| grab 放宽 | Task 4 | +| 月历统计「其他」 | Task 6 | + +## 执行说明 + +实现时优先 **subagent-driven-development**(每 Task 新子代理 + 两段审查),或本会话 **executing-plans** 按检查点推进。