Bill of Materials (BOM) — the recipe for a manufactured item
A BOM reduces to one idea: a parent
Itemplus an ordered list of component lines, each line beingcomponentItemId × quantity × (1 + scrapPercent/100). The header (manufacturing_boms) holds the parent item, version, status, approval state, and rolled-up cost buckets; each line (manufacturing_bom_lines) holds one component, its quantity, scrap allowance, and an optionalcomponentBomIdthat makes the structure recursive (multi-level BOM). Cost is rolled bottom-up: a line's cost is the component's unit cost (or, if the component has its own BOM, that child BOM's recomputedtotalCost) × quantity × the scrap multiplier.
Source: BE manufacturing/bom (bom.scheme.ts, bom.dto.ts, bom.service.ts, bom.repository.ts, bom.resolver.ts, bom.controller.ts, bom.filter.ts, bom.module.ts) + manufacturing/bom/line (line.scheme.ts, line.dto.ts, line.service.ts, line.repository.ts, line.resolver.ts, line.filter.ts, line.module.ts) · Admin src/modules/manufacturing/bom, src/modules/bom (+ src/modules/bom/item) · pages src/pages/manufacturing/bom, src/pages/bom
Related: ./_overview.md (domain map + manufacture-to-stock flow) · component items/costing live in inventory (../inventory/item.md, ../inventory/pricing.md) · production consumes the BOM, see the production doc and ../inventory/stock.md for the backflush ledger write.
1. Purpose & scope
The bom module owns the manufacturing_boms (header) and manufacturing_bom_lines (line) collections and the GraphQL surface that maintains them. A BOM defines what a manufactured item is made of: which components, in what quantities, with what scrap allowance, in what UOM, drawn from which warehouse, and flushed by which method during production.
What this module is responsible for:
- The
BomandBomLineschemas and their enums (BomStatus,BomType,BomApprovalStatus,FlushingMethod). - CRUD over headers and lines (each is a separate resolver/service/repository pair).
- The cost roll-up (
recalculateBomCost) — recursive, bottom-up material costing. - The approval lifecycle (
submitForApproval → approve / reject). - Where-Used — reverse lookup of every BOM that consumes a given item.
- An XLSX export of the BOM list.
What it explicitly does not do:
- It does not move stock. A BOM is a template; the production flow reads the BOM to consume materials (stock OUT) and produce output (stock IN). The BOM module never writes to
stocks. - It does not convert UOM.
uomIdis stored on the line for reference, butrecalculateBomCostmultiplies rawquantity × unitCostwith no UOM conversion (see §9). - It does not enforce a single active BOM per item, nor auto-bump version on edit.
versionis a plain stored number the caller supplies; nothing increments it. - It does not gate BOM consumption on
approvalStatus. The approval lifecycle is tracked but production does not check it before exploding the BOM (see §9).
2. Data model
2.1 Bom (collection manufacturing_boms)
The BOM header. @ApSchema({ collection: "manufacturing_boms", timestamps: true }), soft-deletes via mongoose-delete (deletedAt). Extends BaseSchema (supplies _id, key, ref, documentCode, documentDate, createdBy/At, updatedBy/At, deleted/deletedAt, and the canDelete/canUpdate/canView/canPost flags). companyId and branchId are re-declared on Bom (both set: BaseSchema.toObjectId).
| field | type | required | description |
|---|---|---|---|
bomNumber |
string | yes | Human-facing BOM identifier / name (e.g. BOM-0001). Searchable. Not auto-generated — caller supplies it. |
itemId |
ObjectId | no | The parent / finished item this BOM produces (ref → items). Coerced via toObjectId. |
version |
number | no (default 1) |
Revision number. Plain stored value; nothing auto-increments it. |
status |
BomStatus |
no (default DRAFT) |
Lifecycle: DRAFT / ACTIVE / OBSOLETE. |
effectiveDate |
number | no | Unix ts the BOM becomes effective (toUnixTimestamp setter). Stored, not enforced. |
totalMaterialCost |
number | no (default 0) |
Σ of line costs from the last recalculateBomCost (incl. scrap, recursive children). |
totalLaborCost |
number | no (default 0) |
Labour cost bucket. Caller-supplied — recalculateBomCost does not compute it. |
totalOverheadCost |
number | no (default 0) |
Overhead bucket. Caller-supplied. |
totalCost |
number | no (default 0) |
totalMaterialCost + totalLaborCost + totalOverheadCost (set by recalculateBomCost). |
description |
string | no | Free text / note. Searchable. |
bomType |
BomType |
no (default STANDARD) |
STANDARD or PHANTOM (phantom = pass-through sub-assembly, not stocked). Stored; no special handling in current code. |
approvalStatus |
BomApprovalStatus |
no (default DRAFT) |
Approval lifecycle: DRAFT / PENDING_APPROVAL / APPROVED / REJECTED. |
approvedBy |
ObjectId | no | User who approved (set by approveBom). |
approvedAt |
number | no | Unix ts of approval (toUnixTimestamp setter; approveBom sets Math.floor(Date.now()/1000)). |
rejectionReason |
string | no | Reason captured by rejectBom; cleared on approve. |
lines (virtual, GraphQL only) |
[BomLine] |
— | Child lines. Exposed on the Bom GraphQL type but not auto-populated by the resolver (admin fetches lines via bomLinePage(bomId); see §7). |
// bom.scheme.ts
@ApSchema({ collection: "manufacturing_boms", timestamps: true })
export class Bom extends BaseSchema {
bomNumber: string; // REQUIRED
itemId: Types.ObjectId; // parent / finished item
version: number; // default 1
status: BomStatus; // default DRAFT
effectiveDate: number;
totalMaterialCost: number; // default 0 (set by recalculateBomCost)
totalLaborCost: number; // default 0 (caller-supplied)
totalOverheadCost: number; // default 0 (caller-supplied)
totalCost: number; // default 0 (material + labor + overhead)
description: string;
bomType: BomType; // default STANDARD
approvalStatus: BomApprovalStatus; // default DRAFT
approvedBy: Types.ObjectId;
approvedAt: number;
rejectionReason: string;
companyId: Types.ObjectId;
branchId: Types.ObjectId;
}
BomSchema.plugin(SoftDelete, { deletedAt: true });2.2 BomLine (collection manufacturing_bom_lines)
One component of a BOM. @ApSchema({ collection: "manufacturing_bom_lines", timestamps: true }), soft-deletes via mongoose-delete. Extends BaseSchema.
| field | type | required | description |
|---|---|---|---|
bomId |
ObjectId | yes (logical) | Back-ref to the parent Bom (ref → manufacturing_boms). Required in CreateBomLineInput. |
componentItemId |
ObjectId | yes (logical) | The component item consumed (ref → items). Required in CreateBomLineInput. |
componentBomId |
ObjectId | no | Multi-level link. If the component is itself manufactured, points at its BOM so recalculateBomCost recurses. Optional. |
quantity |
number | no (default 0) |
Quantity of the component per one unit of the parent (in uomId). Required in CreateBomLineInput. |
uomId |
ObjectId | no | UOM the quantity is expressed in (ref → masters, key uom). Reference only — not used in costing. |
scrapPercent |
number | no (default 0) |
Expected scrap/yield-loss %. Inflates required qty: effective qty = quantity × (1 + scrapPercent/100). |
lineCost |
number | no (default 0) |
Computed extended cost of this line (set by recalculateBomCost; see §4.1). |
warehouseId |
ObjectId | no | Source warehouse/branch the component is drawn from during production. |
flushingMethod |
FlushingMethod |
no (default MANUAL) |
How material is back-flushed in production: MANUAL / FORWARD / BACKWARD. Stored; production-side consumption interprets it. |
companyId / branchId |
ObjectId | no | Tenant/branch scoping (re-declared). |
// line.scheme.ts
@ApSchema({ collection: "manufacturing_bom_lines", timestamps: true })
export class BomLine extends BaseSchema {
bomId: Types.ObjectId; // parent BOM
componentItemId: Types.ObjectId; // the component
componentBomId: Types.ObjectId; // optional — recursive multi-level link
quantity: number; // default 0 (per 1 unit of parent)
uomId: Types.ObjectId; // reference UOM (not used in costing)
scrapPercent: number; // default 0
lineCost: number; // default 0 (computed)
warehouseId: Types.ObjectId;
flushingMethod: FlushingMethod; // default MANUAL
companyId: Types.ObjectId;
branchId: Types.ObjectId;
}
BomLineSchema.plugin(SoftDelete, { deletedAt: true });2.3 Enums (verbatim)
// bom.scheme.ts
export enum BomStatus { DRAFT = "DRAFT", ACTIVE = "ACTIVE", OBSOLETE = "OBSOLETE" }
export enum BomType { STANDARD = "STANDARD", PHANTOM = "PHANTOM" }
export enum BomApprovalStatus {
DRAFT = "DRAFT", PENDING_APPROVAL = "PENDING_APPROVAL",
APPROVED = "APPROVED", REJECTED = "REJECTED"
}
// line.scheme.ts
export enum FlushingMethod { MANUAL = "MANUAL", FORWARD = "FORWARD", BACKWARD = "BACKWARD" }All four are GraphQL-registered (registerEnumType in bom.dto.ts / line.dto.ts).
2.4 Relationships & scoping
- References (not embedded):
Bom.itemId → items;BomLine.bomId → manufacturing_boms,BomLine.componentItemId → items,BomLine.componentBomId → manufacturing_boms(recursive),BomLine.uomId → masters,BomLine.warehouseId → branches. - Header↔︎line is referenced, not embedded. Lines are their own collection keyed by
bomId. The GraphQLBom.linesfield exists but the resolver does not populate it — lines are read separately viabomLinePage(bomId). - Lookups: the repositories join via aggregation —
$lookupItem(header →item), and on lines$lookupComponentItem(→componentItem),$lookupComponentBom(→componentBom),$lookupUom(→uom).findById/find/pageall run through these pipelines. - Tenant/branch scoping: both carry
companyId+branchId(../../platform/multi-tenancy.md). Note:bomPageis declared@ApGqlAuthorize({ includeBranchQuery: false })— the branch filter is disabled on the BOM list, so BOMs are listed company-wide rather than branch-scoped (see §9). - Soft delete:
mongoose-delete(deletedAt). Where-Used and cost roll-up both filterdeleted: { $ne: true }so deleted lines drop out automatically.
3. API surface
Two resolvers, both @ApInitGqlAuthorize() at class level (BOM header BomResolver, line BomLineResolver). Mutations carry @AuditMeta({ module: "bom", ... }) (../../platform/audit-trail.md).
3.1 BOM header (bom.resolver.ts)
| Operation | Type | Input | Returns | Auth / Audit |
|---|---|---|---|---|
createBom |
Mutation | bom: CreateBomInput |
Bom |
@ApInitGqlAuthorize · audit CREATE |
updateBom |
Mutation | _id: String, bom: UpdateBomInput |
Bom |
audit UPDATE |
findBom |
Query | _id: ID! |
Bom (with item join) |
— |
bomPage |
Query | page: BomPageInput |
BomPageResult ({ totalRecords, data: [Bom] }) |
@ApGqlAuthorize({ includeBranchQuery: false }) |
bomWhereUsed |
Query | itemId: String |
BomWhereUsedResult ({ data: [BomWhereUsedItem], totalRecords }) |
— |
recalculateBomCost |
Mutation | bomId: String |
Bom |
audit UPDATE |
submitBomForApproval |
Mutation | bomId: String |
Bom |
audit STATUS_CHANGE |
approveBom |
Mutation | bomId: String (+ current user) |
Bom |
audit STATUS_CHANGE |
rejectBom |
Mutation | bomId: String, reason: String |
Bom |
audit STATUS_CHANGE |
deleteBom |
Mutation | _id: String |
Boolean |
audit DELETE |
deleteBoms |
Mutation | _ids: [String] |
Boolean (loops delete) |
audit DELETE |
3.2 BOM line (line/line.resolver.ts)
| Operation | Type | Input | Returns | Auth / Audit |
|---|---|---|---|---|
createBomLine |
Mutation | bomLine: CreateBomLineInput |
BomLine |
audit CREATE |
updateBomLine |
Mutation | _id: String, bomLine: UpdateBomLineInput |
BomLine |
audit UPDATE |
findBomLine |
Query | _id: ID! |
BomLine (with component/uom joins) |
— |
bomLinePage |
Query | page: BomLinePageInput (bomId filter) |
BomLinePageResult |
@ApGqlAuthorize({ includeBranchQuery: false }) |
deleteBomLine |
Mutation | _id: String |
Boolean |
audit DELETE |
deleteBomLines |
Mutation | _ids: [String] |
Boolean |
audit DELETE |
3.3 Inputs (bom.dto.ts / line/line.dto.ts)
input CreateBomInput { # CommonBomInput
bomNumber: String!
itemId: String
version: Float
status: BomStatus! # note: required in input, default DRAFT in schema
effectiveDate: Float
totalMaterialCost: Float
totalLaborCost: Float
totalOverheadCost: Float
totalCost: Float
description: String
bomType: BomType
approvalStatus: BomApprovalStatus
rejectionReason: String
}
input UpdateBomInput { ... } # PartialType(CommonBomInput) — all optional
input BomPageInput {
skip: Float! take: Float!
keyword: String # regex over bomNumber + description
sortBy: String sortOrder: SortOrder
status: BomStatus
approvalStatus: BomApprovalStatus
}
input CreateBomLineInput { # CommonBomLineInput
bomId: String!
componentItemId: String!
componentBomId: String
quantity: Float!
uomId: String
scrapPercent: Float
lineCost: Float
warehouseId: String
flushingMethod: FlushingMethod
}
input BomLinePageInput { skip: Float! take: Float! keyword: String sortBy/sortOrder bomId: String }BomWhereUsedItem shape: { bomId, bomNumber, parentItemId, parentItemName, version, status, quantityUsed }.
3.4 REST (bom.controller.ts)
@Controller("api/bom"), @ApiAuthorize():
GET /api/bom/download?downloadType=XLSX— streams an XLSX of the BOM list. Per row resolves the parent item name and aggregates its lines: columnsBOM Name,Product,Version,Total Items(line count),Total Quantity(Σ line.quantity),Total Cost(helper.formatAmt(bom.totalCost)),Status,Note.
4. Business rules & calculations
4.1 Cost roll-up — recalculateBomCost(bomId) (recursive, bottom-up)
The single non-trivial calculation in the module. For each line it computes an extended lineCost, sums them into totalMaterialCost, then sets totalCost = material + labor + overhead.
// bom.service.ts (verbatim logic)
public async recalculateBomCost(bomId: string): Promise<Bom> {
const bom = await this.bomRepo.findById(bomId); // throws if missing
const lines = await this.bomLineSvc.repo.find({ bomId: bom._id.toString() });
let totalMaterial = 0;
for (const line of lines) {
const item = await this.itemSvc.repo.findById(line.componentItemId?.toString());
let lineCost = 0;
if (line.componentBomId) {
// recurse into the child BOM and use ITS rolled-up totalCost
const childBom = await this.recalculateBomCost(line.componentBomId.toString());
lineCost = (childBom.totalCost || 0) * (line.quantity || 0);
} else {
const unitCost = item?.costPrice || item?.cost || 0; // component unit cost
lineCost = unitCost * (line.quantity || 0);
}
const scrapMultiplier = 1 + (line.scrapPercent || 0) / 100; // scrap inflates cost
lineCost *= scrapMultiplier;
totalMaterial += lineCost;
await this.bomLineSvc.repo.update(line._id, { lineCost }); // persist per-line cost
}
return this.bomRepo.update(bomId, {
totalMaterialCost: totalMaterial,
totalCost: totalMaterial + (bom.totalLaborCost || 0) + (bom.totalOverheadCost || 0),
});
}The formulas, stated plainly:
# leaf component line (no child BOM):
lineCost = (item.costPrice ?? item.cost ?? 0) × quantity × (1 + scrapPercent/100)
# sub-assembly line (has componentBomId):
childTotal = recalculateBomCost(componentBomId).totalCost # recurse first
lineCost = childTotal × quantity × (1 + scrapPercent/100)
# header roll-up:
totalMaterialCost = Σ lineCost
totalCost = totalMaterialCost + totalLaborCost + totalOverheadCost
Key properties for a faithful rebuild:
- Bottom-up, recursive. Sub-assemblies are costed first (their own
recalculateBomCostruns as a side effect, persisting theirtotalCost), then folded into the parent. A deep tree is fully revalued in one call. - Component unit cost =
item.costPrice || item.cost || 0. Reads the inventory item's cost (../inventory/pricing.md); no UOM conversion (see §9). - Scrap inflates cost multiplicatively per line.
- Labor/overhead are not computed here — they are caller-supplied buckets on the header that the roll-up merely adds in. Routing/work-center costs are not automatically pulled in.
- No cycle guard. A BOM that (transitively) references itself via
componentBomIdwould recurse infinitely (see §9).
BomCostResult/bomCostReport(manufacturing reports module) is a different number: it aggregates planned material/labor/overhead from production orders (plannedMaterialCostetc.), grouped by item — not the BOM roll-up above. See./_overview.md§reports. The authoritative per-BOM cost isBom.totalCostset byrecalculateBomCost.
4.2 Where-Used — whereUsed(itemId)
Reverse BOM lookup: "which BOMs consume this item?" Aggregates from manufacturing_boms, $lookups its lines, matches lines.componentItemId == itemId (and lines.deleted != true), joins the parent item, and projects { bomId, bomNumber, parentItemId, parentItemName, version, status, quantityUsed }. Drives the admin "Where-Used" tab and informs impact analysis before editing/obsoleting a component.
4.3 Approval state machine
approvalStatus transitions, enforced in the service (each throws on an illegal source state):
submitBomForApproval approveBom
DRAFT ─────────────────────────▶ PENDING_APPROVAL ──────────▶ APPROVED
▲ │ rejectBom
│ (re-submit) ▼
REJECTED ◀─────────────────────── REJECTED
└── submitBomForApproval ──▶ PENDING_APPROVAL
submitForApproval— allowed only fromDRAFTorREJECTED; setsPENDING_APPROVAL. Otherwise throws "Only DRAFT or REJECTED BOMs can be submitted for approval".approveBom(bomId, userId)— allowed only fromPENDING_APPROVAL; setsAPPROVED, stampsapprovedBy = userId,approvedAt = now, clearsrejectionReason. Else throws "Only PENDING_APPROVAL BOMs can be approved".rejectBom(bomId, reason)— allowed only fromPENDING_APPROVAL; setsREJECTED+rejectionReason. Else throws "Only PENDING_APPROVAL BOMs can be rejected".
This is the BOM's own lightweight approval flow — separate from the platform workflow/approval engine. BomStatus (DRAFT/ACTIVE/OBSOLETE) is an independent lifecycle field; the two are not linked in code.
4.4 Validation & side effects
- Validation is GraphQL-input-level only (
bomNumberrequired on create;bomId,componentItemId,quantityrequired onCreateBomLineInput). Noclass-validatordecorators or service-level invariants beyond the approval-state guards. - Side effects: mutations write audit-trail entries (
@AuditMeta).recalculateBomCostwriteslineCostto every line andtotalMaterialCost/totalCostto the header. No GL legs, no stock rows — the BOM never posts to the ledger or accounts. - Transactionality: none explicit.
recalculateBomCostperforms sequential reads/updates without a Mongo session/transaction wrapper (BomService.setSessionis a no-op).
5. Permissions
- Both resolvers are class-level
@ApInitGqlAuthorize()(JWT-authenticated; see../../platform/auth.md). bomPageandbomLinePageadditionally use@ApGqlAuthorize({ includeBranchQuery: false })— RBAC access-group check (../../platform/permissions-access.md) but with branch filtering turned off, so the list is company-wide.- Domain-level subscription/feature gating (the manufacturing feature flag + CASL permission module) is applied at the manufacturing layer — see
./_overview.md§access control. The BOM resolvers do not declare per-action CASL strings inline. - Every mutation is audited (
module: "bom", collectionsboms/bom_lines). - REST download:
@ApiAuthorize().
6. Flows
6.1 Create a BOM with components (happy path)
- Admin opens the BOM create form → submits header →
createBom(CreateBomInput)→BomService.createpersistsBom(statusDRAFT, approvalStatusDRAFT,createdBy = user). - For each component row →
createBomLine(CreateBomLineInput)(bomId,componentItemId,quantity,scrapPercent, optionalcomponentBomIdfor a sub-assembly) →BomLineService.create. - Admin (or a follow-up call) triggers
recalculateBomCost(bomId)→ recursive roll-up writeslineCostper line andtotalMaterialCost/totalCoston the header (§4.1).
6.2 Approval lifecycle
submitBomForApproval(bomId)— fromDRAFT/REJECTED→PENDING_APPROVAL.- Approver:
approveBom(bomId)→APPROVED(+approvedBy/At), orrejectBom(bomId, reason)→REJECTED(+rejectionReason). Rejected BOMs can be re-submitted. - Unhappy paths: calling any transition from a disallowed source state throws the corresponding "Only … can be …" error (§4.3).
6.3 Multi-level (recursive) BOM
- A line whose component is itself manufactured sets
componentBomIdto that component's BOM. recalculateBomCost(parent)recurses into the child BOM first, revalues it, and uses itstotalCostas the line's unit cost basis (§4.1). Arbitrary depth is supported (no explicit limit).- Production explosion / MRP traverse the same
componentBomIdlinks (see./_overview.md).
6.4 Where-Used (impact analysis)
- Before obsoleting/editing a component item, admin opens its Where-Used tab →
bomWhereUsed(itemId). - Returns every parent BOM consuming it, with
quantityUsed, so the user sees the blast radius.
6.5 Edit / delete
updateBom/updateBomLinepatch fields (audit UPDATE). After a line edit, re-runrecalculateBomCostto refresh costs (not automatic).deleteBom/deleteBomLine(and the…sbatch variants) soft-delete; deleted lines drop out of Where-Used and cost roll-up automatically (deleted != truefilters).
7. Admin UI
There are two admin BOM surfaces (BE↔︎admin is not 1:1):
A. src/modules/manufacturing/bom (the manufacturing-domain BOM screen) — pages under src/pages/manufacturing/bom:
page.tsx— list (components/table.tsx),detail.tsx— header + lines,components/create.tsx— create/edit form,components/select.tsx— BOM picker (used by production/routing).context.tsx→useBomState()exposesbomPage,findBom,createBom,updateBom,deleteBom, plus the cost/approval mutations (recalculateBomCost,submitBomForApproval,approveBom,rejectBom) andbomWhereUsed.gql/query.ts+gql/fragment.tshold the ops.
B. src/modules/bom (+ src/modules/bom/item) — a parallel BOM module mapped to pages under src/pages/bom:
page.tsx/detail.tsx/components/{create,table}.tsxfor the header;bom/item/(its ownpage.tsx,context.tsx,gql/,model.ts) manages the lines as a child sub-module (the admin "BOM item" = BEBomLine).context.tsxdrives header CRUD;bom/item/context.tsxdrives line CRUD viabomLinePage(bomId),createBomLine,updateBomLine,deleteBomLine.
Notable UX: lines are loaded separately (bomLinePage keyed by bomId) since the GraphQL Bom.lines field is not server-populated; a "Where-Used" view (bomWhereUsed); a "Recalculate cost" action wired to recalculateBomCost; approve/reject actions wired to the approval mutations; XLSX export via /api/bom/download. Component selection uses the item select; sub-assembly selection sets componentBomId.
8. Dependencies & integrations
- Imports:
ItemModule(component unit cost viaitem.costPrice/cost, parent-item resolve),BomLineModule(header service/controller/cost roll-up call into the line service),AuthModule,UserModule. All wired withforwardRef(circular deps across manufacturing). - Exported & consumed by:
- Production — reads the BOM + lines to generate production materials and back-flush consumption (stock OUT) on a production order (see
./_overview.mdand../inventory/stock.md§4.4 manufacturing backflush). - MRP — explodes the BOM (
exploseBom) to compute component net requirements (README MRP flow;mrp/mrp.service.ts). - Item —
Item.defaultBomIdpoints at the item's standard BOM;Item.isManufactured/manufacturingTypeflag which items can have BOMs (../inventory/item.md).
- Production — reads the BOM + lines to generate production materials and back-flush consumption (stock OUT) on a production order (see
- No cron/queue/external service. The XLSX export is synchronous (
exceljsviaApBaseController).
9. Gotchas & project-specific rules
recalculateBomCostignores UOM. It multiplies rawquantity × unitCostwith nouomId-based conversion. If a line'squantityis in a non-base UOM, the cost is wrong. Store quantities in the component's base UOM (or extend the roll-up to convert).- No recursion/cycle guard on
componentBomId. A BOM that references itself (directly or transitively) will recurse until the stack blows. There is no visited-set protection. - Labor & overhead are manual.
recalculateBomCostonly computes material cost;totalLaborCostandtotalOverheadCostare whatever the caller stored. Routing/work-center costs are not rolled into the BOM automatically. approvalStatusis tracked but not enforced. Nothing prevents producing from aDRAFT/REJECTEDBOM — production does not gate onapprovalStatus(orBomStatus). Two independent lifecycle fields (status= DRAFT/ACTIVE/OBSOLETE,approvalStatus= the approval flow) coexist and are not linked.versionnever auto-increments. Editing a BOM mutates it in place; there is no copy-on-write revision. To keep history, create a new BOM with a higherversionmanually (and mark the old oneOBSOLETE).bomNumberis not generated. Unlike orderrefs, the caller supplies it. No uniqueness constraint in code.bomPage/bomLinePagedisable the branch filter (includeBranchQuery: false) — BOMs list company-wide even though rows carrybranchId.Bom.linesGraphQL field is not populated by the resolver. Fetch lines viabomLinePage(bomId).bomType: PHANTOMis stored but inert. No phantom-specific pass-through logic exists in the BOM module; any phantom handling would be in production explosion.bomCostReport≠ BOM roll-up. The reports query sums production-order planned costs by item, not the BOM'stotalCost. Don't conflate the two cost numbers (§4.1).- README drift.
manufacturing/README.mdreferences abom/line/{…}layout and collections namedboms/bom_lines; the actual collections aremanufacturing_boms/manufacturing_bom_lines. Trust the schemas.