120 lines
6.5 KiB
Markdown
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.
|