|
|
@@ -0,0 +1,884 @@
|
|
|
+# 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**
|
|
|
+
|
|
|
+```python
|
|
|
+# 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: import error or missing symbols.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement helpers**
|
|
|
+
|
|
|
+```python
|
|
|
+# 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```python
|
|
|
+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`:
|
|
|
+
|
|
|
+```python
|
|
|
+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):
|
|
|
+
|
|
|
+```python
|
|
|
+@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**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS (mock-based validate test).
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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:
|
|
|
+
|
|
|
+```python
|
|
|
+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:
|
|
|
+
|
|
|
+```python
|
|
|
+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:
|
|
|
+
|
|
|
+```python
|
|
|
+ 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```typescript
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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:
|
|
|
+
|
|
|
+```ts
|
|
|
+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**
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```typescript
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh backend
|
|
|
+./scripts/update-graph.sh frontend
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit docs / graph**
|
|
|
+
|
|
|
+```bash
|
|
|
+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?
|