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

27 KiB
Raw Blame History

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: 手工验收
  1. 打开已有实例 F6E46BC0CB2C4A12A4B324A9735ED1E0(或本地等价:设计抄送→审批)。
  2. 进度应为:发起人 → 抄送人 → 审批人(待处理)→(若有后续)未到达。
  3. 拒绝后进度末尾为「流程结束(已拒绝)」且无「未到达」。
  • 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

语义:

  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
@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. 进度可见第 1 轮 + 打回意见;实例「待修改」。
  3. 发起人改表单再提交 → 第 2 轮;原同意节点重新出现待办。
  4. 拒绝一条单 → 结束且不能再提交。
  5. 撤销 → 「我发起的」重提 → 新 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 时写库顺序参考现有或签测试。