docs: add implementation plan for duty staff role filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-17 18:18:02 +08:00
1 parent 2942d176bd
commit 42ec16b0cf
1 file changed
+487
@@ -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 同步修补,否则会因新增角色校验而失败。