# Design ## Context See `proposal.md` for motivation. Current constraints that shape the approach: - Contract uniqueness is application-level (`validateContractProductPackageSpecs` / `_validate_contract_product_package_specs`), not a DB unique index. - `pl_plan_contract` already stores N contracts per stop/side; `assert_at_most_one_contract` and frontend `validateSingleContractPerStop` cap it at 1. - Stop products (`pl_plan_load_product`, `pl_plan_delivery_product`) have `product_id` + `package_spec` + `plan_qty` / inbound|outbound actuals, **no** `contract_product_id`. - Occupancy in `pl_contract_plan_qty.py` matches by product + package membership so uniqueness can uniquely identify a line. Dirty duplicates are summed. - Occupancy buckets already use plan qty for unarchived-arranged and archive actuals for archived (`QTY_MODE_PLAN` / `QTY_MODE_ARCHIVE_ACTUAL`). - UI mock: `docs/superpowers/demos/2026-10-05-contract-plan-line-qty-demo.html`. ## Goals / Non-Goals **Goals:** - Persist stop-product → contract-line binding and key all occupancy on that id. - Lift the one-contract-per-stop cap with same-customer validation. - Keep existing plan-qty vs archive-actual field split; add over-delivery confirm only on formal archive. - Leave driver/site UX product-centric. **Non-Goals:** - Plan-time single physical row with archive-only allocation (方案乙). - Auto-splitting a weighbridge total by ratio. - Replacing `crm_contract_product_price_tier` (date-based price on one line remains). - Changing driver/site to select contracts. - Migrating historical plans onto line ids (fallback match only). ## Decisions ### 1. Occupancy key = `contract_product_id` | Choice | Rationale | |--------|-----------| | **Adopted**: Bind and aggregate by contract product line id | Duplicate product+package is otherwise unresolvable | | Rejected: Keep `(productId, packageSpec)` uniqueness | Contradicts price-split lines | | Rejected: Sum duplicates (current dirty-data path) | Cannot split 10 vs 20 on two lines | Arrange-qty API MUST accept `contractProductId` (keep product+package as fallback for old clients / history). ### 2. Schema: columns on stop product tables Add nullable `contract_id`, `contract_product_id` to `pl_plan_load_product` and `pl_plan_delivery_product`. Prefer also storing snapshot `contract_no` only if list/print needs it without join; otherwise join `pl_plan_contract` + CRM. Do **not** encode allocation as a child table of one physical product row: planner already splits rows. `pl_plan_product` remains the plan-level product rollup (same product may still be one rollup row for vehicle/certs). Stop-level rows carry the binding. If current code requires 1:1 `plan_product_id` per stop row of the same product, keep sharing one `pl_plan_product` for identical product+package **or** clone plan_product rows per binding—prefer **share one plan_product** when product+package match so driver/certs stay one SKU, and put binding only on stop products. ### 3. Multi-contract: remove at-most-one, keep same customer Remove `assert_at_most_one_contract` for load/delivery (and supplier/demander only if those UIs must also multi-select; default this change: **load + delivery only**; supplier/demander stay 1 unless the same form already reuses the helper—if the helper is shared, split load/delivery from supplier/demander or add a `max_count` argument). Product whitelist = union of selected contracts' lines. Each stop product's `contract_id` MUST be one of the stop's linked contracts. ### 4. Picker = contract lines Frontend add-product opens lines from selected contracts: contract no, product name, package (if multi-package line, pick one package for the stop row), price, remaining. Do not open a “split dialog after picking SKU” as the primary path (supersedes `2026-09-29` demo). Optional later: picking a SKU that hits multiple lines could pre-open those lines; v1 is explicit line pick. ### 5. Validation split | Gate | Rule | |------|------| | Contract save | Duplicate package allowed | | Plan save | Package ∈ bound line; product matches; contract on stop; **no** remaining hard-block | | Arrange | Remaining hard-block per `contractProductId` | | Archive draft | Warn over remaining; persist | | Archive submit | Actuals sum = vehicle/IO total when present; over remaining → confirm then post to that line | Confirmation is a dedicated archive request flag or two-step API (`confirmOverage: true`) so a plain retry cannot skip the dialog. Negative remaining is stored as archived > contract qty; UI shows 超交. Subsequent arrange against that line treats remaining ≤ 0 as hard-block. ### 6. Driver / site aggregation Existing driver/site APIs that list stop products by product+package SHOULD continue to show one card with summed qty when multiple bound rows share product+package. Do not add contract fields to those payloads unless already present; if present, omit from UI. ## Risks / Trade-offs | Risk | Mitigation | |------|------------| | Historical plans lack line id | Fallback match; new writes require id when contracts selected | | Sharing one `pl_plan_product` vs cloning | Certs/hazard stay on SKU; occupancy never uses plan_product id | | User confirms huge over-delivery | Dialog lists excess; audit log of confirm | | `crm-contract-multi-package-shared-qty` uniqueness tests | Update those tests in the same implementation wave | | Arrange helper used for supplier contracts | Parameterize max-one vs multi | ## Migration Plan 1. Add nullable columns; deploy backend that writes ids when provided and occupancy prefers id. 2. Deploy frontend plan/contract/archive together. 3. Rollback: occupancy can still sum by package if ids absent; do not re-enable uniqueness until UI rolled back. ## Open Questions - Whether load and delivery must always have the same set of bound lines after “sync from load” (keep current optional sync button; not required equal).