docs: add implementation plan for duty staff role filter.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 同步修补,否则会因新增角色校验而失败。
|
||||
Reference in new issue
Block a user