2026-07-22-oa-related-approval-nested-print.md 23 KB

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:

    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:

        self.assertEqual(fields[2]["value"], "已关联 2 条")
        self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
  • Step 2: Run test — expect FAIL
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:

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
cd backend && source venv/bin/activate && pytest tests/test_oa_related_approval.py -v

Expected: all PASS.

  • 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): 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:

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:

<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):

.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
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:

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

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:

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:

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:

<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:

.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
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
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
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.