From 42ec16b0cf55c6891f2d8c1943dbebf1b65f6fef Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 17 Aug 2026 18:18:02 +0800 Subject: [PATCH] docs: add implementation plan for duty staff role filter. Co-authored-by: Cursor --- .../plans/2026-08-17-duty-staff-role.md | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-duty-staff-role.md diff --git a/docs/superpowers/plans/2026-08-17-duty-staff-role.md b/docs/superpowers/plans/2026-08-17-duty-staff-role.md new file mode 100644 index 000000000..fb2bb7766 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-duty-staff-role.md @@ -0,0 +1,487 @@ +# 值班排班 ·「值班人员」角色筛选 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:** 仅「值班人员」角色下的启用用户可出现在排班配置下拉与公开抢班名单中;新指定/新抢班后端强制校验;历史 assignment 仍展示。 + +**Architecture:** 迁移插入固定 UUID 角色「值班人员」(无菜单)。在 `duty_schedule.py` 集中实现角色解析与用户过滤;管理端新增 `GET /web/admin/duty/users/options`;公开页 `users` 与 `grab`/`save_day` 复用同一校验。前端配置页改调值班专用 options。 + +**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus;pytest;MySQL 迁移脚本 + +**Spec:** `docs/superpowers/specs/2026-08-17-duty-staff-role-design.md` + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Create | `backend/scripts/migrate_20260817_duty_staff_role.sql` | 幂等插入「值班人员」角色 | +| Modify | `backend/app/models/duty_models.py` | 增加 `DUTY_STAFF_ROLE_ID` / `DUTY_STAFF_ROLE_NAME` 常量 | +| Modify | `backend/app/services/duty_schedule.py` | 角色解析、名单过滤、指定/抢班校验;公开 users 复用 | +| Modify | `backend/app/api/duty.py` | 注册 `GET /users/options` | +| Modify | `backend/tests/test_duty_schedule.py` | 过滤/拒绝无角色;修补现有 grab mock | +| Modify | `web-admin/src/api/duty.ts` | `fetchDutyUserOptions` | +| Modify | `web-admin/src/views/duty/schedule/index.vue` | 改用专用 options + 文案提示 | + +--- + +### Task 1: 迁移脚本插入「值班人员」角色 + +**Files:** +- Create: `backend/scripts/migrate_20260817_duty_staff_role.sql` + +- [ ] **Step 1: 写迁移 SQL** + +```sql +-- backend/scripts/migrate_20260817_duty_staff_role.sql +-- 值班排班可排班名单角色:仅作标签,不挂菜单 + +INSERT INTO `sys_role` ( + `id`, `role_name`, `data_scope`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) VALUES ( + 'R65CAAE057914CCCAE2A81BE627A5101', + '值班人员', + '4', + '1', + 'shanxia2.0', + 90, + '值班排班可指定/抢班名单;无菜单权限', + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +) +ON DUPLICATE KEY UPDATE + `role_name` = VALUES(`role_name`), + `status` = VALUES(`status`), + `remark` = VALUES(`remark`), + `del_flag` = 0; +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/scripts/migrate_20260817_duty_staff_role.sql +git commit -m "chore(db): add duty staff role for schedule eligibility" +``` + +--- + +### Task 2: 服务层常量 + 角色解析 / 名单过滤(TDD) + +**Files:** +- Modify: `backend/app/models/duty_models.py` +- Modify: `backend/app/services/duty_schedule.py` +- Modify: `backend/tests/test_duty_schedule.py` + +- [ ] **Step 1: 在 `duty_models.py` 增加常量(文件顶部常量区附近)** + +```python +# 可排班名单角色(sys_role 固定 id;无菜单,仅标签) +DUTY_STAFF_ROLE_ID = "R65CAAE057914CCCAE2A81BE627A5101" +DUTY_STAFF_ROLE_NAME = "值班人员" +``` + +- [ ] **Step 2: 写失败单测(追加到 `test_duty_schedule.py`)** + +```python +from app.models.duty_models import DUTY_STAFF_ROLE_ID, DUTY_STAFF_ROLE_NAME +from app.services.duty_schedule import ( + assert_user_is_duty_staff, + list_duty_staff_users, + resolve_duty_staff_role_id, +) + + +def test_resolve_duty_staff_role_id_by_fixed_id(): + db = MagicMock() + role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID, role_name=DUTY_STAFF_ROLE_NAME, del_flag=0) + q = MagicMock() + q.filter.return_value.first.return_value = role + db.query.return_value = q + assert resolve_duty_staff_role_id(db, "shanxia2.0") == DUTY_STAFF_ROLE_ID + + +def test_list_duty_staff_users_empty_when_no_role(): + db = MagicMock() + q = MagicMock() + q.filter.return_value.first.return_value = None # resolve role → None twice + db.query.return_value = q + assert list_duty_staff_users(db, "t1") == [] + + +def test_assert_user_is_duty_staff_rejects_without_role(): + db = MagicMock() + # resolve role id + role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID) + role_q = MagicMock() + role_q.filter.return_value.first.return_value = role + # user-role link missing + ur_q = MagicMock() + ur_q.filter.return_value.first.return_value = None + + def query_side_effect(model): + name = getattr(model, "__name__", "") + if name == "SysRole": + return role_q + if name == "SysUserRole": + return ur_q + return MagicMock() + + db.query.side_effect = query_side_effect + with pytest.raises(DutyBizError, match="值班人员"): + assert_user_is_duty_staff(db, "t1", "u1") + + +def test_grab_rejects_non_duty_staff(): + 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, + ) + user = SimpleNamespace( + id="u1", + tenant_no="t1", + nick_name="李四", + user_no="lisi", + status="1", + del_flag=0, + ) + day_q = MagicMock() + day_q.filter.return_value.with_for_update.return_value.first.return_value = day + 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 = None + + def query_side_effect(model): + name = getattr(model, "__name__", "") + if name == "DutyDay": + return day_q + if name == "SysUser": + return user_q + if name == "SysRole": + return role_q + if name == "SysUserRole": + return ur_q + return MagicMock() + + db.query.side_effect = query_side_effect + with pytest.raises(DutyBizError, match="值班人员"): + grab(db, "tok", SHIFT_EVENING, "u1") +``` + +- [ ] **Step 3: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py::test_resolve_duty_staff_role_id_by_fixed_id tests/test_duty_schedule.py::test_list_duty_staff_users_empty_when_no_role tests/test_duty_schedule.py::test_assert_user_is_duty_staff_rejects_without_role tests/test_duty_schedule.py::test_grab_rejects_non_duty_staff -v +``` + +Expected: FAIL(函数未定义或 grab 未校验角色) + +- [ ] **Step 4: 实现服务函数(`duty_schedule.py`)** + +在文件顶部增加导入: + +```python +from app.models.duty_models import ( + # ...existing... + DUTY_STAFF_ROLE_ID, + DUTY_STAFF_ROLE_NAME, +) +from app.models.models import SysRole, SysUser, SysUserRole +``` + +(若已有 `SysUser` 导入则合并,勿重复。) + +新增: + +```python +def resolve_duty_staff_role_id(db: Session, tenant_no: str) -> Optional[str]: + """按固定 id 查找「值班人员」角色;找不到再按角色名兜底。""" + role = ( + db.query(SysRole) + .filter( + SysRole.id == DUTY_STAFF_ROLE_ID, + SysRole.tenant_no == tenant_no, + SysRole.del_flag == 0, + ) + .first() + ) + if role: + return role.id + role = ( + db.query(SysRole) + .filter( + SysRole.role_name == DUTY_STAFF_ROLE_NAME, + SysRole.tenant_no == tenant_no, + SysRole.del_flag == 0, + SysRole.status == "1", + ) + .first() + ) + return role.id if role else None + + +def list_duty_staff_users(db: Session, tenant_no: str) -> List[Dict[str, str]]: + """本租户启用且绑定「值班人员」角色的用户(公开页 users / 管理端 options)。""" + role_id = resolve_duty_staff_role_id(db, tenant_no) + if not role_id: + return [] + rows = ( + db.query(SysUser) + .join( + SysUserRole, + and_( + SysUserRole.user_id == SysUser.id, + SysUserRole.role_id == role_id, + SysUserRole.del_flag == 0, + SysUserRole.tenant_no == tenant_no, + ), + ) + .filter( + SysUser.tenant_no == tenant_no, + SysUser.del_flag == 0, + SysUser.status == "1", + ) + .order_by(SysUser.nick_name.asc(), SysUser.user_no.asc()) + .all() + ) + out: List[Dict[str, str]] = [] + seen = set() + for r in rows: + if r.id in seen: + continue + seen.add(r.id) + label = (r.nick_name or "").strip() or (r.user_no or "").strip() + if not label: + continue + out.append({"id": r.id, "label": label}) + return out + + +def assert_user_is_duty_staff(db: Session, tenant_no: str, user_id: str) -> None: + role_id = resolve_duty_staff_role_id(db, tenant_no) + if not role_id: + raise DutyBizError("未配置「值班人员」角色,请联系管理员") + link = ( + db.query(SysUserRole) + .filter( + SysUserRole.user_id == user_id, + SysUserRole.role_id == role_id, + SysUserRole.tenant_no == tenant_no, + SysUserRole.del_flag == 0, + ) + .first() + ) + if not link: + raise DutyBizError("仅可选择「值班人员」角色的用户") +``` + +确保 `from sqlalchemy import and_`(若尚未导入)。 + +将 `_list_public_users` 改为: + +```python +def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]: + return list_duty_staff_users(db, tenant_no) +``` + +在 `_resolve_grab_user` 成功查到 user 后调用: + +```python + assert_user_is_duty_staff(db, tenant_no, user.id) + return user +``` + +在 `save_day` 写入每个 `SOURCE_ASSIGN` 且有 `uid` 时校验(写入 `db.add(DutyAssignment(...))` 之前): + +```python + if item.source == SOURCE_ASSIGN and uid: + assert_user_is_duty_staff(db, tenant_no, uid) +``` + +更新 `__all__` 导出:`list_duty_staff_users`, `assert_user_is_duty_staff`, `resolve_duty_staff_role_id`。 + +- [ ] **Step 5: 修补现有会查到用户的 grab 单测**,在 mock 中让 `SysUserRole.filter...first` 返回非空对象(例如 `SimpleNamespace(id="ur1")`),并为 `SysRole` 返回固定角色;否则 `test_grab_rejects_when_full` / `test_grab_rejects_duplicate_same_shift` 会先因无角色失败。 + +辅助写法(两处 `query_side_effect` 内增加): + +```python + if name == "SysRole": + rq = MagicMock() + rq.filter.return_value.first.return_value = SimpleNamespace(id=DUTY_STAFF_ROLE_ID) + return rq + if name == "SysUserRole": + urq = MagicMock() + urq.filter.return_value.first.return_value = SimpleNamespace(id="ur1") + return urq +``` + +- [ ] **Step 6: 跑全文件测试** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_duty_schedule.py -v +``` + +Expected: 全部 PASS + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/models/duty_models.py backend/app/services/duty_schedule.py backend/tests/test_duty_schedule.py +git commit -m "feat(duty): restrict assign and grab to duty staff role" +``` + +--- + +### Task 3: 管理端 options API + +**Files:** +- Modify: `backend/app/api/duty.py` + +- [ ] **Step 1: 增加路由(放在 `month_summary` 之前或之后均可)** + +```python +@router.get("/users/options") +def duty_user_options( + _user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """值班可安排人员:仅「值班人员」角色下的启用用户。""" + tenant_no = _user.get("tenant_no", "") or "" + rows = duty_svc.list_duty_staff_users(db, tenant_no) + out = [{"label": r["label"], "value": r["id"]} for r in rows] + return success_response(out) +``` + +- [ ] **Step 2: 手动冒烟(可选,需本地库已跑迁移)** + +```bash +# 执行迁移后,用已登录 admin token 请求 +# GET /api/web/admin/duty/users/options +# 期望:未赋角色时 [];赋角色后出现该用户 +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/duty.py +git commit -m "feat(duty): add users/options endpoint filtered by duty staff role" +``` + +--- + +### Task 4: 前端改用专用 options + 文案 + +**Files:** +- Modify: `web-admin/src/api/duty.ts` +- Modify: `web-admin/src/views/duty/schedule/index.vue` + +- [ ] **Step 1: 在 `duty.ts` 增加** + +```typescript +export function fetchDutyUserOptions() { + return request.get>({ + url: '/web/admin/duty/users/options' + }) +} +``` + +- [ ] **Step 2: 改 `schedule/index.vue`** + +将: + +```typescript +import { fetchUserOptions } from '@/api/admin' +``` + +改为从 `@/api/duty` 引入 `fetchDutyUserOptions`(与其它 duty API 合并 import)。 + +`onMounted` 内: + +```typescript +const opts = await fetchDutyUserOptions() +``` + +表单项 label / tip: + +- `label`:`直接安排(值班人员)` +- 或保留 label,在 `form-tip` 追加一句:`直接安排与公开链接仅可选择已分配「值班人员」角色的用户。` + +- [ ] **Step 3: 本地确认** + +1. 用户管理给某用户勾选「值班人员」并保存 +2. 打开值班配置弹窗:下拉仅见该用户(及同角色用户) +3. 公开链接下拉同样仅见该角色用户 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/api/duty.ts web-admin/src/views/duty/schedule/index.vue +git commit -m "feat(web-admin): use duty staff options in schedule dialog" +``` + +--- + +### Task 5: 运维核对与收尾 + +**Files:** 无代码,或按需在 `docs/sql/shanxia2.0.sql` 备注/追加角色行(若仓库要求同步全量 SQL;本任务以执行 migrate 脚本为准,**不要**为同步而大改全量 dump,除非团队惯例要求)。 + +- [ ] **Step 1: 在目标库执行迁移** + +```bash +# 按本地习惯执行,例如: +mysql ... < backend/scripts/migrate_20260817_duty_staff_role.sql +``` + +- [ ] **Step 2: 核对清单** + +| 检查项 | 期望 | +|--------|------| +| 角色管理可见「值班人员」 | 是,无菜单 | +| 全局 `/users/options` | 仍返回全部用户 | +| 值班 `/duty/users/options` | 仅值班人员 | +| 公开页 users | 仅值班人员 | +| 历史无角色 assignment | 仍显示 | +| `pytest tests/test_duty_schedule.py` | 全绿 | + +- [ ] **Step 3: 若有 graphify 约定,仅在改了代码文件后更新** + +```bash +./scripts/update-graph.sh backend +``` + +(前端仅 API/文案小改时可选 `frontend`。) + +--- + +## Spec 覆盖自检 + +| Spec 项 | Task | +|---------|------| +| D1 新建「值班人员」 | Task 1 | +| D2 无菜单、用户管理勾选 | Task 1(无 role_menu) | +| D3 管理端+公开页过滤 | Task 2–4 | +| D4 历史仍显示 | Task 2(不删 assignment) | +| D5 后端强制校验 | Task 2 `assert_user_is_duty_staff` | +| D6 专用 options、不改全局 | Task 3–4 | +| D7 不自动赋角色 | Task 1 无 user_role 插入 | +| 单测 | Task 2 | + +--- + +## 执行说明 + +实现时按 Task 顺序;每完成一个 Task 勾选步骤并提交。现有 grab 单测必须在 Task 2 Step 5 同步修补,否则会因新增角色校验而失败。