|
|
@@ -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
|
|
|
+ <el-button
|
|
|
+ v-if="canApprove"
|
|
|
+ class="btn-ghost"
|
|
|
+ :loading="acting"
|
|
|
+ @click="openTransfer"
|
|
|
+ >
|
|
|
+ <el-icon><Position /></el-icon>
|
|
|
+ 转交
|
|
|
+ </el-button>
|
|
|
+```
|
|
|
+
|
|
|
+从 `@element-plus/icons-vue` 增加 `Position` import。
|
|
|
+
|
|
|
+- [ ] **Step 3: 转交弹窗(对齐加签,单选)**
|
|
|
+
|
|
|
+```vue
|
|
|
+ <el-dialog
|
|
|
+ v-model="transferVisible"
|
|
|
+ title="转交"
|
|
|
+ width="520px"
|
|
|
+ class="dialog-form-layout"
|
|
|
+ align-center
|
|
|
+ append-to-body
|
|
|
+ modal-class="protocol-dialog-mask"
|
|
|
+ destroy-on-close
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <el-form label-position="top">
|
|
|
+ <el-form-item label="转交给">
|
|
|
+ <el-select
|
|
|
+ v-model="transferUserId"
|
|
|
+ filterable
|
|
|
+ clearable
|
|
|
+ placeholder="选择用户"
|
|
|
+ style="width: 100%"
|
|
|
+ :loading="userLoading"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="u in transferUserOptions"
|
|
|
+ :key="u.id"
|
|
|
+ :label="`${u.nickName}(${u.userNo})`"
|
|
|
+ :value="u.id"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="说明(可选)">
|
|
|
+ <el-input v-model="transferComment" type="textarea" :rows="2" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button class="btn-ghost" @click="transferVisible = false">
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ 取消
|
|
|
+ </el-button>
|
|
|
+ <el-button class="btn-main" type="primary" :loading="acting" @click="submitTransfer">
|
|
|
+ <el-icon><Check /></el-icon>
|
|
|
+ 确定转交
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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 缺列,对齐该测试而非臆造列。
|