Align with DingTalk semantics: reject ends the instance, return keeps it for initiator edits, and revoke-and-resubmit creates a linked new instance. Progress timeline projects rounds via oa_instance_event. Co-authored-by: Cursor <cursoragent@cursor.com>
27 KiB
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: 按钉钉语义落地审批进度展示,并分阶段实现:P0 稳单轮进度轴;P1 打回同实例再提交 + 撤销关联新单。
Architecture: 详情进度由后端投影 progress(发起/任务/未到达/终态/轮次),任务仍落 oa_task;P1 新增 oa_instance_event 记录提交/打回/撤销等非任务事件,并给实例增加 returned 状态与 source_instance_id。轮内按流程树节点序排序(已有 _sort_tasks_for_detail),轮间按 round_no。
Tech Stack: FastAPI · SQLAlchemy · MySQL 5.7 · Vue 3 · Element Plus · Python unittest
Design spec: docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md
Open questions 收口(本计划采纳)
| # | 决议 |
|---|---|
| 1 | 打回后实例状态 = returned(待发起人修改再提交);列表「我发起的」需能筛出该状态 |
| 2 | P1 打回目标 仅退回发起人(start);不支持退回到中间审批节点 |
| 3 | 使用独立表 oa_instance_event(对齐钉钉 operation_records 思路),不用纯 task 投影硬扛多轮 |
File Map
| 文件 | 职责 |
|---|---|
docs/sql/patch_2026-07-15_oa_progress_return.sql |
P1 新建 oa_instance_event;oa_instance 增 source_instance_id、current_round;注释扩展 status |
backend/app/models/models.py |
OaInstance 新字段;新建 OaInstanceEvent |
backend/app/services/oa_engine.py |
build_progress;return_task;resubmit_instance;start/withdraw 写事件;详情带 progress |
backend/app/crud/oa_approval.py |
DEFAULT_ADVANCED_SETTINGS.allowReturn |
backend/app/api/oa_approval.py |
POST .../return、POST .../resubmit;start 接受 sourceInstanceId |
backend/tests/test_oa_engine.py |
排序/progress/打回/再提交/关联单测 |
backend/tests/test_oa_progress.py |
新建 progress 投影单测(可并入 engine 测) |
web-admin/src/api/oaApproval.ts |
return / resubmit / start 扩展 |
web-admin/src/views/oa-approval/instances/detail.vue |
消费 progress;打回/再提交按钮;轮次 UI |
web-admin/src/views/oa-approval/start/index.vue |
撤销重提带 sourceInstanceId;returned 再提交入口可进编辑 |
web-admin/src/views/oa-approval/designer/index.vue |
高级设置「允许打回」开关 |
web-admin/src/views/oa-approval/mine/index.vue 或 RuntimeList.vue |
状态文案含 returned |
依赖顺序
Task 1 P0 progress 投影 API + 单测
→ Task 2 P0 详情页改吃 progress + 未到达 + 文案
→ Task 3 P1 schema / model / allowReturn
→ Task 4 P1 return_task 引擎 + API
→ Task 5 P1 resubmit_instance + 事件轮次
→ Task 6 P1 撤销重提关联(source_instance_id)
→ Task 7 P1 前端打回 / 再提交 / 多轮轴 / 关联提示
→ Task 8 验收清单走查 + 图谱更新
说明: 会话中已落地的 _flow_node_key_order / _sort_tasks_for_detail / 详情简易 timelineItems 视为 Task 1–2 的起点,需并入正式 progress 契约,避免前后端各拼一套。
Task 1: P0 — 后端 progress 投影
Files:
-
Modify:
backend/app/services/oa_engine.py -
Modify:
backend/tests/test_oa_engine.py -
Step 1: 增加 failing 单测(progress 含发起 + 抄送先于审批 + 未到达)
在 backend/tests/test_oa_engine.py 增加:
class TestBuildProgress(unittest.TestCase):
def test_cc_before_approval_and_pending_ahead(self):
from app.services.oa_engine import build_progress_items
root = {
"id": "s",
"type": "start",
"child": {
"id": "c1",
"type": "cc",
"name": "抄送人",
"child": {
"id": "a1",
"type": "approval",
"name": "审批人",
"child": {
"id": "a2",
"type": "approval",
"name": "总经理",
"child": {"id": "e1", "type": "end"},
},
},
},
}
ts = datetime(2026, 7, 15, 14, 5, 7)
# 审批任务 create 同秒但列表故意审批在前
t_approval = MagicMock(
id="tA",
node_key="a1",
node_name="审批人",
task_type="approval",
status="pending",
assignee_user_id="u2",
order_num=0,
create_time=ts,
update_time=ts,
comment_text=None,
resolve_source="users",
)
t_cc = MagicMock(
id="tC",
node_key="c1",
node_name="抄送人",
task_type="cc",
status="done",
assignee_user_id="u9",
order_num=0,
create_time=ts,
update_time=ts,
comment_text=None,
resolve_source="users",
)
inst = MagicMock(
status="running",
initiator_user_id="u0",
create_time=ts,
update_time=ts,
)
items = build_progress_items(
inst=inst,
flow_root=root,
tasks=[t_approval, t_cc],
name_map={"u0": "发起人甲", "u2": "审批乙", "u9": "抄送丙"},
events=[], # P0 可空
)
kinds = [x["kind"] for x in items]
self.assertEqual(kinds[0], "submit")
# 已产生:cc 再 approval
self.assertEqual(
[x["nodeKey"] for x in items if x["kind"] == "task"],
["c1", "a1"],
)
# running 时未到达 a2
pending = [x for x in items if x["kind"] == "pending_node"]
self.assertTrue(any(p["nodeKey"] == "a2" for p in pending))
- Step 2: 跑测确认失败
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress -v
Expected: ImportError 或 AttributeError: build_progress_items
- Step 3: 实现
build_progress_items+ 详情挂载
在 oa_engine.py 增加(要点):
def build_progress_items(
*,
inst: Any,
flow_root: Optional[Dict[str, Any]],
tasks: List[Any],
name_map: Dict[str, str],
events: Optional[List[Any]] = None,
) -> List[Dict[str, Any]]:
"""投影审批进度。P0:submit + 按树序 tasks + pending_node + terminal。
P1:插入 return/resubmit 事件并带 roundNo。"""
items: List[Dict[str, Any]] = []
round_no = 1
# 1) 首条提交(无 events 时用实例创建)
items.append({
"kind": "submit",
"roundNo": round_no,
"title": "发起人",
"status": "submitted",
"assigneeUserId": inst.initiator_user_id,
"assigneeUserName": name_map.get(inst.initiator_user_id or "", ""),
"timestamp": _fmt_dt(getattr(inst, "create_time", None)),
"commentText": "",
"nodeKey": "",
"taskType": "",
})
sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root)
reached_keys = {(t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()}
for t in sorted_tasks:
items.append({
"kind": "task",
"roundNo": round_no,
"title": t.node_name or t.node_key or "-",
"status": t.status,
"assigneeUserId": t.assignee_user_id,
"assigneeUserName": name_map.get(t.assignee_user_id or "", ""),
"timestamp": _fmt_dt(t.update_time or t.create_time),
"commentText": t.comment_text or "",
"nodeKey": t.node_key or "",
"taskType": t.task_type or "",
"resolveSource": getattr(t, "resolve_source", None) or "",
"taskId": t.id,
})
# 2) 未到达:running 时,流程树中 approval/cc 且不在 reached_keys
if inst.status == "running" and isinstance(flow_root, dict):
order = _flow_node_key_order(flow_root)
for nid, _idx in sorted(order.items(), key=lambda x: x[1]):
node = _find_node(flow_root, nid)
if not node:
continue
nt = _node_type(node)
if nt not in ("approval", "cc") or nid in reached_keys:
continue
items.append({
"kind": "pending_node",
"roundNo": round_no,
"title": node.get("name") or ("抄送人" if nt == "cc" else "审批人"),
"status": "not_reached",
"assigneeUserId": "",
"assigneeUserName": "",
"timestamp": "",
"commentText": "",
"nodeKey": nid,
"taskType": nt,
})
# 3) 终态
if inst.status == "completed":
items.append(_terminal_item("done", "流程结束", inst))
elif inst.status == "rejected":
items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst))
elif inst.status == "cancelled":
items.append(_terminal_item("cancelled", "已撤销", inst))
return items
def _fmt_dt(dt: Any) -> str:
if not dt:
return ""
if hasattr(dt, "strftime"):
return dt.strftime("%Y-%m-%d %H:%M:%S")
return str(dt)
def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]:
return {
"kind": "terminal",
"roundNo": getattr(inst, "current_round", None) or 1,
"title": title,
"status": status,
"assigneeUserId": "",
"assigneeUserName": "",
"timestamp": _fmt_dt(getattr(inst, "update_time", None)),
"commentText": "",
"nodeKey": "",
"taskType": "",
}
在 get_instance_detail 中:
data["progress"] = build_progress_items(
inst=inst,
flow_root=flow_root,
tasks=tasks,
name_map=name_map,
events=[], # Task 3+ 再查 oa_instance_event
)
# 保留 data["tasks"] 供审批按钮找 pending;UI 进度优先 progress
- Step 4: 跑测通过
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress tests.test_oa_engine.TestOaEngineHelpers -v
Expected: OK
- Step 5: Commit
git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py
git commit -m "$(cat <<'EOF'
feat(oa): project approval progress for instance detail
EOF
)"
Task 2: P0 — 详情页消费 progress
Files:
-
Modify:
web-admin/src/views/oa-approval/instances/detail.vue -
Modify:
web-admin/src/api/oaApproval.ts(可选:为 progress 补类型注释) -
Step 1: 用
detail.progress替换本地timelineItems拼装
timelineItems 改为:
const timelineItems = computed(() => {
const list = detail.value?.progress
if (Array.isArray(list) && list.length) {
return list.map((p: any, idx: number) => ({
key: `${p.kind}-${p.taskId || p.nodeKey || idx}-${p.roundNo || 1}`,
title: p.title || '-',
status: p.status || 'info',
timestamp: p.timestamp || '',
assignee: p.assigneeUserName || p.assigneeUserId || '',
resolveSource: p.resolveSource || '',
commentText: p.commentText || '',
taskType: p.taskType || '',
kind: p.kind || '',
roundNo: p.roundNo || 1
}))
}
return [] // 无 progress 时不要静默用错序 tasks
})
文案映射补充:
not_reached: '未到达',
submitted: '已提交',
模板中:若 item.kind === 'pending_node',不显示「处理人」行;item.kind === 'terminal' 可不显示处理人。
按钮文案确认:拒绝按钮文案为 「拒绝」,禁止写成「打回」。
- Step 2: 手工验收
- 打开已有实例
F6E46BC0CB2C4A12A4B324A9735ED1E0(或本地等价:设计抄送→审批)。 - 进度应为:发起人 → 抄送人 → 审批人(待处理)→(若有后续)未到达。
- 拒绝后进度末尾为「流程结束(已拒绝)」且无「未到达」。
- Step 3: Commit
git add web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(oa): render instance detail progress from API projection
EOF
)"
Task 3: P1 — Schema / Model / 高级设置
Files:
-
Create:
docs/sql/patch_2026-07-15_oa_progress_return.sql -
Modify:
backend/app/models/models.py -
Modify:
backend/app/crud/oa_approval.py -
Step 1: 编写 SQL patch
-- docs/sql/patch_2026-07-15_oa_progress_return.sql
-- OA 审批进度多轮 + 打回 + 撤销重提关联
ALTER TABLE oa_instance
ADD COLUMN source_instance_id VARCHAR(64) NULL COMMENT '撤销重提所关联的原实例ID' AFTER status,
ADD COLUMN current_round INT NOT NULL DEFAULT 1 COMMENT '当前提交轮次,从1起' AFTER source_instance_id;
-- status 语义扩展(无需改列类型):running/returned/completed/rejected/cancelled
-- returned = 已打回,待发起人修改再提交
CREATE TABLE IF NOT EXISTS oa_instance_event (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
instance_id VARCHAR(64) NOT NULL COMMENT '实例ID',
round_no INT NOT NULL DEFAULT 1 COMMENT '轮次',
event_type VARCHAR(32) NOT NULL COMMENT 'submit/return/withdraw/complete/reject_end',
actor_user_id VARCHAR(64) NULL COMMENT '操作人',
task_id VARCHAR(64) NULL COMMENT '关联任务(打回时)',
node_key VARCHAR(64) NULL COMMENT '节点key',
comment_text VARCHAR(512) NULL COMMENT '意见',
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
order_num INT NULL COMMENT '排序号',
remark VARCHAR(512) NULL COMMENT '备注',
create_by VARCHAR(64) NULL COMMENT '创建人id',
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_by VARCHAR(64) NULL COMMENT '更新人id',
update_time DATETIME NOT NULL COMMENT '更新时间',
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (id),
KEY idx_oa_evt_inst (instance_id, del_flag, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA实例操作事件(进度轴)';
在目标库执行该 patch(本地/测试库按环境)。
- Step 2: 更新 SQLAlchemy 模型
OaInstance 增加:
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例")
current_round = Column(Integer, nullable=False, default=1, comment="当前轮次")
新建:
class OaInstanceEvent(Base):
__tablename__ = "oa_instance_event"
id = Column(String(64), primary_key=True)
instance_id = Column(String(64), nullable=False)
round_no = Column(Integer, nullable=False, default=1)
event_type = Column(String(32), nullable=False)
actor_user_id = Column(String(64), nullable=True)
task_id = Column(String(64), nullable=True)
node_key = Column(String(64), nullable=True)
comment_text = Column(String(512), nullable=True)
tenant_no = Column(String(64), nullable=False, default="zkxw")
order_num = Column(Integer, nullable=True)
remark = Column(String(512), nullable=True)
create_by = Column(String(64), nullable=True)
create_dept_id = Column(String(64), nullable=False, default="")
create_time = Column(DateTime, nullable=False, default=datetime.now)
update_by = Column(String(64), nullable=True)
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
del_flag = Column(SmallInteger, nullable=False, default=0)
- Step 3: 高级设置默认值
DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = {
"supervisorFallbackToDeptLeader": True,
"allowWithdrawRunning": True,
"approverDedup": False,
"commentRequired": False,
"allowAddSign": True,
"allowReturn": True, # 打回
}
设计器 designer/index.vue 增加与「允许加签」同级的 el-switch:allowReturn,文案「允许审批人打回(退回发起人修改)」。
- Step 4: Commit
git add docs/sql/patch_2026-07-15_oa_progress_return.sql backend/app/models/models.py backend/app/crud/oa_approval.py web-admin/src/views/oa-approval/designer/index.vue
git commit -m "$(cat <<'EOF'
feat(oa): add instance events schema and allowReturn setting
EOF
)"
Task 4: P1 — return_task(打回)
Files:
-
Modify:
backend/app/services/oa_engine.py -
Modify:
backend/app/api/oa_approval.py -
Modify:
backend/tests/test_oa_engine.py -
Step 1: 写 failing 单测(sqlite 内存或 MagicMock 链路)
优先沿用 TestCompleteTaskOrSign 的 sqlite 内存风格:准备实例 running + pending task → return_task → 断言:
inst.status == "returned"- 所有
pending任务变为cancelled(新状态;展示「已取消」) - 存在
event_type == "return"的OaInstanceEvent - 打回任务自身
status == "returned"(或comment+ 专用状态;统一用任务 status=returned)
def test_return_sets_instance_returned_and_cancels_pending(self):
from app.services.oa_engine import return_task
# …插入 ver/inst/task 后
return_task(self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料")
inst = self.db.query(self.OaInstance).filter_by(id="i1").first()
self.assertEqual(inst.status, "returned")
- Step 2: 实现
return_task
语义:
- 校验任务 assignee、
task_type=approval、status=pending、实例running。 advanced.allowReturn必须为 true。- 评论:若
commentRequired或打回场景 SHALL 要求非空意见(打回强制意见,即使全局意见非必填)。 - 当前任务
status=returned,写入comment_text。 - 同实例其余
pending→cancelled。 - 实例
status=returned。 - 写入
oa_instance_event:event_type=return,round_no=inst.current_round。 commit+get_instance_detail。
注意: complete_task(..., action="reject") 保持整单 rejected,不得改成打回。
- Step 3: API
@router.post("/tasks/{task_id}/return")
async def return_oa_task(task_id: str, body: CommentBody, ...):
data = return_task(db, task_id, tenant_no=..., user_id=..., comment=body.comment)
return success_response(data, message="已打回")
- Step 4: 跑测 + Commit
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine -v
git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py backend/tests/test_oa_engine.py
git commit -m "$(cat <<'EOF'
feat(oa): add return-to-initiator action for approval tasks
EOF
)"
Task 5: P1 — resubmit_instance(同实例再提交)
Files:
-
Modify:
backend/app/services/oa_engine.py -
Modify:
backend/app/api/oa_approval.py -
Modify:
backend/tests/test_oa_engine.py -
Step 1: Failing 单测
-
仅
status=returned且操作者为发起人可再提交。 -
更新
form_data_json后current_round += 1,状态改回running。 -
写
event_type=submit(新轮)。 -
调用现有
advance_from(..., from_node=None)从 start 后第一跳重建任务(中间节点重审)。 -
保留旧轮
oa_task/return事件,禁止物理删除历史任务。 -
Step 2: 实现
def resubmit_instance(
db: Session,
instance_id: str,
*,
tenant_no: str,
user_id: str,
form_data: Optional[Dict[str, Any]] = None,
initiator_select_user_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
inst = _get_instance(db, instance_id, tenant_no)
if inst.initiator_user_id != user_id:
raise OaEngineError("仅发起人可再次提交")
if inst.status != "returned":
raise OaEngineError("仅打回待修改的审批可再次提交")
ver = ...
# 公式/校验与 start_instance 同源片段可抽私有函数
now = datetime.now()
if form_data is not None:
inst.form_data_json = _dumps(form_data)
inst.current_round = int(inst.current_round or 1) + 1
inst.status = "running"
inst.update_by = user_id
inst.update_time = now
db.add(inst)
_add_event(db, inst, event_type="submit", round_no=inst.current_round, actor=user_id)
advance_from(
db,
inst=inst,
ver=ver,
from_node=None,
user_id=user_id,
initiator_select_user_ids=initiator_select_user_ids,
)
db.commit()
return get_instance_detail(db, inst.id, tenant_no=tenant_no)
创建任务时把 remark 或后续字段带上 round_no:最低成本在 _create_tasks_for_node 写入 remark=json.dumps({"roundNo": inst.current_round}),或给 oa_task 增加 round_no 列。
本计划采纳: patch 中同时
ALTER TABLE oa_task ADD COLUMN round_no INT NOT NULL DEFAULT 1 COMMENT '所属提交轮次' AFTER sign_mode;
新建任务:round_no=inst.current_round。build_progress_items 按 round_no 分组排序。
- Step 3: API
POST /instances/{id}/resubmit
Body 与 start 类似:formData、initiatorSelectUserIds。
-
Step 4: 升级
build_progress_items -
查询 events,按
(round_no, create_time)展开。 -
每一轮:
submit事件 → 该轮tasks(round_no匹配)按树序 → 若为本轮且 running 则 pending_node。 -
return事件插在对应轮任务之后。 -
终态仅一轮结束时追加。
-
Step 5: 测通 + Commit
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine -v
git add docs/sql/patch_2026-07-15_oa_progress_return.sql backend/app/models/models.py backend/app/services/oa_engine.py backend/app/api/oa_approval.py backend/tests/test_oa_engine.py
git commit -m "$(cat <<'EOF'
feat(oa): support resubmit after return with multi-round progress
EOF
)"
Task 6: P1 — 撤销后重提关联新实例
Files:
-
Modify:
backend/app/services/oa_engine.py(start_instance、withdraw_instance) -
Modify:
backend/app/api/oa_approval.py -
Modify:
web-admin/src/api/oaApproval.ts -
Modify:
web-admin/src/views/oa-approval/start/index.vue -
Modify:
web-admin/src/views/oa-approval/mine/index.vue(或列表组件) -
Step 1:
start_instance增加可选source_instance_id
# 若传入 source_instance_id:
src = _get_instance(db, source_instance_id, tenant_no)
if src.initiator_user_id != initiator_user_id:
raise OaEngineError("只能从本人撤销的审批重提")
if src.status != "cancelled":
raise OaEngineError("仅已撤销的审批可关联重提")
inst.source_instance_id = src.id
# 可选:复制标题前缀「重提-」
withdraw_instance 写 event_type=withdraw。
首次 start_instance 也写 event_type=submit(round 1),便于 progress 统一走 events。
- Step 2: 详情返回
data["sourceInstanceId"] = inst.source_instance_id or ""
# 若有人从本单重提,可反查:
data["resubmittedInstanceId"] = ... # query source_instance_id == inst.id limit 1
- Step 3: 前端「我发起的」
对 cancelled 行提供「修改并重新提交」→ 跳转发起页:
/#/system-manage/oa-approval/start?appId=...&sourceInstanceId=...
发起页 onMounted:若有 sourceInstanceId,拉旧详情预填 formData,提交时带上该字段。
详情页顶部:若有 sourceInstanceId,展示链接「由已撤销单重提,查看原单」。
- Step 4: Commit
git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/start/index.vue web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue
git commit -m "$(cat <<'EOF'
feat(oa): link revoke-and-resubmit as a new instance
EOF
)"
Task 7: P1 — 详情/待办 UI(打回 + 再提交 + 多轮)
Files:
-
Modify:
web-admin/src/views/oa-approval/instances/detail.vue -
Modify:
web-admin/src/api/oaApproval.ts -
Modify:
web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue(状态文案) -
Step 1: API 封装
export function fetchReturnOaTask(taskId: string, comment: string) {
return request.post({ url: `${BASE}/tasks/${taskId}/return`, data: { comment } })
}
export function fetchResubmitOaInstance(
id: string,
data: { formData?: Record<string, any>; initiatorSelectUserIds?: string[] }
) {
return request.post({ url: `${BASE}/instances/${id}/resubmit`, data })
}
-
Step 2: 详情操作区
-
审批人待办:在「拒绝」旁增加 「打回」(
allowReturn !== false),ElMessageBox.prompt必填意见,调fetchReturnOaTask。 -
发起人且
status === 'returned':主按钮 「修改并再次提交」 → 打开与发起类似的可编辑表单(可复用 start 页组件或详情内嵌 form-create 可编辑),提交调resubmit。 -
进度轴:按
roundNo在轮与轮之间插入分隔标题「第 N 轮」(v-if轮号变化)。 -
状态 tag:
returned→warning「待修改」。 -
Step 3: 手工走查脚本
- 发起(抄送→两级审批)→ 第一级同意 → 第二级打回。
- 进度可见第 1 轮 + 打回意见;实例「待修改」。
- 发起人改表单再提交 → 第 2 轮;原同意节点重新出现待办。
- 拒绝一条单 → 结束且不能再提交。
- 撤销 → 「我发起的」重提 → 新 instanceId,详情可回跳旧单。
- Step 4: Commit
git add web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue
git commit -m "$(cat <<'EOF'
feat(oa): UI for return, resubmit, and multi-round progress
EOF
)"
Task 8: 收尾
- Step 1: 对照设计验收勾选
规格 2026-07-15-oa-approval-progress-timeline-design.md §6 P0/P1 清单全部勾选或注明缺口。
- Step 2: 更新知识图谱
./scripts/update-graph.sh backend
# 若前端路由/API 有新增:
./scripts/update-graph.sh frontend
- Step 3: 规格状态
将设计文档表头「状态」改为「已定稿;实现按 plans/2026-07-15-oa-approval-progress-timeline.md」。
Spec coverage 自检
| 规格要求 | Task |
|---|---|
| 轮内按设计节点序 | 1(继承排序)+ 5 |
| 发起人 + 终态 + 未到达 | 1–2 |
| 拒绝 ≠ 打回 | 4(独立 API)+ 7(文案) |
| 撤销 → 新实例再提 | 6 |
| 打回 → 同实例多轮 | 4–5、7 |
| 打回后中间节点重审 | 5(advance_from from start) |
| allowReturn 高级设置 | 3 |
| operation_records 等价 | 3(oa_instance_event) |
风险与注意
- MySQL 5.7:
ALTER加列注意锁表窗口;测试库先跑 patch。 - 旧实例无
oa_instance_event:build_progress_items必须能在events=[]时退化到 P0(用create_time伪造首条 submit)。 - 任务 status 新增
returned/cancelled:列表「已处理」查询勿把cancelled当同意。 - Autoflush=False 会话:取消 pending 时写库顺序参考现有或签测试。