design.md 5.8 KB

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