See proposal.md for motivation. Current state:
crm_contract_product.package_spec is varchar(200) scalar; UI DictSelect single-select.pl_contract_plan_qty.py) keys by (productId, packageSpec); one contract line ↔ one package ↔ one quantity.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:
packageSpec.Non-Goals:
contractProductId in the UI (resolve pool via package ∈ line set; uniqueness guarantees uniqueness).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):
json.dumps(list[str], ensure_ascii=False) with normalized trimmed unique specs (order preserved).[value].API shape:
packageSpecs: string[]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/.
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)
L unique.quantity onto each package when expanding for UI.quantity, line arrangedTotal / archived* aggregates, remaining (line only).packageSpec in the line with its own arranged / archived / unarchived-arranged (no remaining column, or show —).、; {{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).
DictSelect with multi-select (same dict crm_product_gg).productId cannot reuse a package already chosen on another row.packageSpecsForProduct / collectContractProductRows: expand packageSpecs[] from contract API.contractId::productId::packageSpec but response MUST return pool remaining (same for every package on the line) and SHOULD include packageBreakdown + lineQuantity for dialog copy.| 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.
| 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 |
varchar(1000) vs TEXT) — decide at implementation with sample dict label lengths.