6.5 KiB
6.5 KiB
Design
Context
See proposal.md for motivation. Current state:
crm_contract_product.package_specisvarchar(200)scalar; UIDictSelectsingle-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.mdassumes 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
contractProductIdin 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 eitherpackageSpecsor legacypackageSpecon write (preferpackageSpecsif 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
Lunique. - Same product, two single-package lines → two pools (old 15 vs 700 case preserved).
- Do not copy
quantityonto each package when expanding for UI.
3. Detail / print presentation
- Line row: product fields,
quantity, linearrangedTotal/archived*aggregates, remaining (line only). - Child / nested rows or stacked cells: each
packageSpecin 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
DictSelectwith multi-select (same dictcrm_product_gg). - Client validation: ≥1; within form, same
productIdcannot reuse a package already chosen on another row. - Server re-validates on create/update (source of truth).
5. PL frontend adaptations
packageSpecsForProduct/collectContractProductRows: expandpackageSpecs[]from contract API.- Arrange-qty cache key can stay
contractId::productId::packageSpecbut response MUST return pool remaining (same for every package on the line) and SHOULD includepackageBreakdown+lineQuantityfor 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
- Deploy backend codec that reads legacy scalar and JSON array; write JSON always for new saves.
- Widen column; no data backfill required (scalar remains valid until next edit).
- Deploy frontend contract + PL together with backend (arrange-qty response shape).
- 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)vsTEXT) — decide at implementation with sample dict label lengths.