Files
shanxia2.0/openspec/changes/crm-contract-multi-package-shared-qty/design.md
T
2026-09-26 11:59:39 +08:00

6.5 KiB

Design

Context

See proposal.md for motivation. Current state:

  • crm_contract_product.package_spec is varchar(200) scalar; UI DictSelect single-select.
  • Transport qty (pl_contract_plan_qty.py) keys by (productId, packageSpec); one contract line ↔ one package ↔ one quantity.
  • Design doc docs/superpowers/specs/2026-09-10-plan-product-package-key-design.md assumes multi-package = multi-line with independent quotas.

New behavior needs line-scoped shared pools while keeping plan lines single-package, and detail UI split by package for consumption only.

Goals / Non-Goals

Goals:

  • Persist N package specs per contract product line with one quantity (shared pool).
  • Enforce same-contract / same-product package uniqueness across lines.
  • Rewrite arrange-qty / hard-check / detail attach to pool-by-line + optional per-package arranged breakdown.
  • Adapt contract form, detail, print/PDF; keep plan stop product as single packageSpec.

Non-Goals:

  • Binding plan products to contractProductId in the UI (resolve pool via package ∈ line set; uniqueness guarantees uniqueness).
  • Changing plan product schema to multi-package.
  • Per-package independent remaining on shared-pool lines.
  • Migrating historical multi-line split-quota contracts into merged shared-pool lines (users keep both patterns).
  • This iteration MAY leave quote form single-select if create-from-quote maps scalar → one-element list; full quote multi-select is optional follow-up.

Decisions

1. Storage: JSON array in package_spec (widen column)

Choice Rationale
Adopted: Store JSON array string in existing package_spec column; widen to varchar(1000) (or TEXT if preferred in migration) Avoid new table for v1; dict values rarely need structured join; one column keeps CRUD surface small
Rejected: comma / 、 join Spec labels may contain commas; brittle
Rejected: new child table crm_contract_product_package Cleaner long-term but heavier for v1 (extra CRUD, sort, delete cascade)
Rejected: duplicate quantity onto N virtual rows at write Would restore independent quotas and contradict shared-pool requirement

Codec (shared backend util + frontend mirror):

  • Write: json.dumps(list[str], ensure_ascii=False) with normalized trimmed unique specs (order preserved).
  • Read: if value parses as JSON array of strings → use it; else treat whole trimmed string as single legacy scalar → [value].
  • Empty / whitespace-only → invalid (line requires ≥1).

API shape:

  • Write/read primary: packageSpecs: string[]
  • Compat: packageSpec = packageSpecs[0] on responses; accept either packageSpecs or legacy packageSpec on write (prefer packageSpecs if both sent).

Sync docs/sql/shanxia2.0.sql + migration script under backend/scripts/.

2. Pool key = contract product line id (internal); resolve via package membership (external)

plan (productId, packageSpec)
  → find line L where L.productId == productId AND packageSpec ∈ L.packageSpecs
  → poolQty = L.quantity
  → arrangedPool = Σ plan qty for all packages in L.packageSpecs (counted plans)
  → available = poolQty - arrangedPool
  → perPackageArranged[pkg] = Σ plan qty for that pkg only (for display)
  • Uniqueness rule makes L unique.
  • Same product, two single-package lines → two pools (old 15 vs 700 case preserved).
  • Do not copy quantity onto each package when expanding for UI.

3. Detail / print presentation

  • Line row: product fields, quantity, line arrangedTotal / archived* aggregates, remaining (line only).
  • Child / nested rows or stacked cells: each packageSpec in the line with its own arranged / archived / unarchived-arranged (no remaining column, or show —).
  • Print: package column joins specs with 、; {{packageSpecs}} = dedupe union across all lines after expanding arrays.

Prefer nested sub-rows in ContractDetailDialog / detail.vue for clarity (aligned with Fresh Mint list patterns; keep action columns on parent only).

4. Frontend contract form

  • Replace single DictSelect with multi-select (same dict crm_product_gg).
  • Client validation: ≥1; within form, same productId cannot reuse a package already chosen on another row.
  • Server re-validates on create/update (source of truth).

5. PL frontend adaptations

  • packageSpecsForProduct / collectContractProductRows: expand packageSpecs[] from contract API.
  • Arrange-qty cache key can stay contractId::productId::packageSpec but response MUST return pool remaining (same for every package on the line) and SHOULD include packageBreakdown + lineQuantity for dialog copy.
  • Save validation: package ∈ union of line sets (unchanged idea); no change to “one package per plan row”.

6. Relationship to 2026-09-10 design

Case Behavior
One package per line Equivalent to old composite-key buckets
Multiple packages one line New shared pool (supersedes “independent bucket” for that line)

Update or annotate docs/superpowers/specs/2026-09-10-plan-product-package-key-design.md as superseded for multi-package lines when implementing.

Risks / Trade-offs

Risk Mitigation
Old clients send only packageSpec scalar Accept scalar; encode as one-element JSON on write
JSON in varchar mis-read by ad-hoc SQL / exports Document codec; list/export helpers always decode via util
Detail UI complexity / height Nested rows only when packageSpecs.length > 1; single package keeps flat row
Hard-check messages less obvious Violation text include all packages in the pool + remaining
Quote still single-select Map to [packageSpec]; optional later multi-select
Column length overflow with many specs Cap max packages per line (e.g. 20) + widened column

Migration Plan

  1. Deploy backend codec that reads legacy scalar and JSON array; write JSON always for new saves.
  2. Widen column; no data backfill required (scalar remains valid until next edit).
  3. Deploy frontend contract + PL together with backend (arrange-qty response shape).
  4. Rollback: keep read-path accepting both encodings; avoid writing JSON if forced to roll back UI first (prefer dual-write window short).

Open Questions

  • Quote form multi-select in same iteration vs follow-up (default: follow-up; create-from-quote maps scalar → list).
  • Exact max packages per line / column width (varchar(1000) vs TEXT) — decide at implementation with sample dict label lengths.