8.6 KiB
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:
{
"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/LOTquantity_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_codemay bevendor_partnumbercomponent_refmay belot_name,lot_code, or another stable project identifiercomponent_mappingsmay belot_mappings
Quantity Semantics
The total downstream quantity is always:
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:
{
"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:
GETreturns BOM rows withcomponent_mappings[]or the project-specific equivalentPUT/POSTaccepts 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:
{
"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:
{
"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_itemis 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_itemto1
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 <= 0with a validation error - merge duplicate
component_refvalues within one BOM row by summingquantity_per_item - preserve first-seen order when merging duplicates
Example input:
[
{ "component_ref": "LOT_A", "quantity_per_item": 1 },
{ "component_ref": " LOT_A ", "quantity_per_item": 2 },
{ "component_ref": "", "quantity_per_item": 5 }
]
Normalized result:
[
{ "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 reinterpret0or 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
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:
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)
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)
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
}