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

120 lines
6.5 KiB
Markdown

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