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>
This commit is contained in:
1 parent
265d790ea6
commit
26800865cd
13 files changed
+4736
-72
No files matched your search
@@ -0,0 +1,34 @@
|
||||
-- OA 审批进度多轮 + 打回 + 撤销重提关联
|
||||
-- MySQL 5.7 / utf8mb4
|
||||
|
||||
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 = 已打回,待发起人修改再提交
|
||||
|
||||
ALTER TABLE oa_task
|
||||
ADD COLUMN round_no INT NOT NULL DEFAULT 1 COMMENT '所属提交轮次' AFTER sign_mode;
|
||||
|
||||
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实例操作事件(进度轴)';
|
||||
@@ -0,0 +1,759 @@
|
||||
# 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 时写库顺序参考现有或签测试。
|
||||
@@ -0,0 +1,206 @@
|
||||
# OA 审批进度展示与实例流转设计(钉钉对齐)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 状态 | 已定稿;实现已落地(见 `docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md`) |
|
||||
| 日期 | 2026-07-15 |
|
||||
| 范围 | web-admin 审批详情「审批进度」;拒绝 / 撤销 / 打回 的实例语义 |
|
||||
| 权威对齐 | 钉钉 OA 审批详情(`tasks` + `operation_records` 思路) |
|
||||
| 关联 | `2026-07-14-oa-approval-dingtalk-design.md`(本文件**修订**其中「驳回 = 整单结束、V1 不做回退」——拆成「拒绝」与「打回」) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
产品与研发对齐过程中发现两处易混:
|
||||
|
||||
1. **进度排序**:流程设计为「发起人 → 抄送 → 审批 → 结束」,详情曾因同秒创建任务、仅按 `create_time` 排序导致抄送/审批颠倒。
|
||||
2. **打回 vs 拒绝 vs 撤销后再提**:口语「打回」常被当成「拒绝结束」;实际上需要三套语义,且「同实例第二次提交」主要出现在**打回**路径。
|
||||
|
||||
本文件固定:**实例怎么活** + **进度怎么画**,并与钉钉主流体感对齐。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 审批进度可读、可审计,与流程设计顺序在「同一轮」内一致。
|
||||
- 明确拒绝 / 撤销 / 打回三种动作对实例生命周期的影响。
|
||||
- 分阶段落地:先稳单轮 + 拒绝/撤销收尾;再做打回同实例多轮与撤销关联新单。
|
||||
|
||||
非目标(本文件不展开实现细节到接口字段级,留给实现计划):
|
||||
|
||||
- 转交、评论独立墙、代理人等钉钉全量操作类型的 UI 一一复刻。
|
||||
- 小程序端进度 UI(语义同本章,交互另规)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三种动作(SHALL)
|
||||
|
||||
| 动作 | 操作者 | 本单命运 | 再次进入审批 | 钉钉类比 |
|
||||
|------|--------|----------|--------------|----------|
|
||||
| **拒绝** | 审批人 | **结束**(结果:已拒绝) | 只能**新开实例** | 任务 `REFUSE` + 实例完结 |
|
||||
| **撤销** | 发起人(受高级设置约束) | **终止**(已撤销) | **新实例**再提(可关联旧单) | `TERMINATE`;重提常带 `biz_action=REVOKE` / 附属实例 |
|
||||
| **打回**(退回) | 审批人 | **不结束** | **同一实例**改表单后再次提交 | `REDIRECT_PROCESS` |
|
||||
|
||||
### 3.1 禁止混淆的表述
|
||||
|
||||
- **不得**将「打回」实现为「拒绝结束」。
|
||||
- **不得**将「拒绝」实现为「退回发起人改单」。
|
||||
- 「撤销后再提交」**不是**同实例复活(见 §3.2);「打回后再提交」**才是**同实例第二轮(见 §3.3)。
|
||||
|
||||
### 3.2 撤销 → 修改 → 再提交(钉钉思路)
|
||||
|
||||
```
|
||||
实例 #1:… → 发起人撤销 → 状态终态「已撤销」
|
||||
实例 #2:重新发起(新 instanceId)→ 可选关联 #1
|
||||
```
|
||||
|
||||
- 列表:旧单「已撤销」,新单「审批中 / 已完成 …」。
|
||||
- 详情:各自独立进度轴;新单详情可展示「由某某已撤销单重提」并跳转旧单。
|
||||
- **SHALL NOT** 在已撤销实例上直接改状态为 running 并清空历史后重跑(避免审计与待办混乱)。
|
||||
|
||||
### 3.3 打回 → 修改 → 再提交(同实例多轮)
|
||||
|
||||
```
|
||||
同一实例:
|
||||
第 1 轮提交 → … → 审批人打回(附意见)
|
||||
→ 发起人改表单 → 第 2 轮提交 → 按退回策略重走节点 …
|
||||
```
|
||||
|
||||
- 进度轴**保留第 1 轮记录**,再追加第 2 轮,不得覆盖抹掉。
|
||||
- **退回后中间已通过节点是否重审**:默认与钉钉宜搭高级流程一致——**需要重新审批**(从退回目标之后的路径重走;V1 打回目标默认为「发起人 / 开始节点」)。若日后支持「退回到指定节点」,另开变更,默认仍「退回后至结束前节点重审」。
|
||||
|
||||
### 3.4 拒绝(打回≠拒绝)
|
||||
|
||||
- 当前审批人拒绝 → 实例进入终态「已拒绝」。
|
||||
- 进度:拒绝节点结果 + 「流程结束(已拒绝)」;**不得**再展示后续未到达节点为待处理。
|
||||
- 业务上若需继续办理:发起人**新开一张**审批(与旧单无强制引擎关联;产品可选「复制表单发起」属后续增强)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 审批进度展示模型(SHALL)
|
||||
|
||||
对齐钉钉:**对外一条时间轴,对内区分「流程快照感」与「操作/轮次事件」**。
|
||||
|
||||
### 4.1 推荐模型:混合
|
||||
|
||||
| 层级 | 规则 |
|
||||
|------|------|
|
||||
| **轮次(大序)** | 同一实例内按「提交轮 / 打回事件」分段或打标(第 1 轮、打回、第 2 轮…) |
|
||||
| **轮内(小序)** | **严格按该版本流程树设计节点顺序**(抄送在审批前则展示在审批前) |
|
||||
| **同节点多人** | 会签 / 或签 / 加签:**一人一条**结果,不合并成一条含糊节点 |
|
||||
| **未到达** | 仅当前轮且实例 `running` 时可灰显「未到达」;终态(通过 / 拒绝 / 撤销)后**不再**挂未达节点 |
|
||||
| **抄送** | 不阻断;创建即记入进度(知会),排序仍服从设计序 |
|
||||
| **结束** | 通过 →「流程结束」;拒绝 →「流程结束(已拒绝)」;撤销 →「已撤销」(可不展示虚假「流程正常结束」) |
|
||||
|
||||
### 4.2 单轮 happy path 示意
|
||||
|
||||
```
|
||||
发起人 · 张三 · 已提交 · t0
|
||||
抄送人 · … · 已抄送 · t1 ← 设计在审批前则在上
|
||||
审批人 · … · 待处理 / 已同意 · t2
|
||||
(可选)后续节点 · 未到达
|
||||
(全部通过后)流程结束
|
||||
```
|
||||
|
||||
### 4.3 打回多轮示意
|
||||
|
||||
```
|
||||
—— 第 1 轮 ——
|
||||
发起人 · 已提交
|
||||
审批人甲 · 已同意
|
||||
审批人乙 · 打回(意见:请补充附件)
|
||||
—— 第 2 轮 ——
|
||||
发起人 · 再次提交
|
||||
审批人甲 · 待处理 / …
|
||||
…
|
||||
```
|
||||
|
||||
### 4.4 撤销后再提(跨实例)示意
|
||||
|
||||
```
|
||||
【实例 #1 详情】
|
||||
… → 已撤销
|
||||
|
||||
【实例 #2 详情】
|
||||
提示:本单由「实例 #1」撤销后重提
|
||||
发起人 · 已提交
|
||||
…
|
||||
```
|
||||
|
||||
### 4.5 排序与数据权威
|
||||
|
||||
- **展示排序权威**:流程树节点序(`node_key` 在 flow tree 先序/路径序中的位置)→ 同节点内 `order_num` → `create_time` → `id`。
|
||||
- **禁止**仅依赖同秒精度的 `create_time` 作为唯一排序键(会导致抄送/审批颠倒)。
|
||||
- 新建任务时 **SHALL** 写入递增 `order_num`,便于同节点加签等场景稳定排序。
|
||||
- 长期演进:可引入独立 `operation_records`(或等价事件表),与钉钉 `operation_records` 对齐;在引入前,允许由「实例事件 + oa_task」投影生成时间轴,但语义须符合本章。
|
||||
|
||||
### 4.6 与「发起页流程预览」的关系
|
||||
|
||||
- 发起页底部预览(见 `2026-07-15-oa-start-flow-preview-design.md`):提交前的**预期链**。
|
||||
- 详情审批进度:提交后的**实际发生 + 当前轮预期未达**。
|
||||
- 两者节点名与顺序在同一发布版本下应一致;条件分支以实际表单路径为准。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分阶段落地
|
||||
|
||||
### 5.1 阶段 P0(当前可验收,不依赖打回引擎)
|
||||
|
||||
1. 详情进度按**流程树节点序**排列任务(兼容历史 `order_num=0` 同秒数据)。
|
||||
2. 时间轴含:**发起人(已提交)**;任务节点;终态时 **流程结束 / 已拒绝 / 已撤销**。
|
||||
3. 拒绝、撤销行为保持:本单结束,无同实例第二轮。
|
||||
4. 撤销后再提:用户从发起入口**新开实例**(关联旧单可为 P1)。
|
||||
|
||||
### 5.2 阶段 P1(钉钉完整语义)
|
||||
|
||||
1. 审批人操作拆分:**同意 / 拒绝 / 打回**(打回需意见;高级设置可开关)。
|
||||
2. 打回后:取消当前及后续未完成待办;实例进入「待发起人修改」类状态(命名实现时确定);发起人可改表单并**再次提交**;引擎按 §3.3 重走。
|
||||
3. 进度轴支持**多轮**展示(§4.3)。
|
||||
4. 撤销后重提:创建新实例时写入关联(如 `oa_relation` 或实例字段 `prev_instance_id` / `biz_action=revoke_resubmit`),双方详情可跳转。
|
||||
|
||||
### 5.3 与旧规格的关系
|
||||
|
||||
`2026-07-14-oa-approval-dingtalk-design.md` §5.2 原文:
|
||||
|
||||
> 结束:实例 `completed` / `rejected`(驳回策略:整单结束,V1 不做复杂回退多版本)
|
||||
|
||||
**修订为**:
|
||||
|
||||
- 「驳回」一词废弃;改为「**拒绝** = 整单结束」。
|
||||
- 「**打回** = 同实例退回重提」列入 P1,不再用「V1 永不做回退」表述封死产品方向。
|
||||
- P0 仍可不实现打回引擎,但产品文案与按钮不得把拒绝叫做打回。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收要点
|
||||
|
||||
### P0
|
||||
|
||||
- [x] 设计为「抄送 → 审批」时,详情进度在同秒创建下仍为抄送在上、审批在下。
|
||||
- [x] 进度首条为发起人已提交;拒绝/撤销/通过后有正确终态文案。
|
||||
- [x] 拒绝后列表与详情均为已结束,无法在同单再提交。
|
||||
|
||||
### P1
|
||||
|
||||
- [x] 打回后同实例可再次提交,轴上可见两轮提交与中间打回意见。(引擎+UI 已实现;需业务侧走查)
|
||||
- [x] 打回后中间已同意节点默认需重审。
|
||||
- [x] 撤销后重提为新 `instanceId`,可从新单跳到旧单。
|
||||
- [x] 拒绝与打回按钮/结果文案互不混用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 决议记录
|
||||
|
||||
| 日期 | 决议 |
|
||||
|------|------|
|
||||
| 2026-07-15 | 按钉钉思路:拒绝结束本单;撤销后再提 = 新实例;打回 = 同实例再提交;进度 = 轮次时间轴 + 轮内设计序。 |
|
||||
| 2026-07-15 | 未到达节点:进行中可展示;终态不展示(在混合模型中确认)。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开放问题(已在实现计划收口)
|
||||
|
||||
1. 打回后实例状态 = **`returned`**(待发起人修改再提交)。
|
||||
2. P1 打回目标 **仅退回发起人(start)**。
|
||||
3. 使用独立表 **`oa_instance_event`**(对齐钉钉 operation_records)。
|
||||
Reference in new issue
Block a user