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
| 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.
Files:
backend/app/services/oa_related_approval.pyCreate: 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()
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
Expected: import error or missing symbols.
# 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
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
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
)"
Files:
backend/app/services/oa_related_approval.pybackend/app/api/oa_approval.pyModify: 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
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})
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
Expected: PASS (mock-based validate test).
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
)"
start_instance writes relations with field_keyFiles:
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")])
start_instanceAfter 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,
)
)
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.
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
)"
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 }
})
}
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add OA related-approval API clients
EOF
)"
Files:
web-admin/src/components/oa-approval/OaRelatedApproval.vueweb-admin/src/utils/oa-form-create-related-approval.tsweb-admin/src/main.tsModify: 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:
disabled or no modelValue?.length: show Mint dashed box +「添加关联」button (hidden when disabled)fetchOaRelatedSummaries (mode=view when disabled, picker when editing — use view always for display after select is fine)oa-rel--flow; narrow (matchMedia('(max-width: 768px)') or container): oa-rel--stackrouter.push(/system-manage/oa-approval/instances/${id})el-dialog + table from fetchOaRelatedCandidates; multi-select; confirm merges into modelValueKeep SCSS scoped; colors --primary / #10b981 / #ecfdf5; no FormCreate blue.
// 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.
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
)"
Files:
web-admin/src/utils/oa-related-approval-rule.tsEnsure 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: '提示信息'
}
]
}
}
Open /#/system-manage/oa-approval/designer/:id →「2 表单设计」→ left 基础组件 shows「关联审批单」→ drag → export JSON type is relatedApproval → publish still OK.
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
)"
Files:
Modify: web-admin/src/components/oa-approval/OaRelatedApproval.vue
[ ] Step 1: Implement full interactions per spec §5
appName · applyTime + first 3 fields as tag flowfullWidth items flex-basis: 100%label:value[ ] 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
)"
Files:
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
relatedApproval fieldoa_relation rows have field_keycd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py tests/test_oa_publish.py -v
./scripts/update-graph.sh backend
./scripts/update-graph.sh frontend
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 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) |
No TBD steps; view-mode enrichment clarified in File map.
Plan complete and saved to docs/superpowers/plans/2026-07-22-oa-related-approval.md.
Two execution options:
Which approach?