2026-07-23-oa-approval-transfer.md 26 KB

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 可用前提下):

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: 跑测确认失败(函数未定义)
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 之前。

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")))改为:

OaTask.status.in_(("approved", "rejected", "returned", "transferred")),
  • Step 3: 进度聚合时间源纳入 transferred

在 build_progress_items 内 acted 过滤(约 status in approved/rejected/returned/completed)改为:

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
    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: 跑通测试
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_engine.py::TestTransferTask -v

Expected: 全部 PASS。

  • Step 6: Commit
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 旁:

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 之后)
@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
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 脚本增加断言

  // 转交
  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: 跑脚本确认失败
cd web-admin && pnpm exec tsx scripts/check-oa-progress-person-lines.ts

Expected: FAIL(已转交 未出现或 tone 不对)。

  • Step 3: 更新 oa-progress-person-lines.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 增加:

transferred: '已转交'

OaProgressChain.vue 的 statusText map 增加 transferred: '已转交'。

  • Step 6: Commit
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 旁:

export function fetchTransferOaTask(
  taskId: string,
  data: { targetUserId: string; comment?: string }
) {
  return request.post({
    url: `${BASE}/tasks/${taskId}/transfer`,
    data,
    showSuccessMessage: true
  })
}
  • Step 2: 详情页按钮

在加签按钮后增加(canApprove 即可,无开关):

          <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: 转交弹窗(对齐加签,单选)
      <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 逻辑
  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
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: 更新知识图谱

./scripts/update-graph.sh all
  • Step 3: Commit
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 缺列,对齐该测试而非臆造列。