2026-07-22-oa-related-approval.md 27 KB

OA Related Approval Control 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: Deliver the FormCreate relatedApproval control so designers can drag it, initiators can multi-select their own completed approvals, see expand/collapse field summaries, persist oa_relation with field_key, and open related instance details.

Architecture: Backend adds picker list + enrichment APIs and changes start_instance to extract relatedApproval IDs from form_data (with relatedInstanceIds fallback). Frontend registers a custom FormCreate component on both the main app and oa-fc-host (four-layer FcDesigner extension), rendering Mint-styled summaries (Web wrap flow / narrow stacked rows).

Tech Stack: FastAPI · SQLAlchemy · pytest · Vue 3 · FormCreate / @form-create/designer · Element Plus · Fresh Mint SCSS

Spec: docs/superpowers/specs/2026-07-22-oa-related-approval-design.md


File map

Path Responsibility
Create: backend/app/services/oa_related_approval.py Flatten schema→summary fields; extract links from form_data; picker/enrich helpers
Modify: backend/app/services/oa_engine.py Call extract+validate on start; write oa_relation with field_key
Modify: backend/app/api/oa_approval.py GET .../related-candidates, GET .../related-summaries
Create: backend/tests/test_oa_related_approval.py Unit tests for flatten / extract / validate
Create: web-admin/src/components/oa-approval/OaRelatedApproval.vue Runtime control UI
Create: web-admin/src/utils/oa-related-approval-rule.ts DragRule
Create: web-admin/src/utils/oa-form-create-related-approval.ts setupOaRelatedApproval registration
Modify: web-admin/src/main.ts Call setup after FormCreate install
Modify: web-admin/src/oa-fc-host/main.ts Same setup + FcDesigner.addDragRule
Modify: web-admin/src/oa-fc-host/App.vue Optional addComponent if instance API preferred (prefer global addDragRule)
Modify: web-admin/src/api/oaApproval.ts Client APIs
Modify: docs/superpowers/specs/2026-07-22-oa-related-approval-design.md Keep in sync if behavior clarifies

Clarification locked in this plan (view enrichment): For mode=view, return summaries for any existing tenant instance IDs; mark unavailable: true only if missing/deleted. Jump-to-detail still uses existing detail auth. Picker + start validation stay self + completed only.

Progress (2026-07-22): Tasks 1–7 implemented on feat/oa-approval-dingtalk. Task 8 (manual E2E) remains for human verification.


Task 1: Flatten + extract helpers (TDD)

Files:

  • Create: backend/app/services/oa_related_approval.py
  • Create: backend/tests/test_oa_related_approval.py

  • [ ] Step 1: Write failing tests

# backend/tests/test_oa_related_approval.py
import unittest

from app.services.oa_related_approval import (
    extract_related_approval_links,
    flatten_form_fields_for_summary,
)


class TestFlatten(unittest.TestCase):
    def test_skips_layout_and_summarizes_upload(self):
        schema = {
            "rule": [
                {"type": "html", "title": "说明"},
                {"type": "input", "field": "reason", "title": "事由"},
                {"type": "upload", "field": "files", "title": "附件"},
                {"type": "relatedApproval", "field": "prev", "title": "关联"},
                {"type": "textarea", "field": "long", "title": "详细说明"},
            ]
        }
        data = {
            "reason": "出差",
            "files": ["a.png", "b.png"],
            "prev": ["id1", "id2"],
            "long": "很长很长的说明文字" * 5,
        }
        fields = flatten_form_fields_for_summary(schema, data)
        labels = [f["label"] for f in fields]
        self.assertNotIn("说明", labels)
        self.assertEqual(fields[0], {"label": "事由", "value": "出差"})
        self.assertEqual(fields[1]["value"], "2 个")
        self.assertEqual(fields[2]["value"], "已关联 2 条")
        self.assertTrue(fields[3].get("fullWidth"))

    def test_extract_links_with_field_key_and_dedupe(self):
        schema = {
            "rule": [
                {"type": "relatedApproval", "field": "relA"},
                {
                    "type": "fcRow",
                    "children": [{"type": "relatedApproval", "field": "relB"}],
                },
            ]
        }
        data = {"relA": ["x", "x", ""], "relB": "solo"}
        links = extract_related_approval_links(schema, data)
        self.assertEqual(links, [("relA", "x"), ("relB", "solo")])


if __name__ == "__main__":
    unittest.main()
  • Step 2: Run tests — expect FAIL
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v

Expected: import error or missing symbols.

  • Step 3: Implement helpers
# backend/app/services/oa_related_approval.py
"""关联审批单:字段扁平化与 form_data 抽链"""
from __future__ import annotations

from typing import Any, Dict, List, Optional, Sequence, Tuple

LAYOUT_SKIP = frozenset(
    {"fcRow", "fcTable", "col", "row", "elCard", "space", "divider", "html", "title", "text", "tag"}
)
UPLOAD_TYPES = frozenset({"upload", "fcUpload", "image", "oaAttachment"})
LONG_TYPES = frozenset({"textarea", "input"})


def _walk_rules(rules: Any, out: List[dict]) -> None:
    if not isinstance(rules, list):
        return
    for r in rules:
        if not isinstance(r, dict):
            continue
        out.append(r)
        children = r.get("children") or (r.get("props") or {}).get("rule") or []
        if isinstance(children, list):
            _walk_rules(children, out)


def _schema_rules(schema: Any) -> List[dict]:
    if isinstance(schema, list):
        out: List[dict] = []
        _walk_rules(schema, out)
        return out
    if not isinstance(schema, dict):
        return []
    out = []
    _walk_rules(schema.get("rule") or schema.get("rules") or [], out)
    return out


def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
    """returns (text, fullWidth)"""
    if typ in UPLOAD_TYPES:
        if raw is None or raw == "" or raw == []:
            n = 0
        elif isinstance(raw, list):
            n = len(raw)
        else:
            n = len([x for x in str(raw).split(",") if x.strip()])
        return f"{n} 个", False
    if typ == "relatedApproval":
        if isinstance(raw, list):
            n = len([x for x in raw if str(x).strip()])
        elif raw:
            n = 1
        else:
            n = 0
        return f"已关联 {n} 条", False
    if isinstance(raw, list):
        text = "、".join(str(x) for x in raw if x is not None and str(x) != "")
    elif isinstance(raw, dict):
        text = str(raw.get("label") or raw.get("value") or raw)
    else:
        text = "" if raw is None else str(raw)
    full = typ in LONG_TYPES and len(text) > 40
    return text, full


def flatten_form_fields_for_summary(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
    data = form_data or {}
    out: List[Dict[str, Any]] = []
    for rule in _schema_rules(schema):
        typ = str(rule.get("type") or "")
        if not typ or typ in LAYOUT_SKIP:
            continue
        field = str(rule.get("field") or "").strip()
        if not field:
            continue
        label = str(rule.get("title") or rule.get("label") or field)
        text, full = _format_value(typ, data.get(field))
        item: Dict[str, Any] = {"label": label, "value": text}
        if full:
            item["fullWidth"] = True
        out.append(item)
    return out


def extract_related_approval_links(
    schema: Any, form_data: Optional[Dict[str, Any]]
) -> List[Tuple[str, str]]:
    """Ordered unique (field_key, to_instance_id)."""
    data = form_data or {}
    seen = set()
    links: List[Tuple[str, str]] = []
    for rule in _schema_rules(schema):
        if str(rule.get("type") or "") != "relatedApproval":
            continue
        field = str(rule.get("field") or "").strip()
        if not field:
            continue
        raw = data.get(field)
        ids: Sequence[Any]
        if isinstance(raw, list):
            ids = raw
        elif raw is None or raw == "":
            ids = []
        else:
            ids = [raw]
        for x in ids:
            tid = str(x or "").strip()
            if not tid:
                continue
            key = (field, tid)
            if key in seen:
                continue
            seen.add(key)
            links.append((field, tid))
    return links
  • Step 4: Run tests — expect PASS
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
  • Step 5: Commit
git add backend/app/services/oa_related_approval.py backend/tests/test_oa_related_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): add related-approval flatten and extract helpers

EOF
)"

Task 2: Picker list + enrichment service + API

Files:

  • Modify: backend/app/services/oa_related_approval.py
  • Modify: backend/app/api/oa_approval.py
  • Modify: backend/tests/test_oa_related_approval.py

  • [ ] Step 1: Add failing tests for validate + summary shape

class TestValidateLinkable(unittest.TestCase):
    def test_rejects_non_completed(self):
        from app.services.oa_related_approval import assert_linkable_instance
        from app.services.oa_engine import OaEngineError
        from unittest.mock import MagicMock
        inst = MagicMock(status="running", initiator_user_id="u1", tenant_no="t", del_flag=0)
        with self.assertRaises(OaEngineError):
            assert_linkable_instance(inst, user_id="u1", tenant_no="t")
  • [ ] Step 2: Run — expect FAIL (missing assert_linkable_instance)

  • [ ] Step 3: Implement service functions

Append to oa_related_approval.py:

from datetime import datetime

from app.models.models import OaApp, OaAppVersion, OaInstance
from app.services.oa_engine import OaEngineError, _loads


def assert_linkable_instance(inst: OaInstance, *, user_id: str, tenant_no: str) -> None:
    if not inst or inst.del_flag != 0 or inst.tenant_no != tenant_no:
        raise OaEngineError("关联的审批单不存在")
    if (inst.initiator_user_id or "") != user_id:
        raise OaEngineError("只能关联本人发起的审批单")
    if (inst.status or "") != "completed":
        raise OaEngineError("只能关联已通过的审批单")


def list_related_candidates(
    db,
    *,
    tenant_no: str,
    user_id: str,
    keyword: str = "",
    page: int = 1,
    page_size: int = 20,
) -> Dict[str, Any]:
    q = db.query(OaInstance).filter(
        OaInstance.tenant_no == tenant_no,
        OaInstance.del_flag == 0,
        OaInstance.initiator_user_id == user_id,
        OaInstance.status == "completed",
    )
    kw = (keyword or "").strip()
    if kw:
        from sqlalchemy import or_

        app_ids = [
            a.id
            for a in db.query(OaApp.id)
            .filter(
                OaApp.tenant_no == tenant_no,
                OaApp.del_flag == 0,
                OaApp.app_name.like(f"%{kw}%"),
            )
            .all()
        ]
        clauses = [OaInstance.title.like(f"%{kw}%")]
        if app_ids:
            clauses.append(OaInstance.app_id.in_(app_ids))
        q = q.filter(or_(*clauses))
    total = q.count()
    page = max(1, int(page or 1))
    page_size = min(100, max(1, int(page_size or 20)))
    rows = (
        q.order_by(OaInstance.create_time.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )
    app_map = {}
    ids = list({r.app_id for r in rows if r.app_id})
    if ids:
        for a in db.query(OaApp).filter(OaApp.id.in_(ids)).all():
            app_map[a.id] = a.app_name or ""
    items = []
    for r in rows:
        items.append(
            {
                "id": r.id,
                "appName": app_map.get(r.app_id, ""),
                "title": r.title or "",
                "applyTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
                "status": r.status or "",
            }
        )
    return {"list": items, "total": total, "page": page, "pageSize": page_size}


def build_related_summaries(
    db,
    *,
    tenant_no: str,
    user_id: str,
    ids: List[str],
    mode: str = "picker",
) -> List[Dict[str, Any]]:
    mode = (mode or "picker").strip()
    out: List[Dict[str, Any]] = []
    clean = [str(i).strip() for i in ids if str(i).strip()]
    if not clean:
        return out
    insts = (
        db.query(OaInstance)
        .filter(
            OaInstance.tenant_no == tenant_no,
            OaInstance.del_flag == 0,
            OaInstance.id.in_(clean),
        )
        .all()
    )
    by_id = {i.id: i for i in insts}
    for iid in clean:
        inst = by_id.get(iid)
        if not inst:
            out.append(
                {
                    "id": iid,
                    "unavailable": True,
                    "appName": "",
                    "applyTime": "",
                    "status": "",
                    "fields": [],
                }
            )
            continue
        if mode == "picker":
            try:
                assert_linkable_instance(inst, user_id=user_id, tenant_no=tenant_no)
            except OaEngineError:
                out.append(
                    {
                        "id": iid,
                        "unavailable": True,
                        "appName": "",
                        "applyTime": "",
                        "status": "",
                        "fields": [],
                    }
                )
                continue
        app = db.query(OaApp).filter(OaApp.id == inst.app_id, OaApp.del_flag == 0).first()
        ver = (
            db.query(OaAppVersion)
            .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
            .first()
        )
        schema = _loads(ver.form_schema_json, {}) if ver else {}
        form_data = _loads(inst.form_data_json, {}) if inst.form_data_json else {}
        out.append(
            {
                "id": inst.id,
                "unavailable": False,
                "appName": (app.app_name if app else "") or (inst.title or ""),
                "applyTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
                "status": inst.status or "",
                "fields": flatten_form_fields_for_summary(schema, form_data),
            }
        )
    return out
  • Step 4: Wire API routes in oa_approval.py (after templates section is fine):
@router.get("/related-candidates")
async def list_related_candidates(
    keyword: str = Query(""),
    page: int = Query(1),
    pageSize: int = Query(20, alias="pageSize"),
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.services import oa_related_approval as rel

    tenant_no = _get_tenant_no(db, user)
    data = rel.list_related_candidates(
        db,
        tenant_no=tenant_no,
        user_id=user.get("user_id") or "",
        keyword=keyword,
        page=page,
        page_size=pageSize,
    )
    return success_response(data)


@router.get("/related-summaries")
async def get_related_summaries(
    ids: str = Query("", description="comma-separated instance ids"),
    mode: str = Query("picker"),
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.services import oa_related_approval as rel
    from app.services.oa_engine import OaEngineError

    tenant_no = _get_tenant_no(db, user)
    id_list = [x.strip() for x in (ids or "").split(",") if x.strip()]
    try:
        items = rel.build_related_summaries(
            db,
            tenant_no=tenant_no,
            user_id=user.get("user_id") or "",
            ids=id_list,
            mode=mode,
        )
    except OaEngineError as e:
        return error_response(str(e))
    return success_response({"list": items})
  • Step 5: Run unit tests
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v

Expected: PASS (mock-based validate test).

  • Step 6: Commit
git add backend/app/services/oa_related_approval.py backend/app/api/oa_approval.py backend/tests/test_oa_related_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): add related-approval candidate and summary APIs

EOF
)"

Task 3: start_instance writes relations with field_key

Files:

  • Modify: backend/app/services/oa_engine.py (start_instance)
  • Modify: backend/tests/test_oa_related_approval.py

  • [ ] Step 1: Add unit test for merge logic (pure function)

In oa_related_approval.py add:

def merge_relation_targets(
    schema: Any,
    form_data: Optional[Dict[str, Any]],
    legacy_ids: Optional[List[str]] = None,
) -> List[Tuple[Optional[str], str]]:
    """Returns (field_key|None, to_id) — form fields first, then legacy without key."""
    links = extract_related_approval_links(schema, form_data)
    result: List[Tuple[Optional[str], str]] = [(f, i) for f, i in links]
    seen = {(f, i) for f, i in links}
    seen_ids = {i for _, i in links}
    for x in legacy_ids or []:
        tid = str(x or "").strip()
        if not tid or tid in seen_ids:
            continue
        result.append((None, tid))
        seen_ids.add(tid)
    return result

Test:

def test_merge_prefers_form_fields(self):
    schema = {"rule": [{"type": "relatedApproval", "field": "r1"}]}
    m = merge_relation_targets(schema, {"r1": ["a"]}, ["a", "b"])
    self.assertEqual(m, [("r1", "a"), (None, "b")])
  • Step 2: Replace relation loop in start_instance

After db.add(inst), replace the for to_id in related_instance_ids block with:

    from app.services.oa_related_approval import (
        assert_linkable_instance,
        merge_relation_targets,
    )

    targets = merge_relation_targets(schema, data, related_instance_ids)
    for field_key, to_id in targets:
        target = (
            db.query(OaInstance)
            .filter(
                OaInstance.id == to_id,
                OaInstance.tenant_no == tenant_no,
                OaInstance.del_flag == 0,
            )
            .first()
        )
        assert_linkable_instance(target, user_id=initiator_user_id, tenant_no=tenant_no)
        db.add(
            OaRelation(
                id=generate_uuid(),
                from_instance_id=inst.id,
                to_instance_id=to_id,
                field_key=field_key,
                tenant_no=tenant_no,
                order_num=0,
                remark=None,
                create_by=initiator_user_id,
                create_dept_id=dept_id or "",
                create_time=now,
                update_by=initiator_user_id,
                update_time=now,
                del_flag=0,
            )
        )
  • Step 3: Run tests
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py tests/test_oa_engine.py -v --tb=short

Expected: related tests PASS; existing engine tests still PASS.

  • Step 4: Commit
git add backend/app/services/oa_engine.py backend/app/services/oa_related_approval.py backend/tests/test_oa_related_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): persist relatedApproval links with field_key on start

EOF
)"

Task 4: Frontend API client

Files:

  • Modify: web-admin/src/api/oaApproval.ts

  • [ ] Step 1: Append APIs

export type OaRelatedSummaryField = {
  label: string
  value: string
  fullWidth?: boolean
}

export type OaRelatedSummary = {
  id: string
  unavailable?: boolean
  appName: string
  applyTime: string
  status: string
  fields: OaRelatedSummaryField[]
}

export function fetchOaRelatedCandidates(params?: {
  keyword?: string
  page?: number
  pageSize?: number
}) {
  return request.get<{ list: any[]; total: number; page: number; pageSize: number }>({
    url: `${BASE}/related-candidates`,
    params
  })
}

export function fetchOaRelatedSummaries(ids: string[], mode: 'picker' | 'view' = 'view') {
  return request.get<{ list: OaRelatedSummary[] }>({
    url: `${BASE}/related-summaries`,
    params: { ids: ids.filter(Boolean).join(','), mode }
  })
}
  • Step 2: Commit
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add OA related-approval API clients

EOF
)"

Task 5: Runtime component + FormCreate registration (spike shell)

Files:

  • Create: web-admin/src/components/oa-approval/OaRelatedApproval.vue
  • Create: web-admin/src/utils/oa-form-create-related-approval.ts
  • Modify: web-admin/src/main.ts
  • Modify: web-admin/src/oa-fc-host/main.ts

  • [ ] Step 1: Create minimal runtime component (modelValue string[], designer-safe empty state)

Use modelValue + update:modelValue + disabled + formCreateInject like SignaturePad. Collapsed N=3 constant:

const COLLAPSE_FIELD_COUNT = 3

Skeleton behavior:

  • If disabled or no modelValue?.length: show Mint dashed box +「添加关联」button (hidden when disabled)
  • Else: list cards; each loads summary via fetchOaRelatedSummaries (mode=view when disabled, picker when editing — use view always for display after select is fine)
  • Expand/collapse local Set
  • Web: class oa-rel--flow; narrow (matchMedia('(max-width: 768px)') or container): oa-rel--stack
  • Click title → router.push(/system-manage/oa-approval/instances/${id})
  • Picker dialog: el-dialog + table from fetchOaRelatedCandidates; multi-select; confirm merges into modelValue

Keep SCSS scoped; colors --primary / #10b981 / #ecfdf5; no FormCreate blue.

  • Step 2: Registration helper
// web-admin/src/utils/oa-form-create-related-approval.ts
import FcDesigner from '@form-create/designer'
import OaRelatedApproval from '@/components/oa-approval/OaRelatedApproval.vue'
import { relatedApprovalDragRule } from '@/utils/oa-related-approval-rule'

const FLAG = '__oaRelatedApprovalConfigured'

export function setupOaRelatedApproval(formCreate?: { component?: Function }) {
  if ((FcDesigner as any)[FLAG]) return
  FcDesigner.component('relatedApproval', OaRelatedApproval)
  formCreate?.component?.('relatedApproval', OaRelatedApproval)
  if (typeof FcDesigner.addDragRule === 'function') {
    FcDesigner.addDragRule(relatedApprovalDragRule)
  }
  ;(FcDesigner as any)[FLAG] = true
}
  • [ ] Step 3: Call setup in main.ts and oa-fc-host/main.ts immediately after setupFormCreateUpload(...).

  • [ ] Step 4: Manual smoke

cd web-admin && pnpm dev

Open designer form tab: left menu should list control after Task 6 DragRule; for now if rule missing, at least runtime import must not crash app.

  • Step 5: Commit
git add web-admin/src/components/oa-approval/OaRelatedApproval.vue \
  web-admin/src/utils/oa-form-create-related-approval.ts \
  web-admin/src/main.ts web-admin/src/oa-fc-host/main.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): register relatedApproval FormCreate runtime component

EOF
)"

Task 6: DragRule for designer palette

Files:

  • Create: web-admin/src/utils/oa-related-approval-rule.ts
  • Ensure Task 5 import resolves

  • [ ] Step 1: Implement DragRule

import uniqueId from '@form-create/utils/lib/unique'

export const relatedApprovalDragRule = {
  menu: 'main' as const,
  icon: 'icon-select',
  label: '关联审批单',
  name: 'relatedApproval',
  input: true,
  mask: true,
  validate: ['array'],
  event: ['change'],
  rule() {
    return {
      type: 'relatedApproval',
      field: uniqueId(),
      title: '关联审批单',
      info: '',
      $required: false,
      props: {}
    }
  },
  props() {
    return [
      {
        type: 'input',
        field: 'formCreateInfo',
        title: '提示信息'
      }
    ]
  }
}
  • Step 2: Verify designer

Open /#/system-manage/oa-approval/designer/:id →「2 表单设计」→ left 基础组件 shows「关联审批单」→ drag → export JSON type is relatedApproval → publish still OK.

  • Step 3: Commit
git add web-admin/src/utils/oa-related-approval-rule.ts web-admin/src/utils/oa-form-create-related-approval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add relatedApproval designer drag rule

EOF
)"

Task 7: Complete UI (picker, expand/collapse, responsive)

Files:

  • Modify: web-admin/src/components/oa-approval/OaRelatedApproval.vue

  • [ ] Step 1: Implement full interactions per spec §5

    • Collapse: appName · applyTime + first 3 fields as tag flow
    • Expand: all fields; fullWidth items flex-basis: 100%
    • Stack mode on narrow: each field one row label:value
    • Remove button when not disabled
    • Empty picker empty-state copy:「暂无已通过的申请」
    • Already-selected rows disabled in picker
    • Designer iframe: do not auto-fetch on mount if no ids; button may no-op or open empty dialog without crashing
  • [ ] Step 2: Visual check against Fresh Mint (primary green chips, 8–10px radius, no blue)

  • [ ] Step 3: Commit

git add web-admin/src/components/oa-approval/OaRelatedApproval.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): finish relatedApproval picker and summary UI

EOF
)"

Task 8: End-to-end acceptance + docs touch-up

Files:

  • Modify: docs/superpowers/specs/2026-07-22-oa-related-approval-design.md (view-mode clarification if needed)
  • Optional: checklist note in docs/superpowers/plans/2026-07-14-oa-approval-e2e-checklist.md

  • [ ] Step 1: Manual E2E

  1. Publish app with relatedApproval field
  2. Have ≥1 completed instance as same user
  3. Start new instance → select 2 → expand/collapse → submit
  4. DB/API: oa_relation rows have field_key
  5. Detail: summaries load; title navigates
  6. Tamper ID in network → start fails with clear message
  7. Old app without control: unchanged
  • Step 2: Run backend tests once more
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py tests/test_oa_publish.py -v
  • Step 3: Update graph if code landed
./scripts/update-graph.sh backend
./scripts/update-graph.sh frontend
  • Step 4: Commit docs / graph
git add docs/superpowers/specs/2026-07-22-oa-related-approval-design.md \
  docs/superpowers/plans/2026-07-22-oa-related-approval.md \
  backend/graphify-out web-admin/graphify-out 2>/dev/null || true
git commit -m "$(cat <<'EOF'
docs(oa): mark related-approval E2E done and refresh graphs

EOF
)"

Spec coverage self-check

Spec item Task
FormCreate 四层扩展 5–6
form_data string[] 5, 7
本人+completed+任意应用+多选 2, 7
收起前 3 / 展开全字段 / Web 流 / 移动逐行 7
enrichment + flatten 规则 1–2
start 写 oa_relation+field_key + legacy 兜底 3
越权拒绝 / 旧应用兼容 2–3, 8
Fresh Mint UI 5, 7
详情跳转 7
不串子流程 / 不回填字段 N/A (by omission)

Placeholder scan

No TBD steps; view-mode enrichment clarified in File map.


Execution handoff

Plan complete and saved to docs/superpowers/plans/2026-07-22-oa-related-approval.md.

Two execution options:

  1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks
  2. Inline Execution — execute in this session with executing-plans checkpoints

Which approach?