Explorar el Código

docs: add implementation plan for duty staff role filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 2 meses
padre
commit
42ec16b0cf
Se han modificado 1 ficheros con 487 adiciones y 0 borrados
  1. 487 0
      docs/superpowers/plans/2026-08-17-duty-staff-role.md

+ 487 - 0
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<Array<{ label: string; value: string }>>({
+    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 同步修补,否则会因新增角色校验而失败。