diff --git a/README.md b/README.md index a5a94e9..83a9c82 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ rules/patterns/ — shared engineering rule contracts go-background-tasks/ — Task Manager pattern, polling go-code-style/ — layering, error wrapping, startup sequence go-project-bible/ — how to write and maintain a project bible + bom-decomposition/ — one BOM row to many component/LOT mappings import-export/ — CSV Excel-compatible format, streaming export table-management/ — toolbar, filtering, pagination modal-workflows/ — state machine, htmx pattern, confirmation diff --git a/rules/patterns/bom-decomposition/contract.md b/rules/patterns/bom-decomposition/contract.md new file mode 100644 index 0000000..4dee0d9 --- /dev/null +++ b/rules/patterns/bom-decomposition/contract.md @@ -0,0 +1,302 @@ +# Contract: BOM Decomposition Mapping + +Version: 1.0 + +## Purpose + +Defines the canonical way to represent a BOM row that decomposes one external/vendor item into +multiple internal component or LOT rows. + +This is not an alternate-choice mapping. +All mappings in the row apply simultaneously. + +Use this contract when: +- one vendor part number expands into multiple LOTs +- one bundle SKU expands into multiple internal components +- one external line item contributes quantities to multiple downstream rows + +## Canonical Data Model + +One BOM row has one item quantity and zero or more mapping entries: + +```json +{ + "sort_order": 10, + "item_code": "SYS-821GE-TNHR", + "quantity": 3, + "description": "Vendor bundle", + "unit_price": 12000.00, + "total_price": 36000.00, + "component_mappings": [ + { "component_ref": "CHASSIS_X13_8GPU", "quantity_per_item": 1 }, + { "component_ref": "PS_3000W_Titanium", "quantity_per_item": 2 }, + { "component_ref": "RAILKIT_X13", "quantity_per_item": 1 } + ] +} +``` + +Rules: +- `component_mappings[]` is the only canonical persisted decomposition format. +- Each mapping entry contains: + - `component_ref` — stable identifier of the downstream component/LOT + - `quantity_per_item` — how many units of that component are produced by one BOM row unit +- Derived or UI-only fields may exist at runtime, but they are not the source of truth. + +Project-specific names are allowed if the semantics stay identical: +- `item_code` may be `vendor_partnumber` +- `component_ref` may be `lot_name`, `lot_code`, or another stable project identifier +- `component_mappings` may be `lot_mappings` + +## Quantity Semantics + +The total downstream quantity is always: + +```text +downstream_total_qty = row.quantity * mapping.quantity_per_item +``` + +Example: +- BOM row quantity = `3` +- mapping A quantity per item = `1` +- mapping B quantity per item = `2` + +Result: +- component A total = `3` +- component B total = `6` + +This multiplication rule is mandatory for estimate/cart/build expansion. + +## Persistence Contract + +The source of truth is the persisted BOM row JSON payload. + +If the project stores BOM rows: +- in a SQL JSON column, the JSON payload is the source of truth +- in a text column containing JSON, that JSON payload is the source of truth +- in an API document later persisted as JSON, the row payload shape must remain unchanged + +Example persisted payload: + +```json +{ + "vendor_spec": [ + { + "sort_order": 10, + "vendor_partnumber": "ABC-123", + "quantity": 2, + "description": "Bundle", + "lot_mappings": [ + { "lot_name": "LOT_CPU", "quantity_per_pn": 1 }, + { "lot_name": "LOT_RAIL", "quantity_per_pn": 1 } + ] + } + ] +} +``` + +Persistence rules: +- the decomposition must be stored inside each BOM row +- all mapping entries for that row must live in one array field +- no secondary storage format may act as a competing source of truth + +## API Contract + +API read and write payloads must expose the same decomposition shape that is persisted. + +Rules: +- `GET` returns BOM rows with `component_mappings[]` or the project-specific equivalent +- `PUT` / `POST` accepts the same shape +- rebuild/apply/cart expansion must read only from the persisted mapping array +- if the mapping array is empty, the row contributes nothing downstream +- row order is defined by `sort_order` +- mapping entry order may be preserved for UX, but business logic must not depend on it + +Correct: + +```json +{ + "vendor_spec": [ + { + "sort_order": 10, + "vendor_partnumber": "ABC-123", + "quantity": 2, + "lot_mappings": [ + { "lot_name": "LOT_CPU", "quantity_per_pn": 1 }, + { "lot_name": "LOT_RAIL", "quantity_per_pn": 1 } + ] + } + ] +} +``` + +Wrong: + +```json +{ + "vendor_spec": [ + { + "sort_order": 10, + "vendor_partnumber": "ABC-123", + "primary_lot": "LOT_CPU", + "secondary_lots": ["LOT_RAIL"] + } + ] +} +``` + +## UI Invariants + +The UI may render the mapping list in any layout, but it must preserve the same semantics. + +Rules: +- the first visible mapping row is not special; it is only the first entry in the array +- additional rows may be added via `+`, modal, inline insert, or another UI affordance +- every mapping row is equally editable and removable +- `quantity_per_item` is edited per mapping row, not once for the whole row +- blank mapping rows may exist temporarily in draft UI state, but they must not be persisted +- new UI rows should default `quantity_per_item` to `1` + +## Normalization and Validation + +Two stages are allowed: +- draft UI normalization for convenience +- server-side persistence validation for correctness + +Canonical rules before persistence: +- trim `component_ref` +- drop rows with empty `component_ref` +- reject `quantity_per_item <= 0` with a validation error +- merge duplicate `component_ref` values within one BOM row by summing `quantity_per_item` +- preserve first-seen order when merging duplicates + +Example input: + +```json +[ + { "component_ref": "LOT_A", "quantity_per_item": 1 }, + { "component_ref": " LOT_A ", "quantity_per_item": 2 }, + { "component_ref": "", "quantity_per_item": 5 } +] +``` + +Normalized result: + +```json +[ + { "component_ref": "LOT_A", "quantity_per_item": 3 } +] +``` + +Why validation instead of silent repair: +- API contracts between applications must fail loudly on invalid quantities +- UI may prefill `1`, but the server must not silently reinterpret `0` or negative values + +## Forbidden Patterns + +Do not introduce incompatible storage or logic variants such as: +- `primary_lot`, `secondary_lots`, `main_component`, `bundle_lots` +- one field for the component and a separate field for its quantity outside the mapping array +- special-case logic where the first mapping row is "main" and later rows are optional add-ons +- computing downstream rows from temporary UI fields instead of the persisted mapping array +- storing the same decomposition in multiple shapes at once + +## Reference Go Types + +```go +type BOMItem struct { + SortOrder int `json:"sort_order"` + ItemCode string `json:"item_code"` + Quantity int `json:"quantity"` + Description string `json:"description,omitempty"` + UnitPrice *float64 `json:"unit_price,omitempty"` + TotalPrice *float64 `json:"total_price,omitempty"` + ComponentMappings []ComponentMapping `json:"component_mappings,omitempty"` +} + +type ComponentMapping struct { + ComponentRef string `json:"component_ref"` + QuantityPerItem int `json:"quantity_per_item"` +} +``` + +Project-specific aliases are acceptable if they preserve identical semantics: + +```go +type VendorSpecItem struct { + SortOrder int `json:"sort_order"` + VendorPartnumber string `json:"vendor_partnumber"` + Quantity int `json:"quantity"` + Description string `json:"description,omitempty"` + UnitPrice *float64 `json:"unit_price,omitempty"` + TotalPrice *float64 `json:"total_price,omitempty"` + LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"` +} + +type VendorSpecLotMapping struct { + LotName string `json:"lot_name"` + QuantityPerPN int `json:"quantity_per_pn"` +} +``` + +## Reference Normalization (Go) + +```go +func NormalizeComponentMappings(in []ComponentMapping) ([]ComponentMapping, error) { + if len(in) == 0 { + return nil, nil + } + + merged := map[string]int{} + order := make([]string, 0, len(in)) + + for _, m := range in { + ref := strings.TrimSpace(m.ComponentRef) + if ref == "" { + continue + } + if m.QuantityPerItem <= 0 { + return nil, fmt.Errorf("component %q has invalid quantity_per_item %d", ref, m.QuantityPerItem) + } + if _, exists := merged[ref]; !exists { + order = append(order, ref) + } + merged[ref] += m.QuantityPerItem + } + + out := make([]ComponentMapping, 0, len(order)) + for _, ref := range order { + out = append(out, ComponentMapping{ + ComponentRef: ref, + QuantityPerItem: merged[ref], + }) + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} +``` + +## Reference Expansion (Go) + +```go +type CartItem struct { + ComponentRef string + Quantity int +} + +func ExpandBOMRow(row BOMItem) []CartItem { + result := make([]CartItem, 0, len(row.ComponentMappings)) + for _, m := range row.ComponentMappings { + qty := row.Quantity * m.QuantityPerItem + if qty <= 0 { + continue + } + result = append(result, CartItem{ + ComponentRef: m.ComponentRef, + Quantity: qty, + }) + } + return result +} +```