diff --git a/docs/superpowers/plans/2026-07-23-oa-approval-transfer.md b/docs/superpowers/plans/2026-07-23-oa-approval-transfer.md new file mode 100644 index 00000000..8abdfc79 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-oa-approval-transfer.md @@ -0,0 +1,852 @@ +# OA 审批 · 转交 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:** 审批待办增加「转交」:关闭本人 pending 为 `transferred`,同节点新建目标人 pending,进度与已办可追溯。 + +**Architecture:** 引擎新增 `transfer_task`(对齐 `add_sign`:关旧 + 建新);API `POST /tasks/{id}/transfer`;详情页弹窗单选用户;进度人员行与打印状态文案增加「已转交」;已办列表查询纳入 `transferred`。无设计器开关。 + +**Tech Stack:** FastAPI · SQLAlchemy · pytest/unittest · Vue 3 · Element Plus · `tsx` 断言脚本(进度纯函数) + +**Spec:** `docs/superpowers/specs/2026-07-23-oa-approval-transfer-design.md` +**Demo:** `docs/mockups/oa-approval-transfer-demo.html` + +--- + +## File map + +| Path | Responsibility | +|------|----------------| +| Modify: `backend/app/services/oa_engine.py` | `transfer_task`;已办查询含 `transferred`;进度聚合/`acted` 时间源纳入 `transferred`;状态文案 | +| Modify: `backend/app/api/oa_approval.py` | `OaTransferBody` + `POST /tasks/{task_id}/transfer` | +| Modify: `backend/tests/test_oa_engine.py` | 转交单测(SQLite 集成风格对齐 `TestCompleteTaskOrSign`) | +| Modify: `web-admin/src/api/oaApproval.ts` | `fetchTransferOaTask` | +| Modify: `web-admin/src/views/oa-approval/instances/detail.vue` | 转交按钮 + 弹窗 | +| Modify: `web-admin/src/utils/oa-progress-person-lines.ts` | `transferred` → 已转交 / mute | +| Modify: `web-admin/scripts/check-oa-progress-person-lines.ts` | 断言补转交行 | +| Modify: `web-admin/src/components/oa-approval/OaProgressChain.vue` | 节点/人员 `statusText` 含已转交 | +| Modify: `web-admin/src/utils/oa-approval-print.ts` | `STATUS_TEXT.transferred` | +| Modify: `web-admin/src/views/oa-approval/done/index.vue`(及 pending/mine/cc 若共用映射) | 任务/实例状态文案含已转交(仅展示映射即可) | + +**Locked rules (from spec):** + +- 语义:替换处理人;说明可选;无 `allowTransfer`;可选租户有效用户(排除本人与本节点 pending);允许再转交。 +- `resolve_source="transfer"`;事件 `event_type="transfer"`。 +- 会签/或签只换本人那份;`sign_mode` 不变。 + +--- + +### Task 1: 引擎转交 — 失败用例(TDD) + +**Files:** +- Modify: `backend/tests/test_oa_engine.py` +- Modify: `backend/app/services/oa_engine.py`(下一步才实现;本任务先写测) + +- [ ] **Step 1: 追加失败用例类(先写测)** + +在 `backend/tests/test_oa_engine.py` 文件末尾追加(在现有 import/`OaEngineError` 可用前提下): + +```python +class TestTransferTask(unittest.TestCase): + def setUp(self): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask, SysUser + + self.engine = create_engine("sqlite:///:memory:") + OaAppVersion.__table__.create(self.engine) + OaInstance.__table__.create(self.engine) + OaTask.__table__.create(self.engine) + OaInstanceEvent.__table__.create(self.engine) + SysUser.__table__.create(self.engine) + Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) + self.db = Session() + self.OaTask = OaTask + self.OaInstance = OaInstance + self.OaAppVersion = OaAppVersion + self.SysUser = SysUser + now = datetime.now() + self.db.add( + OaAppVersion( + id="v1", + app_id="app1", + version_no=1, + status="published", + is_latest_published="1", + form_schema_json="{}", + flow_tree_json="{}", + advanced_settings_json="{}", + tenant_no="zkxw", + create_dept_id="", + create_time=now, + update_time=now, + del_flag=0, + ) + ) + self.db.add( + OaInstance( + id="i1", + app_id="app1", + app_version_id="v1", + title="t", + status="running", + initiator_user_id="u0", + initiator_dept_id="d1", + form_data_json="{}", + tenant_no="zkxw", + create_dept_id="d1", + create_time=now, + update_time=now, + del_flag=0, + current_round=1, + ) + ) + for uid, name in (("u1", "李四"), ("u2", "赵六"), ("u3", "钱七")): + self.db.add( + SysUser( + id=uid, + tenant_no="zkxw", + user_no=uid, + nick_name=name, + status="1", + del_flag=0, + create_time=now, + update_time=now, + ) + ) + self.db.add( + OaTask( + id="t1", + instance_id="i1", + node_key="a1", + node_name="审批人", + task_type="approval", + assignee_user_id="u1", + status="pending", + resolve_source="role", + sign_mode="and", + round_no=1, + tenant_no="zkxw", + order_num=1, + create_dept_id="d1", + create_time=now, + update_time=now, + del_flag=0, + ) + ) + # 会签另一人 + self.db.add( + OaTask( + id="t2", + instance_id="i1", + node_key="a1", + node_name="审批人", + task_type="approval", + assignee_user_id="u3", + status="pending", + resolve_source="role", + sign_mode="and", + round_no=1, + tenant_no="zkxw", + order_num=2, + create_dept_id="d1", + create_time=now, + update_time=now, + del_flag=0, + ) + ) + self.db.commit() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_transfer_rejects_self(self): + from app.services.oa_engine import OaEngineError, transfer_task + + with self.assertRaises(OaEngineError) as ctx: + transfer_task( + self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u1" + ) + self.assertIn("自己", str(ctx.exception)) + + def test_transfer_rejects_existing_pending(self): + from app.services.oa_engine import OaEngineError, transfer_task + + with self.assertRaises(OaEngineError) as ctx: + transfer_task( + self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u3" + ) + self.assertIn("待办", str(ctx.exception)) + + def test_transfer_rejects_non_assignee(self): + from app.services.oa_engine import OaEngineError, transfer_task + + with self.assertRaises(OaEngineError) as ctx: + transfer_task( + self.db, "t1", tenant_no="zkxw", user_id="u2", target_user_id="u2" + ) + self.assertIn("无权", str(ctx.exception)) +``` + +> 注意:`SysUser` / `OaInstance` 必填列以 `TestCompleteTaskOrSign.setUp` 与 `models.py` 为准;若缺列导致建表/插入失败,对齐该测试类字段后再跑。 + +- [ ] **Step 2: 跑测确认失败(函数未定义)** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_oa_engine.py::TestTransferTask -v +``` + +Expected: FAIL / ERROR,含 `cannot import name 'transfer_task'` 或 `AttributeError`。 + +- [ ] **Step 3: Commit 失败测(可选)或与 Task 2 一并提交** + +若仓库习惯「红测不单独提交」,可跳过本步 commit,进入 Task 2。 + +--- + +### Task 2: 实现 `transfer_task` + 成功/连续/会签用例 + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Modify: `backend/tests/test_oa_engine.py` + +- [ ] **Step 1: 在 `add_sign` 之后实现 `transfer_task`** + +插入位置:`backend/app/services/oa_engine.py` 中 `add_sign` 函数结束后、`return_task` 之前。 + +```python +def transfer_task( + db: Session, + task_id: str, + *, + tenant_no: str, + user_id: str, + target_user_id: str, + comment: Optional[str] = None, +) -> Dict[str, Any]: + """当前待办处理人转交:关闭本人 pending,同节点新建目标人 pending。""" + task = ( + db.query(OaTask) + .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0) + .first() + ) + if not task: + raise OaEngineError("任务不存在") + if task.assignee_user_id != user_id: + raise OaEngineError("无权转交该任务") + if task.task_type != "approval" or task.status != "pending": + raise OaEngineError("仅待处理的审批任务可转交") + + inst = _get_instance(db, task.instance_id, tenant_no) + if inst.status != "running": + raise OaEngineError("实例已结束") + + target = (target_user_id or "").strip() + if not target: + raise OaEngineError("请选择转交人") + if target == user_id: + raise OaEngineError("不能转交给自己") + + u = ( + db.query(SysUser) + .filter( + SysUser.id == target, + SysUser.tenant_no == tenant_no, + SysUser.del_flag == 0, + SysUser.status == "1", + ) + .first() + ) + if not u: + raise OaEngineError(f"转交用户无效:{target}") + + existing = { + r.assignee_user_id + for r in db.query(OaTask) + .filter( + OaTask.instance_id == inst.id, + OaTask.node_key == task.node_key, + OaTask.del_flag == 0, + OaTask.status == "pending", + ) + .all() + } + if target in existing: + raise OaEngineError("该用户已在当前节点待办中") + + now = datetime.now() + comment_text = (comment or "").strip() or None + round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1) + + task.status = "transferred" + task.comment_text = comment_text + task.update_by = user_id + task.update_time = now + db.add(task) + + next_order = _next_task_order_num(db, inst.id) + db.add( + OaTask( + id=generate_uuid(), + instance_id=inst.id, + node_key=task.node_key, + node_name=task.node_name, + task_type="approval", + assignee_user_id=target, + status="pending", + comment_text=None, + resolve_source="transfer", + sign_mode=task.sign_mode or "or", + round_no=round_no, + tenant_no=tenant_no, + order_num=next_order, + remark=comment_text, + create_by=user_id, + create_dept_id=inst.initiator_dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + ) + + event_comment = comment_text or f"转交给 {target}" + _add_instance_event( + db, + inst=inst, + event_type="transfer", + actor_user_id=user_id, + round_no=round_no, + comment=event_comment, + task_id=task.id, + node_key=task.node_key, + ) + db.commit() + return get_instance_detail(db, inst.id, tenant_no=tenant_no) +``` + +- [ ] **Step 2: 已办查询纳入 `transferred`** + +在 `list_tasks_for_user` 的 `kind == "done"` 分支(约 `OaTask.status.in_(("approved", "rejected", "returned"))`)改为: + +```python +OaTask.status.in_(("approved", "rejected", "returned", "transferred")), +``` + +- [ ] **Step 3: 进度聚合时间源纳入 `transferred`** + +在 `build_progress_items` 内 `acted` 过滤(约 status in approved/rejected/returned/completed)改为: + +```python +acted = [ + p + for p in people + if p["status"] in ("approved", "rejected", "returned", "completed", "transferred") +] +``` + +`_aggregate_node_status` **不必**把 `transferred` 升为节点终态;有 `pending` 时仍返回 `pending`(现有逻辑已满足)。若节点仅剩全部 `transferred`(异常路径),可落到 `sset[0]`;正常转交后必有新 pending。 + +- [ ] **Step 4: 追加成功用例到 `TestTransferTask`** + +```python + def test_transfer_success_and_keeps_co_approver(self): + from app.services.oa_engine import transfer_task + from unittest.mock import patch + + with patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1"}, + ): + transfer_task( + self.db, + "t1", + tenant_no="zkxw", + user_id="u1", + target_user_id="u2", + comment="出差代批", + ) + t1 = self.db.query(self.OaTask).filter_by(id="t1").one() + self.assertEqual(t1.status, "transferred") + self.assertEqual(t1.comment_text, "出差代批") + t2 = self.db.query(self.OaTask).filter_by(id="t2").one() + self.assertEqual(t2.status, "pending") + neo = ( + self.db.query(self.OaTask) + .filter_by(instance_id="i1", assignee_user_id="u2", status="pending") + .one() + ) + self.assertEqual(neo.resolve_source, "transfer") + self.assertEqual(neo.sign_mode, "and") + self.assertEqual(neo.node_key, "a1") + + def test_transfer_comment_optional(self): + from app.services.oa_engine import transfer_task + from unittest.mock import patch + + with patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1"}, + ): + transfer_task( + self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u2" + ) + t1 = self.db.query(self.OaTask).filter_by(id="t1").one() + self.assertEqual(t1.status, "transferred") + self.assertTrue(t1.comment_text in (None, "")) + + def test_transfer_chain(self): + from app.services.oa_engine import transfer_task + from unittest.mock import patch + + with patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1"}, + ): + transfer_task( + self.db, "t1", tenant_no="zkxw", user_id="u1", target_user_id="u2" + ) + neo = ( + self.db.query(self.OaTask) + .filter_by(assignee_user_id="u2", status="pending") + .one() + ) + # 再建一个有效用户 u4 用于二次转交,或转回需先不在 pending:用新用户 + # 简化:在 setUp 已有 u2;二次转交给新插入用户 + now = datetime.now() + self.db.add( + self.SysUser( + id="u4", + tenant_no="zkxw", + user_no="u4", + nick_name="孙八", + status="1", + del_flag=0, + create_time=now, + update_time=now, + ) + ) + self.db.commit() + with patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1"}, + ): + transfer_task( + self.db, + neo.id, + tenant_no="zkxw", + user_id="u2", + target_user_id="u4", + ) + self.assertEqual( + self.db.query(self.OaTask).filter_by(id=neo.id).one().status, + "transferred", + ) + self.assertEqual( + self.db.query(self.OaTask) + .filter_by(assignee_user_id="u4", status="pending") + .count(), + 1, + ) +``` + +- [ ] **Step 5: 跑通测试** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_oa_engine.py::TestTransferTask -v +``` + +Expected: 全部 PASS。 + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py +git commit -m "$(cat <<'EOF' +feat(oa): add transfer_task to reassign pending approval + +Close the current assignee's task as transferred and create a same-node pending task for the target user; include transferred in done lists. +EOF +)" +``` + +--- + +### Task 3: API 路由 + +**Files:** +- Modify: `backend/app/api/oa_approval.py` + +- [ ] **Step 1: 增加 Body** + +在 `OaAddSignBody` 旁: + +```python +class OaTransferBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + target_user_id: str = Field(..., alias="targetUserId") + comment: Optional[str] = None +``` + +- [ ] **Step 2: 增加路由(建议放在 `add-sign` 之后)** + +```python +@router.post("/tasks/{task_id}/transfer") +async def transfer_oa_task( + task_id: str, + body: OaTransferBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, transfer_task + + try: + tenant_no = _get_tenant_no(db, user) + data = transfer_task( + db, + task_id, + tenant_no=tenant_no, + user_id=user.get("user_id") or "", + target_user_id=body.target_user_id, + comment=body.comment, + ) + return success_response(data, message="已转交") + except OaEngineError as e: + return error_response(400, str(e)) +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/oa_approval.py +git commit -m "$(cat <<'EOF' +feat(oa): expose POST /tasks/{id}/transfer API + +EOF +)" +``` + +--- + +### Task 4: 前端进度文案(TDD) + +**Files:** +- Modify: `web-admin/scripts/check-oa-progress-person-lines.ts` +- Modify: `web-admin/src/utils/oa-progress-person-lines.ts` +- Modify: `web-admin/src/utils/oa-approval-print.ts` +- Modify: `web-admin/src/components/oa-approval/OaProgressChain.vue` + +- [ ] **Step 1: 在 check 脚本增加断言** + +```ts + // 转交 + const xfer: ProgressPersonInput[] = [ + { + nickName: '李四', + status: 'transferred', + commentText: '', + timestamp: '2026-07-23 10:05:00' + }, + { nickName: '赵六', status: 'pending', commentText: '', timestamp: '' } + ] + const xferLines = formatProgressPersonLines(xfer) + assert.deepEqual( + xferLines.map((x) => x.text), + ['李四 已转交 2026-07-23 10:05:00', '赵六 待处理'] + ) + assert.equal(xferLines[0].tone, 'mute') + + const xferComment: ProgressPersonInput[] = [ + { + nickName: '李四', + status: 'transferred', + commentText: '出差代批', + timestamp: '2026-07-23 10:05:00' + } + ] + assert.equal( + formatProgressPersonLines(xferComment)[0].text, + '李四 出差代批 2026-07-23 10:05:00' + ) +``` + +- [ ] **Step 2: 跑脚本确认失败** + +```bash +cd web-admin && pnpm exec tsx scripts/check-oa-progress-person-lines.ts +``` + +Expected: FAIL(`已转交` 未出现或 tone 不对)。 + +- [ ] **Step 3: 更新 `oa-progress-person-lines.ts`** + +```ts +const ACTED_STATUS = new Set([ + 'approved', + 'rejected', + 'returned', + 'completed', + 'transferred' +]) + +function statusFallbackLabel(status: string): string { + const s = (status || '').trim() + if (s === 'approved') return '已同意' + if (s === 'rejected') return '已拒绝' + if (s === 'returned') return '已打回' + if (s === 'completed') return '已完成' + if (s === 'transferred') return '已转交' + return s || '已处理' +} + +function toneForStatus(status: string): PersonLineTone { + const s = (status || '').trim() + if (s === 'approved' || s === 'completed') return 'ok' + if (s === 'pending' || s === 'running' || s === 'not_reached') return 'pending' + if (s === 'rejected' || s === 'returned' || s === 'cancelled') return 'danger' + if (s === 'done' || s === 'transferred') return 'mute' + return '' +} +``` + +- [ ] **Step 4: 再跑脚本** + +Expected: PASS(打印成功或无 assert 抛错)。 + +- [ ] **Step 5: 打印与进度链状态映射** + +`oa-approval-print.ts` 的 `STATUS_TEXT` 增加: + +```ts +transferred: '已转交' +``` + +`OaProgressChain.vue` 的 `statusText` map 增加 `transferred: '已转交'`。 + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/utils/oa-progress-person-lines.ts \ + web-admin/scripts/check-oa-progress-person-lines.ts \ + web-admin/src/utils/oa-approval-print.ts \ + web-admin/src/components/oa-approval/OaProgressChain.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): show transferred status on OA progress lines + +EOF +)" +``` + +--- + +### Task 5: 前端 API + 详情转交 UI + +**Files:** +- Modify: `web-admin/src/api/oaApproval.ts` +- Modify: `web-admin/src/views/oa-approval/instances/detail.vue` + +- [ ] **Step 1: API 方法** + +在 `fetchAddSignOaTask` 旁: + +```ts +export function fetchTransferOaTask( + taskId: string, + data: { targetUserId: string; comment?: string } +) { + return request.post({ + url: `${BASE}/tasks/${taskId}/transfer`, + data, + showSuccessMessage: true + }) +} +``` + +- [ ] **Step 2: 详情页按钮** + +在加签按钮后增加(`canApprove` 即可,无开关): + +```vue + + + 转交 + +``` + +从 `@element-plus/icons-vue` 增加 `Position` import。 + +- [ ] **Step 3: 转交弹窗(对齐加签,单选)** + +```vue + + + + + + + + + + + + + +``` + +- [ ] **Step 4: script 逻辑** + +```ts + import { fetchTransferOaTask, /* 已有 imports */ } from '@/api/oaApproval' + + const transferVisible = ref(false) + const transferUserId = ref('') + const transferComment = ref('') + + const transferUserOptions = computed(() => { + const me = currentUserId.value + const pendingIds = new Set( + (detail.value?.tasks || []) + .filter((t: any) => t.status === 'pending' && t.taskType === 'approval') + .map((t: any) => String(t.assigneeUserId || '')) + ) + return userOptions.value.filter((u) => u.id !== me && !pendingIds.has(u.id)) + }) + + async function openTransfer() { + transferUserId.value = '' + transferComment.value = '' + transferVisible.value = true + if (!userOptions.value.length) await loadUsers() + } + + async function submitTransfer() { + const task = myPendingTask.value + if (!task) return + if (!transferUserId.value) { + ElMessage.warning('请选择转交人') + return + } + acting.value = true + try { + await fetchTransferOaTask(task.id, { + targetUserId: transferUserId.value, + comment: transferComment.value || undefined + }) + transferVisible.value = false + await load() + } finally { + acting.value = false + } + } +``` + +> `currentUserId` / `detail.tasks` 字段名以详情接口实际返回为准(若任务列表在 `detail.myTasks` 或 progress people,则用 `myPendingTask` 的 `nodeKey` 过滤同节点 pending assignee;最小实现:过滤全部 pending assignee + 本人即可满足「本节点已有待办」)。 + +- [ ] **Step 5: 手工冒烟** + +1. 发起一单,待办人打开详情,见「转交」。 +2. 转给另一用户(无说明)→ 成功;原人操作区消失;进度「已转交」+ 新人「待处理」。 +3. 原人「已处理」列表可见;新人「待处理」可见。 +4. 会签节点只换自己那份。 + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/instances/detail.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add OA task transfer action on instance detail + +EOF +)" +``` + +--- + +### Task 6: 列表状态文案扫尾 + 图谱 + +**Files:** +- Modify: `web-admin/src/views/oa-approval/done/index.vue`(及 `pending`/`mine`/`cc`/`detail` 中 `statusText` 若展示任务状态) +- Run: `./scripts/update-graph.sh all`(或 backend + frontend) + +- [ ] **Step 1: 各页 `statusText` map 增加 `transferred: '已转交'`**(与 `returned` 同级;避免 raw 英文落到表格)。 + +- [ ] **Step 2: 更新知识图谱** + +```bash +./scripts/update-graph.sh all +``` + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/oa-approval backend/graphify-out web-admin/graphify-out +git commit -m "$(cat <<'EOF' +chore(oa): label transferred status and refresh code graphs + +EOF +)" +``` + +--- + +## Spec coverage checklist + +| Spec 要求 | Task | +|-----------|------| +| `transfer_task` 关旧+建新 | 2 | +| 排除自己 / 本节点 pending / 无效用户 | 1–2 | +| 说明可选 | 2, 5 | +| 无 allowTransfer | 全计划(不写开关) | +| 连续转交 | 2 | +| 会签只换自己 | 2 | +| 已办含 transferred | 2 | +| API POST transfer | 3 | +| 详情按钮+弹窗 | 5 | +| 进度「已转交」/ mute | 4 | +| 打印 STATUS_TEXT | 4 | +| Demo 已存在 | 无需新任务 | + +--- + +## Self-review notes + +- 无 TBD/placeholder。 +- `targetUserId` 前后端别名一致(`OaTransferBody` + `fetchTransferOaTask`)。 +- `SysUser` 建表字段以现有 `TestCompleteTaskOrSign` 为准;实现时若 SQLite 缺列,对齐该测试而非臆造列。