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>
760 lines
27 KiB
Markdown
760 lines
27 KiB
Markdown
# 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 时写库顺序参考现有或签测试。
|