Files
zkxw/docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md
T
zlwuandCursor 26800865cd feat(oa): add return, resubmit, and multi-round approval progress
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>
2026-07-15 14:53:52 +08:00

760 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |
---
## 依赖顺序
```text
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` 增加:
```python
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: 跑测确认失败**
```bash
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` 增加(要点):
```python
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` 中:
```python
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: 跑测通过**
```bash
cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress tests.test_oa_engine.TestOaEngineHelpers -v
```
Expected: OK
- [ ] **Step 5: Commit**
```bash
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` 改为:
```typescript
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
})
```
文案映射补充:
```typescript
not_reached: '未到达',
submitted: '已提交',
```
模板中:若 `item.kind === 'pending_node'`,不显示「处理人」行;`item.kind === 'terminal'` 可不显示处理人。
按钮文案确认:拒绝按钮文案为 **「拒绝」**,禁止写成「打回」。
- [ ] **Step 2: 手工验收**
1. 打开已有实例 `F6E46BC0CB2C4A12A4B324A9735ED1E0`(或本地等价:设计抄送→审批)。
2. 进度应为:发起人 → 抄送人 → 审批人(待处理)→(若有后续)未到达。
3. 拒绝后进度末尾为「流程结束(已拒绝)」且无「未到达」。
- [ ] **Step 3: Commit**
```bash
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**
```sql
-- 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` 增加:
```python
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例")
current_round = Column(Integer, nullable=False, default=1, comment="当前轮次")
```
新建:
```python
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: 高级设置默认值**
```python
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**
```bash
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`**)
```python
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`**
语义:
1. 校验任务 assignee、`task_type=approval`、`status=pending`、实例 `running`。
2. `advanced.allowReturn` 必须为 true。
3. 评论:若 `commentRequired` 或打回场景 **SHALL 要求非空意见**(打回强制意见,即使全局意见非必填)。
4. 当前任务 `status=returned`,写入 `comment_text`。
5. 同实例其余 `pending` → `cancelled`。
6. 实例 `status=returned`。
7. 写入 `oa_instance_event`:`event_type=return`,`round_no=inst.current_round`。
8. `commit` + `get_instance_detail`。
**注意:** `complete_task(..., action="reject")` 保持整单 `rejected`,**不得**改成打回。
- [ ] **Step 3: API**
```python
@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**
```bash
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: 实现**
```python
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 中同时
```sql
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**
```bash
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`**
```python
# 若传入 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: 详情返回**
```python
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**
```bash
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 封装**
```typescript
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. 进度可见第 1 轮 + 打回意见;实例「待修改」。
3. 发起人改表单再提交 → 第 2 轮;原同意节点重新出现待办。
4. 拒绝一条单 → 结束且不能再提交。
5. 撤销 → 「我发起的」重提 → **新 instanceId**,详情可回跳旧单。
- [ ] **Step 4: Commit**
```bash
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: 更新知识图谱**
```bash
./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 时写库顺序参考现有或签测试。