# 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 } ```