# 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) ```text 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.