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
| 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 不变。Files:
backend/tests/test_oa_engine.pyModify: 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为准;若缺列导致建表/插入失败,对齐该测试类字段后再跑。
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。
若仓库习惯「红测不单独提交」,可跳过本步 commit,进入 Task 2。
transfer_task + 成功/连续/会签用例Files:
backend/app/services/oa_engine.pyModify: 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)
transferred在 list_tasks_for_user 的 kind == "done" 分支(约 OaTask.status.in_(("approved", "rejected", "returned")))改为:
OaTask.status.in_(("approved", "rejected", "returned", "transferred")),
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。
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,
)
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_engine.py::TestTransferTask -v
Expected: 全部 PASS。
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
)"
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
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))
git add backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): expose POST /tasks/{id}/transfer API
EOF
)"
Files:
web-admin/scripts/check-oa-progress-person-lines.tsweb-admin/src/utils/oa-progress-person-lines.tsweb-admin/src/utils/oa-approval-print.tsModify: 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'
)
cd web-admin && pnpm exec tsx scripts/check-oa-progress-person-lines.ts
Expected: FAIL(已转交 未出现或 tone 不对)。
oa-progress-person-lines.tsconst 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 ''
}
Expected: PASS(打印成功或无 assert 抛错)。
oa-approval-print.ts 的 STATUS_TEXT 增加:
transferred: '已转交'
OaProgressChain.vue 的 statusText map 增加 transferred: '已转交'。
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
)"
Files:
web-admin/src/api/oaApproval.tsModify: 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
})
}
在加签按钮后增加(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。
<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>
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 + 本人即可满足「本节点已有待办」)。
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
)"
Files:
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
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 要求 | 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 已存在 | 无需新任务 |
targetUserId 前后端别名一致(OaTransferBody + fetchTransferOaTask)。SysUser 建表字段以现有 TestCompleteTaskOrSign 为准;实现时若 SQLite 缺列,对齐该测试而非臆造列。