Files
bible/rules/patterns/bom-decomposition/contract.md
2026-03-07 15:08:45 +03:00

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/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:

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:

  • 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:

{
  "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_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:

[
  { "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 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

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
}