|
|
@@ -0,0 +1,741 @@
|
|
|
+# OA Related Approval Nested + Print 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:** Make nested「已关联 M 条」clickable on screen, and make PDF print render related approvals as multi-line field blocks with up to 3 levels of recursion (deduped).
|
|
|
+
|
|
|
+**Architecture:** Keep screen enrichment flat (`value` + optional `relatedIds`). Do not return a summary tree from the API. Print path asynchronously batches `fetchOaRelatedSummaries`, then a pure recursive builder (`maxDepth=3`, `visited`) produces `RelatedPrintBlock[]` for `OaApprovalPrintSheet`.
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI · pytest · Vue 3 · TypeScript · existing `oa-approval-print.ts` / html2canvas / jspdf · Element Plus
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| Path | Responsibility |
|
|
|
+|------|----------------|
|
|
|
+| Modify: `backend/app/services/oa_related_approval.py` | Flatten relatedApproval fields with `relatedIds` |
|
|
|
+| Modify: `backend/tests/test_oa_related_approval.py` | Assert `relatedIds` on nested/related flatten |
|
|
|
+| Modify: `web-admin/src/api/oaApproval.ts` | `OaRelatedSummaryField.relatedIds?: string[]` |
|
|
|
+| Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue` | Clickable「已关联 M 条」→ detail |
|
|
|
+| Create: `web-admin/src/utils/oa-approval-print-related.ts` | Pure recursive block builder + async fetch wrapper |
|
|
|
+| Modify: `web-admin/src/utils/oa-approval-print.ts` | Async form rows; special-case `relatedApproval` |
|
|
|
+| Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue` | Render `blocks` with indent |
|
|
|
+| Verify: V1 spec cross-ref (already updated in brainstorm) | No further doc task unless drift |
|
|
|
+
|
|
|
+**Locked rules:**
|
|
|
+- Screen: nested = count text + jump (`M=1` → that id; `M>1` → first id). No nested cards.
|
|
|
+- Print: multi-line blocks; recurse on `relatedIds` while `depth < 3`; at depth 3 keep count text only; duplicate id → title `(已在上文出现)`.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: Backend flatten emits `relatedIds` (TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/tests/test_oa_related_approval.py`
|
|
|
+- Modify: `backend/app/services/oa_related_approval.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write failing test for `relatedIds`**
|
|
|
+
|
|
|
+Add to `TestFlatten` in `backend/tests/test_oa_related_approval.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+ def test_related_approval_field_includes_related_ids(self):
|
|
|
+ schema = {
|
|
|
+ "rule": [
|
|
|
+ {"type": "input", "field": "reason", "title": "事由"},
|
|
|
+ {"type": "relatedApproval", "field": "prev", "title": "关联审批单"},
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ data = {"reason": "拜访", "prev": ["id1", "", "id2", "id1"]}
|
|
|
+ fields = flatten_form_fields_for_summary(schema, data)
|
|
|
+ rel = next(f for f in fields if f["label"] == "关联审批单")
|
|
|
+ self.assertEqual(rel["value"], "已关联 2 条")
|
|
|
+ self.assertEqual(rel["relatedIds"], ["id1", "id2"])
|
|
|
+
|
|
|
+ def test_related_approval_empty_ids(self):
|
|
|
+ schema = {"rule": [{"type": "relatedApproval", "field": "prev", "title": "关联"}]}
|
|
|
+ fields = flatten_form_fields_for_summary(schema, {"prev": []})
|
|
|
+ self.assertEqual(fields[0]["value"], "已关联 0 条")
|
|
|
+ self.assertEqual(fields[0]["relatedIds"], [])
|
|
|
+```
|
|
|
+
|
|
|
+Also update existing `test_skips_layout_and_summarizes_upload` assertion for the related field — after change it should allow `relatedIds`:
|
|
|
+
|
|
|
+```python
|
|
|
+ self.assertEqual(fields[2]["value"], "已关联 2 条")
|
|
|
+ self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run test — expect FAIL**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py::TestFlatten::test_related_approval_field_includes_related_ids -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `AssertionError` / `KeyError` — no `relatedIds`.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement flatten change**
|
|
|
+
|
|
|
+In `backend/app/services/oa_related_approval.py`, add helper and change `flatten_form_fields_for_summary`:
|
|
|
+
|
|
|
+```python
|
|
|
+def _normalize_related_ids(raw: Any) -> List[str]:
|
|
|
+ if isinstance(raw, list):
|
|
|
+ items = raw
|
|
|
+ elif raw is None or raw == "":
|
|
|
+ items = []
|
|
|
+ else:
|
|
|
+ items = [raw]
|
|
|
+ out: List[str] = []
|
|
|
+ seen = set()
|
|
|
+ for x in items:
|
|
|
+ t = str(x or "").strip()
|
|
|
+ if not t or t in seen:
|
|
|
+ continue
|
|
|
+ seen.add(t)
|
|
|
+ out.append(t)
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+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)
|
|
|
+ raw = data.get(field)
|
|
|
+ if typ == "relatedApproval":
|
|
|
+ ids = _normalize_related_ids(raw)
|
|
|
+ out.append(
|
|
|
+ {
|
|
|
+ "label": label,
|
|
|
+ "value": f"已关联 {len(ids)} 条",
|
|
|
+ "relatedIds": ids,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ continue
|
|
|
+ text, full = _format_value(typ, raw)
|
|
|
+ item: Dict[str, Any] = {"label": label, "value": text}
|
|
|
+ if full:
|
|
|
+ item["fullWidth"] = True
|
|
|
+ out.append(item)
|
|
|
+ return out
|
|
|
+```
|
|
|
+
|
|
|
+Keep `_format_value` relatedApproval branch for any other callers, or leave as-is (dead for flatten path).
|
|
|
+
|
|
|
+- [ ] **Step 4: Run full related-approval tests — expect PASS**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: all PASS.
|
|
|
+
|
|
|
+- [ ] **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): emit relatedIds when flattening nested relatedApproval fields
|
|
|
+
|
|
|
+Screen jump and print recursion need the instance id list alongside the
|
|
|
+「已关联 M 条」summary text without changing form_data shape.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: Frontend types + clickable nested count
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/oaApproval.ts`
|
|
|
+- Modify: `web-admin/src/components/oa-approval/OaRelatedApproval.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: Extend field type**
|
|
|
+
|
|
|
+In `web-admin/src/api/oaApproval.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+export type OaRelatedSummaryField = {
|
|
|
+ label: string
|
|
|
+ value: string
|
|
|
+ fullWidth?: boolean
|
|
|
+ /** 嵌套关联审批单的 instanceId 列表(可跳转 / 打印递归) */
|
|
|
+ relatedIds?: string[]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Render clickable count in card fields**
|
|
|
+
|
|
|
+In `OaRelatedApproval.vue` field value area, replace the plain `{{ f.value }}` branch with:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template v-else-if="(f.relatedIds?.length || 0) > 0">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="oa-related-approval__link-btn"
|
|
|
+ @click.stop="goDetail(f.relatedIds![0])"
|
|
|
+ >
|
|
|
+ {{ f.value }}
|
|
|
+ </button>
|
|
|
+</template>
|
|
|
+<template v-else>{{ f.value }}</template>
|
|
|
+```
|
|
|
+
|
|
|
+Add style (native button, survives `form.disabled`):
|
|
|
+
|
|
|
+```scss
|
|
|
+.oa-related-approval__link-btn {
|
|
|
+ padding: 0;
|
|
|
+ border: 0;
|
|
|
+ background: transparent;
|
|
|
+ color: var(--primary, #10b981);
|
|
|
+ font-size: inherit;
|
|
|
+ text-decoration: underline;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.oa-related-approval__link-btn:hover {
|
|
|
+ color: var(--primary-dark, #059669);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Reuse existing `goDetail(id)` (router push to `/system-manage/oa-approval/instances/${id}`).
|
|
|
+
|
|
|
+- [ ] **Step 3: Manual smoke**
|
|
|
+
|
|
|
+1. Open an instance whose related card shows a nested field「已关联 1 条」with backend returning `relatedIds`.
|
|
|
+2. Click the count → navigates to that instance.
|
|
|
+3. Confirm no nested sub-cards appear.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/oaApproval.ts web-admin/src/components/oa-approval/OaRelatedApproval.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): make nested relatedApproval count jump to first instance
|
|
|
+
|
|
|
+Uses relatedIds from enrichment so screen stays count-only without nested cards.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Pure print block builder
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/utils/oa-approval-print-related.ts`
|
|
|
+
|
|
|
+- [ ] **Step 1: Add types + pure builder**
|
|
|
+
|
|
|
+Create `web-admin/src/utils/oa-approval-print-related.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+import type { OaRelatedSummary, OaRelatedSummaryField } from '@/api/oaApproval'
|
|
|
+import { fetchOaRelatedSummaries } from '@/api/oaApproval'
|
|
|
+
|
|
|
+export const OA_RELATED_PRINT_MAX_DEPTH = 3
|
|
|
+
|
|
|
+export type RelatedPrintBlock = {
|
|
|
+ indexLabel: string
|
|
|
+ title: string
|
|
|
+ lines?: { label: string; value: string }[]
|
|
|
+ children?: RelatedPrintBlock[]
|
|
|
+}
|
|
|
+
|
|
|
+function datePart(applyTime: string) {
|
|
|
+ const t = String(applyTime || '').trim()
|
|
|
+ return t ? t.slice(0, 10) : ''
|
|
|
+}
|
|
|
+
|
|
|
+function titleOf(s: OaRelatedSummary) {
|
|
|
+ if (s.unavailable) return '无法查看'
|
|
|
+ const name = String(s.appName || '审批单').trim() || '审批单'
|
|
|
+ const d = datePart(s.applyTime)
|
|
|
+ return d ? `${name} · ${d}` : name
|
|
|
+}
|
|
|
+
|
|
|
+/** Pure: build blocks from an already-loaded summary map (for tests / recursion). */
|
|
|
+export function buildRelatedPrintBlocksFromMap(
|
|
|
+ ids: string[],
|
|
|
+ summaryById: Map<string, OaRelatedSummary>,
|
|
|
+ opts: {
|
|
|
+ depth: number
|
|
|
+ maxDepth?: number
|
|
|
+ visited: Set<string>
|
|
|
+ indexPrefix?: string
|
|
|
+ }
|
|
|
+): RelatedPrintBlock[] {
|
|
|
+ const maxDepth = opts.maxDepth ?? OA_RELATED_PRINT_MAX_DEPTH
|
|
|
+ const depth = opts.depth
|
|
|
+ const visited = opts.visited
|
|
|
+ const prefix = opts.indexPrefix || ''
|
|
|
+ const blocks: RelatedPrintBlock[] = []
|
|
|
+
|
|
|
+ ids.forEach((rawId, i) => {
|
|
|
+ const id = String(rawId || '').trim()
|
|
|
+ if (!id) return
|
|
|
+ const indexLabel = prefix ? `${prefix}.${i + 1}` : String(i + 1)
|
|
|
+
|
|
|
+ if (visited.has(id)) {
|
|
|
+ blocks.push({ indexLabel, title: '(已在上文出现)' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ visited.add(id)
|
|
|
+
|
|
|
+ const s = summaryById.get(id)
|
|
|
+ if (!s) {
|
|
|
+ blocks.push({ indexLabel, title: '关联信息加载失败' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (s.unavailable) {
|
|
|
+ blocks.push({ indexLabel, title: '无法查看' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const lines: { label: string; value: string }[] = []
|
|
|
+ const children: RelatedPrintBlock[] = []
|
|
|
+ const fields = Array.isArray(s.fields) ? s.fields : []
|
|
|
+
|
|
|
+ for (const f of fields) {
|
|
|
+ const label = String(f.label || '').trim() || '-'
|
|
|
+ const value = String(f.value || '').trim() || '-'
|
|
|
+ const nestedIds = (f.relatedIds || []).map((x) => String(x || '').trim()).filter(Boolean)
|
|
|
+
|
|
|
+ if (nestedIds.length && depth < maxDepth) {
|
|
|
+ // Parent line keeps count text; children expand underneath
|
|
|
+ lines.push({ label, value })
|
|
|
+ children.push(
|
|
|
+ ...buildRelatedPrintBlocksFromMap(nestedIds, summaryById, {
|
|
|
+ depth: depth + 1,
|
|
|
+ maxDepth,
|
|
|
+ visited,
|
|
|
+ indexPrefix: indexLabel
|
|
|
+ })
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ lines.push({ label, value })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ blocks.push({
|
|
|
+ indexLabel,
|
|
|
+ title: titleOf(s),
|
|
|
+ lines,
|
|
|
+ children: children.length ? children : undefined
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ return blocks
|
|
|
+}
|
|
|
+
|
|
|
+async function loadSummariesIntoMap(ids: string[], map: Map<string, OaRelatedSummary>) {
|
|
|
+ const missing = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))].filter(
|
|
|
+ (id) => !map.has(id)
|
|
|
+ )
|
|
|
+ if (!missing.length) return
|
|
|
+ try {
|
|
|
+ const res: any = await fetchOaRelatedSummaries(missing, 'view')
|
|
|
+ const list: OaRelatedSummary[] = Array.isArray(res?.list) ? res.list : Array.isArray(res) ? res : []
|
|
|
+ for (const s of list) {
|
|
|
+ if (s?.id) map.set(String(s.id), s)
|
|
|
+ }
|
|
|
+ for (const id of missing) {
|
|
|
+ if (!map.has(id)) {
|
|
|
+ map.set(id, {
|
|
|
+ id,
|
|
|
+ unavailable: true,
|
|
|
+ appName: '',
|
|
|
+ applyTime: '',
|
|
|
+ status: '',
|
|
|
+ fields: []
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ for (const id of missing) {
|
|
|
+ if (!map.has(id)) {
|
|
|
+ map.set(id, {
|
|
|
+ id,
|
|
|
+ unavailable: true,
|
|
|
+ appName: '',
|
|
|
+ applyTime: '',
|
|
|
+ status: '',
|
|
|
+ fields: []
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Collect nested relatedIds up to maxDepth for batch prefetch. */
|
|
|
+function collectNestedIds(
|
|
|
+ ids: string[],
|
|
|
+ map: Map<string, OaRelatedSummary>,
|
|
|
+ depth: number,
|
|
|
+ maxDepth: number,
|
|
|
+ acc: Set<string>
|
|
|
+) {
|
|
|
+ for (const id of ids) {
|
|
|
+ const t = String(id || '').trim()
|
|
|
+ if (!t || acc.has(t)) continue
|
|
|
+ acc.add(t)
|
|
|
+ if (depth >= maxDepth) continue
|
|
|
+ const s = map.get(t)
|
|
|
+ if (!s || s.unavailable) continue
|
|
|
+ for (const f of s.fields || []) {
|
|
|
+ const nested = (f.relatedIds || []).map((x) => String(x || '').trim()).filter(Boolean)
|
|
|
+ for (const n of nested) acc.add(n)
|
|
|
+ if (nested.length) collectNestedIds(nested, map, depth + 1, maxDepth, acc)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Prefetch summaries (breadth waves up to maxDepth) then build blocks.
|
|
|
+ * depth starts at 1 for ids on the printed form's relatedApproval field.
|
|
|
+ */
|
|
|
+export async function assembleRelatedPrintBlocks(
|
|
|
+ ids: string[],
|
|
|
+ opts?: { maxDepth?: number }
|
|
|
+): Promise<RelatedPrintBlock[]> {
|
|
|
+ const maxDepth = opts?.maxDepth ?? OA_RELATED_PRINT_MAX_DEPTH
|
|
|
+ const rootIds = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))]
|
|
|
+ if (!rootIds.length) return []
|
|
|
+
|
|
|
+ const map = new Map<string, OaRelatedSummary>()
|
|
|
+ // Wave fetch: load roots, then any nested ids referenced, up to maxDepth
|
|
|
+ await loadSummariesIntoMap(rootIds, map)
|
|
|
+ for (let d = 1; d < maxDepth; d++) {
|
|
|
+ const need = new Set<string>()
|
|
|
+ collectNestedIds(rootIds, map, 1, maxDepth, need)
|
|
|
+ const missing = [...need].filter((id) => !map.has(id))
|
|
|
+ if (!missing.length) break
|
|
|
+ await loadSummariesIntoMap(missing, map)
|
|
|
+ }
|
|
|
+
|
|
|
+ return buildRelatedPrintBlocksFromMap(rootIds, map, {
|
|
|
+ depth: 1,
|
|
|
+ maxDepth,
|
|
|
+ visited: new Set()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export function normalizeRelatedIdsFromFormValue(raw: unknown): string[] {
|
|
|
+ if (Array.isArray(raw)) {
|
|
|
+ const out: string[] = []
|
|
|
+ const seen = new Set<string>()
|
|
|
+ for (const x of raw) {
|
|
|
+ const t = String(x || '').trim()
|
|
|
+ if (!t || seen.has(t)) continue
|
|
|
+ seen.add(t)
|
|
|
+ out.push(t)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+ if (raw == null || raw === '') return []
|
|
|
+ const t = String(raw).trim()
|
|
|
+ return t ? [t] : []
|
|
|
+}
|
|
|
+
|
|
|
+/** @internal exported for quick node assert — optional smoke */
|
|
|
+export function _smokeBuildDuplicate() {
|
|
|
+ const map = new Map<string, OaRelatedSummary>()
|
|
|
+ map.set('a', {
|
|
|
+ id: 'a',
|
|
|
+ appName: '单A',
|
|
|
+ applyTime: '2026-07-10 12:00:00',
|
|
|
+ status: 'completed',
|
|
|
+ fields: [{ label: '关联', value: '已关联 1 条', relatedIds: ['a'] }]
|
|
|
+ })
|
|
|
+ return buildRelatedPrintBlocksFromMap(['a'], map, { depth: 1, visited: new Set() })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Quick sanity in Node (optional)**
|
|
|
+
|
|
|
+From `web-admin` (or skip if no ts-node). Prefer manual reason:
|
|
|
+
|
|
|
+- Self-ref `a → a`: first block full; nested child title `(已在上文出现)`.
|
|
|
+- Depth: when `depth === 3`, nested `relatedIds` must not create children.
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/utils/oa-approval-print-related.ts
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): add related-approval print block assembler
|
|
|
+
|
|
|
+Pure map-based recursion with maxDepth=3 and visited dedupe; async
|
|
|
+wrapper batch-fetches summaries for PDF export.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: Wire print model + sheet UI
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/utils/oa-approval-print.ts`
|
|
|
+- Modify: `web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: Extend `OaPrintFieldRow` and make form-row build async**
|
|
|
+
|
|
|
+In `oa-approval-print.ts`:
|
|
|
+
|
|
|
+```ts
|
|
|
+import {
|
|
|
+ assembleRelatedPrintBlocks,
|
|
|
+ normalizeRelatedIdsFromFormValue,
|
|
|
+ type RelatedPrintBlock
|
|
|
+} from '@/utils/oa-approval-print-related'
|
|
|
+
|
|
|
+export type OaPrintFieldRow = {
|
|
|
+ label: string
|
|
|
+ text?: string
|
|
|
+ urls?: string[]
|
|
|
+ /** 关联审批单多行块(优先于 text) */
|
|
|
+ blocks?: RelatedPrintBlock[]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Change `walkRules` / `buildFormRows` to async:
|
|
|
+
|
|
|
+```ts
|
|
|
+async function walkRules(
|
|
|
+ rules: any[],
|
|
|
+ formData: Record<string, any>,
|
|
|
+ out: OaPrintFieldRow[]
|
|
|
+) {
|
|
|
+ if (!Array.isArray(rules)) return
|
|
|
+ for (const rule of rules) {
|
|
|
+ if (!rule || typeof rule !== 'object') continue
|
|
|
+ const type = String(rule.type || '').trim()
|
|
|
+ const children = rule.children || rule.props?.rule
|
|
|
+ if (Array.isArray(children) && children.length) {
|
|
|
+ await walkRules(children, formData, out)
|
|
|
+ }
|
|
|
+ if (SKIP_RULE_TYPES.has(type)) continue
|
|
|
+ const field = String(rule.field || '').trim()
|
|
|
+ if (!field) continue
|
|
|
+ const label = String(rule.title || rule.label || rule.props?.label || field).trim() || field
|
|
|
+ const value = formData?.[field]
|
|
|
+
|
|
|
+ if (type === 'relatedApproval') {
|
|
|
+ const ids = normalizeRelatedIdsFromFormValue(value)
|
|
|
+ if (!ids.length) {
|
|
|
+ out.push({ label, text: '-' })
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const blocks = await assembleRelatedPrintBlocks(ids)
|
|
|
+ out.push({ label, blocks: blocks.length ? blocks : [{ indexLabel: '1', title: '关联信息加载失败' }] })
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ // … keep existing upload / url / formatScalar branches …
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function buildFormRows(detail: any): Promise<OaPrintFieldRow[]> {
|
|
|
+ // same as today but await walkRules(...)
|
|
|
+}
|
|
|
+
|
|
|
+export async function buildOaPrintModel(
|
|
|
+ detail: any,
|
|
|
+ opts: { printerName: string; printTime?: string }
|
|
|
+): Promise<OaPrintModel> {
|
|
|
+ // …
|
|
|
+ rows: [...headerRows, ...(await buildFormRows(detail))],
|
|
|
+ // …
|
|
|
+}
|
|
|
+
|
|
|
+export async function exportOaApprovalPdf(...) {
|
|
|
+ const model = await buildOaPrintModel(detail, { printerName: opts.printerName })
|
|
|
+ // unchanged mount/canvas
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Search callers of `buildOaPrintModel` — only `exportOaApprovalPdf` today; ensure `await`.
|
|
|
+
|
|
|
+- [ ] **Step 2: Render blocks in `OaApprovalPrintSheet.vue`**
|
|
|
+
|
|
|
+In the table cell template:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template v-if="row.blocks?.length">
|
|
|
+ <div class="oa-print-related">
|
|
|
+ <div
|
|
|
+ v-for="(b, bi) in row.blocks"
|
|
|
+ :key="`b-${idx}-${bi}`"
|
|
|
+ class="oa-print-related__block"
|
|
|
+ >
|
|
|
+ <div class="oa-print-related__title">{{ b.indexLabel }}. {{ b.title }}</div>
|
|
|
+ <div
|
|
|
+ v-for="(line, li) in b.lines || []"
|
|
|
+ :key="`l-${bi}-${li}`"
|
|
|
+ class="oa-print-related__line"
|
|
|
+ >
|
|
|
+ <span class="oa-print-related__k">{{ line.label }}</span>
|
|
|
+ <span class="oa-print-related__v">{{ line.value }}</span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-if="b.children?.length"
|
|
|
+ class="oa-print-related__children"
|
|
|
+ >
|
|
|
+ <!-- recurse: reuse a small inner component or inline second level -->
|
|
|
+ <div
|
|
|
+ v-for="(c, ci) in b.children"
|
|
|
+ :key="`c-${bi}-${ci}`"
|
|
|
+ class="oa-print-related__block"
|
|
|
+ >
|
|
|
+ <div class="oa-print-related__title">{{ c.indexLabel }}. {{ c.title }}</div>
|
|
|
+ <div
|
|
|
+ v-for="(line, li) in c.lines || []"
|
|
|
+ :key="`cl-${ci}-${li}`"
|
|
|
+ class="oa-print-related__line"
|
|
|
+ >
|
|
|
+ <span class="oa-print-related__k">{{ line.label }}</span>
|
|
|
+ <span class="oa-print-related__v">{{ line.value }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="c.children?.length" class="oa-print-related__children">
|
|
|
+ <div
|
|
|
+ v-for="(g, gi) in c.children"
|
|
|
+ :key="`g-${ci}-${gi}`"
|
|
|
+ class="oa-print-related__block"
|
|
|
+ >
|
|
|
+ <div class="oa-print-related__title">{{ g.indexLabel }}. {{ g.title }}</div>
|
|
|
+ <div
|
|
|
+ v-for="(line, li) in g.lines || []"
|
|
|
+ :key="`gl-${gi}-${li}`"
|
|
|
+ class="oa-print-related__line"
|
|
|
+ >
|
|
|
+ <span class="oa-print-related__k">{{ line.label }}</span>
|
|
|
+ <span class="oa-print-related__v">{{ line.value }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+<template v-else-if="row.urls?.length">
|
|
|
+ ...
|
|
|
+</template>
|
|
|
+<template v-else>
|
|
|
+ <span class="oa-print-text">{{ row.text || '-' }}</span>
|
|
|
+</template>
|
|
|
+```
|
|
|
+
|
|
|
+SCSS:
|
|
|
+
|
|
|
+```scss
|
|
|
+.oa-print-related__block { margin: 0 0 8px; }
|
|
|
+.oa-print-related__title { font-weight: 600; margin-bottom: 2px; }
|
|
|
+.oa-print-related__line {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.oa-print-related__k { color: #64748b; flex: 0 0 auto; }
|
|
|
+.oa-print-related__v { color: #334155; word-break: break-all; }
|
|
|
+.oa-print-related__children {
|
|
|
+ margin: 4px 0 0 14px;
|
|
|
+ padding-left: 8px;
|
|
|
+ border-left: 2px solid #cbd5e1;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+(Three nested template levels cover maxDepth=3 without a recursive SFC.)
|
|
|
+
|
|
|
+- [ ] **Step 3: Manual PDF check**
|
|
|
+
|
|
|
+1. Print instance with 1 related approval that has normal fields → PDF shows multi-line block, not UUID.
|
|
|
+2. Chain A→B→C→D → D appears only as「已关联 M 条」under C (3rd related layer).
|
|
|
+3. Cycle A→B→A → second A is「(已在上文出现)」.
|
|
|
+4. Instance without relatedApproval → PDF unchanged.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/utils/oa-approval-print.ts web-admin/src/components/oa-approval/OaApprovalPrintSheet.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): print relatedApproval as recursive multi-line blocks
|
|
|
+
|
|
|
+PDF export fetches summaries and renders up to three nested levels with
|
|
|
+dedupe, instead of dumping raw instance ids.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: Spec status + graph + final verify
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md` (status line)
|
|
|
+- Run: `./scripts/update-graph.sh frontend` (and backend if Python signatures changed)
|
|
|
+
|
|
|
+- [ ] **Step 1: Mark spec status**
|
|
|
+
|
|
|
+Change header status to: `已批准(实现计划见 docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md)`
|
|
|
+
|
|
|
+Confirm V1 §5 already cross-references nested-print design (done in brainstorm).
|
|
|
+
|
|
|
+- [ ] **Step 2: Regression commands**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v
|
|
|
+cd ../web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -50
|
|
|
+./scripts/update-graph.sh all
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit docs/graph if dirty**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/specs/2026-07-22-oa-related-approval-nested-print-design.md \
|
|
|
+ docs/superpowers/plans/2026-07-22-oa-related-approval-nested-print.md \
|
|
|
+ backend/graphify-out web-admin/graphify-out
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+docs(oa): add nested relatedApproval print plan and mark spec ready
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage checklist
|
|
|
+
|
|
|
+| Spec requirement | Task |
|
|
|
+|------------------|------|
|
|
|
+| enrichment `relatedIds` | Task 1 |
|
|
|
+| Screen clickable「已关联 M 条」 | Task 2 |
|
|
|
+| Print multi-line + business fields | Task 3–4 |
|
|
|
+| Recurse max 3 levels | Task 3 (`OA_RELATED_PRINT_MAX_DEPTH`) |
|
|
|
+| Visited dedupe | Task 3 |
|
|
|
+| Unavailable / load fail placeholders | Task 3–4 |
|
|
|
+| No screen nested cards | Task 2 (explicit non-goal) |
|
|
|
+| Old apps unchanged | Task 4 (only `relatedApproval` branch) |
|
|
|
+| V1 cross-ref | Task 5 (verify) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Self-review notes
|
|
|
+
|
|
|
+- No TBD placeholders in steps.
|
|
|
+- `buildOaPrintModel` becomes async — only `exportOaApprovalPdf` awaits it.
|
|
|
+- Sheet uses 3 hardcoded nest levels matching maxDepth=3 (no recursive Vue component required).
|
|
|
+- Prefetch waves may over-fetch slightly; acceptable vs complex BFS queue.
|