docs: add duty shift dict and time implementation plan
Break the confirmed design into SQL, backend, admin UI, and public H5 tasks with TDD steps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
361e923396
commit
1850c7c3df
1 file changed
+965
@@ -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<Array<{ label: string; value: string }>>([])
|
||||
const workPlanOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
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
|
||||
<p v-if="slot.timeLabel" class="slot-time">值班时间:<strong>{{ slot.timeLabel }}</strong></p>
|
||||
<p v-if="slot.workPlanLabels?.length" class="slot-remark">
|
||||
工作安排:<strong>{{ slot.workPlanLabels.join('、') }}</strong>
|
||||
</p>
|
||||
<p v-if="slot.remark" class="slot-note">备注:{{ slot.remark }}</p>
|
||||
```
|
||||
|
||||
去掉把 `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** 按检查点推进。
|
||||
Reference in new issue
Block a user