Bill of Materials (BOM) — the recipe for a manufactured item

A BOM reduces to one idea: a parent Item plus an ordered list of component lines, each line being componentItemId × 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 optional componentBomId that 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 recomputed totalCost) × 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 Bom and BomLine schemas 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. uomId is stored on the line for reference, but recalculateBomCost multiplies raw quantity × unitCost with no UOM conversion (see §9).
  • It does not enforce a single active BOM per item, nor auto-bump version on edit. version is 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-suppliedrecalculateBomCost 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 GraphQL Bom.lines field exists but the resolver does not populate it — lines are read separately via bomLinePage(bomId).
  • Lookups: the repositories join via aggregation — $lookupItem (header → item), and on lines $lookupComponentItem (→ componentItem), $lookupComponentBom (→ componentBom), $lookupUom (→ uom). findById/find/page all run through these pipelines.
  • Tenant/branch scoping: both carry companyId + branchId (../../platform/multi-tenancy.md). Note: bomPage is 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 filter deleted: { $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: columns BOM 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 recalculateBomCost runs as a side effect, persisting their totalCost), 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 componentBomId would recurse infinitely (see §9).

BomCostResult / bomCostReport (manufacturing reports module) is a different number: it aggregates planned material/labor/overhead from production orders (plannedMaterialCost etc.), grouped by item — not the BOM roll-up above. See ./_overview.md §reports. The authoritative per-BOM cost is Bom.totalCost set by recalculateBomCost.

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 from DRAFT or REJECTED; sets PENDING_APPROVAL. Otherwise throws "Only DRAFT or REJECTED BOMs can be submitted for approval".
  • approveBom(bomId, userId) — allowed only from PENDING_APPROVAL; sets APPROVED, stamps approvedBy = userId, approvedAt = now, clears rejectionReason. Else throws "Only PENDING_APPROVAL BOMs can be approved".
  • rejectBom(bomId, reason) — allowed only from PENDING_APPROVAL; sets REJECTED + 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 (bomNumber required on create; bomId, componentItemId, quantity required on CreateBomLineInput). No class-validator decorators or service-level invariants beyond the approval-state guards.
  • Side effects: mutations write audit-trail entries (@AuditMeta). recalculateBomCost writes lineCost to every line and totalMaterialCost/totalCost to the header. No GL legs, no stock rows — the BOM never posts to the ledger or accounts.
  • Transactionality: none explicit. recalculateBomCost performs sequential reads/updates without a Mongo session/transaction wrapper (BomService.setSession is a no-op).

5. Permissions

  • Both resolvers are class-level @ApInitGqlAuthorize() (JWT-authenticated; see ../../platform/auth.md).
  • bomPage and bomLinePage additionally 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", collections boms / bom_lines).
  • REST download: @ApiAuthorize().

6. Flows

6.1 Create a BOM with components (happy path)

  1. Admin opens the BOM create form → submits header → createBom(CreateBomInput)BomService.create persists Bom (status DRAFT, approvalStatus DRAFT, createdBy = user).
  2. For each component row → createBomLine(CreateBomLineInput) (bomId, componentItemId, quantity, scrapPercent, optional componentBomId for a sub-assembly) → BomLineService.create.
  3. Admin (or a follow-up call) triggers recalculateBomCost(bomId) → recursive roll-up writes lineCost per line and totalMaterialCost/totalCost on the header (§4.1).

6.2 Approval lifecycle

  1. submitBomForApproval(bomId) — from DRAFT/REJECTEDPENDING_APPROVAL.
  2. Approver: approveBom(bomId)APPROVED (+ approvedBy/At), or rejectBom(bomId, reason)REJECTED (+ rejectionReason). Rejected BOMs can be re-submitted.
  3. 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

  1. A line whose component is itself manufactured sets componentBomId to that component's BOM.
  2. recalculateBomCost(parent) recurses into the child BOM first, revalues it, and uses its totalCost as the line's unit cost basis (§4.1). Arbitrary depth is supported (no explicit limit).
  3. Production explosion / MRP traverse the same componentBomId links (see ./_overview.md).

6.4 Where-Used (impact analysis)

  1. Before obsoleting/editing a component item, admin opens its Where-Used tab → bomWhereUsed(itemId).
  2. Returns every parent BOM consuming it, with quantityUsed, so the user sees the blast radius.

6.5 Edit / delete

  • updateBom / updateBomLine patch fields (audit UPDATE). After a line edit, re-run recalculateBomCost to refresh costs (not automatic).
  • deleteBom / deleteBomLine (and the …s batch variants) soft-delete; deleted lines drop out of Where-Used and cost roll-up automatically (deleted != true filters).

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.tsxuseBomState() exposes bomPage, findBom, createBom, updateBom, deleteBom, plus the cost/approval mutations (recalculateBomCost, submitBomForApproval, approveBom, rejectBom) and bomWhereUsed. gql/query.ts + gql/fragment.ts hold 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}.tsx for the header; bom/item/ (its own page.tsx, context.tsx, gql/, model.ts) manages the lines as a child sub-module (the admin "BOM item" = BE BomLine).
  • context.tsx drives header CRUD; bom/item/context.tsx drives line CRUD via bomLinePage(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 via item.costPrice/cost, parent-item resolve), BomLineModule (header service/controller/cost roll-up call into the line service), AuthModule, UserModule. All wired with forwardRef (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.md and ../inventory/stock.md §4.4 manufacturing backflush).
    • MRP — explodes the BOM (exploseBom) to compute component net requirements (README MRP flow; mrp/mrp.service.ts).
    • ItemItem.defaultBomId points at the item's standard BOM; Item.isManufactured / manufacturingType flag which items can have BOMs (../inventory/item.md).
  • No cron/queue/external service. The XLSX export is synchronous (exceljs via ApBaseController).

9. Gotchas & project-specific rules

  • recalculateBomCost ignores UOM. It multiplies raw quantity × unitCost with no uomId-based conversion. If a line's quantity is 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. recalculateBomCost only computes material cost; totalLaborCost and totalOverheadCost are whatever the caller stored. Routing/work-center costs are not rolled into the BOM automatically.
  • approvalStatus is tracked but not enforced. Nothing prevents producing from a DRAFT/REJECTED BOM — production does not gate on approvalStatus (or BomStatus). Two independent lifecycle fields (status = DRAFT/ACTIVE/OBSOLETE, approvalStatus = the approval flow) coexist and are not linked.
  • version never 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 higher version manually (and mark the old one OBSOLETE).
  • bomNumber is not generated. Unlike order refs, the caller supplies it. No uniqueness constraint in code.
  • bomPage/bomLinePage disable the branch filter (includeBranchQuery: false) — BOMs list company-wide even though rows carry branchId.
  • Bom.lines GraphQL field is not populated by the resolver. Fetch lines via bomLinePage(bomId).
  • bomType: PHANTOM is 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's totalCost. Don't conflate the two cost numbers (§4.1).
  • README drift. manufacturing/README.md references a bom/line/{…} layout and collections named boms/bom_lines; the actual collections are manufacturing_boms / manufacturing_bom_lines. Trust the schemas.