Production Orders — turning components into finished goods (WIP, material issue, FG output)

A Production Order is the work order that consumes raw materials and labor to produce a finished item. It links an item + its BOM (what to consume) + its Routing (how to make it), tracks planned vs actual cost across three buckets (material / labor / overhead), and moves through a status lifecycle. The two real inventory effects are: material consumption → Stock(type=OUT) (via backflush) and finished-goods output → Stock(type=IN) (via the costing receipt). Manufacturing GL legs follow the classic WIP flow (DR WIP on issue/labor/overhead, DR Finished Goods / CR WIP on completion) — but in the current code only the scrap GL leg is actually wired up; the rest of the journal methods exist and are unused.

Source: BE manufacturing/production (production.scheme.ts, production.dto.ts, production.service.ts, production.repository.ts, production.resolver.ts, production.module.ts) + sub-modules production/material, production/operation, production/output, production/scrap (*.scheme.ts, *.service.ts, *.resolver.ts) · GL in manufacturing/accounting/journal.service.ts + account.types.ts · stock writer inventory/stock/stock.service.ts → backflushConsume() + costing.service.ts → postReceipt() · Admin src/modules/manufacturing/production (+ material, operation, output, scrap) · pages src/pages/manufacturing/production-order/{index,[id]}.tsx

Related: ./_overview.md · ./bom.md (component list + flushing method) · ./routing-work-center.md (operations + cost rates) · ../inventory/stock.md (the ledger that material-OUT and FG-IN write to) · ../finance/journal.md (the GL engine the manufacturing journal posts into) · ../../platform/permissions-access.md


1. Purpose & scope

The production domain owns four collections plus a scrap log, and the orchestration that ties them to inventory and the GL:

  • production_orders — the work-order header: item, BOM, routing, planned qty, status, source, dates, and the six planned/actual cost fields. Auto-numbered (PO-XXXXXXXX) when not supplied.
  • production_materials — the per-order component list (what was/should be consumed). Snapshot of the BOM lines for this order, with required/issued/returned qty and unit/total cost.
  • production_operations — the per-order operation list (the steps actually run). Snapshot of the routing operations, with planned/actual setup+run hours and labor/overhead cost per step.
  • production_outputs — finished-goods receipts: good qty + scrap qty per completion event, with batch/serial/warehouse. Creating an output is what triggers the FG Stock IN (§4.4).
  • scrap_records — scrap logged against an order/operation/item, with reason code and cost. Creating a scrap record posts the one live manufacturing GL leg (DR Scrap Expense / CR WIP) and bumps the order's quantityScrapped (§4.5).

What it is responsible for:

  • Defining and tracking production orders and their material/operation/output/scrap children.
  • Consuming components into stock (backflush) and receiving finished goods into stock.
  • Capturing cost into the order's actual cost fields and (for scrap) posting GL.

What it explicitly does NOT do (important — much is scaffolding):

  • No enforced state machine. Status is a free-set enum changed via the generic updateProductionOrder mutation. The service has release()/complete() methods that flush materials, but neither is exposed as a GraphQL mutation — the admin "Complete Order" button just sets status: COMPLETED directly (§4.1, §4.2).
  • No automatic material→stock or labor→cost on status change in the live API. The backflush logic lives only in the un-wired release()/complete() service methods. Material/operation rows are CRUD'd manually (the admin "seeds" them from BOM/routing). The issueMaterial / startOperation / completeOperation helper methods exist but are not wired to any resolver.
  • Most manufacturing GL legs are not posted. ManufacturingJournalService defines material-issuance, labor, overhead, completion, and variance entries, but only postScrapRecording is actually called anywhere. Material/labor/overhead/completion/variance journals are dead code awaiting wiring (§4.6).
  • No cost rollup engine here. The order's planned*/actual* cost fields are stored numbers; nothing in this module computes them from BOM/routing automatically (a rebuild must fill them, or wire the costing methods). The admin shows them read-only.

Rebuild note: treat this module as a data model + partial orchestration. The schemas, enums, stock backflush, FG receipt, and scrap GL are real and code-accurate. The release/complete automation and the WIP GL flow are designed (and the methods exist) but not connected to the API surface in the current code. This doc documents both what runs and what is defined-but-dormant, and flags which is which.


2. Data model

All five schemas extend BaseSchema (key, ref, documentDate, createdBy/At, updatedBy/At, soft-delete fields, canDelete/canUpdate/canView/canPost), set timestamps: true, plug mongoose-delete (deletedAt), and re-declare companyId/branchId (via BaseSchema.toObjectId). All ObjectId fields use the BaseSchema.toObjectId setter; all date fields use BaseSchema.toUnixTimestamp (stored as unix ms).

2.1 ProductionOrder (collection production_orders)

field type required default description
orderNumber string yes auto PO-{nanoId(8)} Human work-order number. Generated in the resolver if not supplied.
itemId ObjectId no (req in input) Finished item to produce. $lookupItemitem.
bomId ObjectId no The BOM defining components. $lookupBombom.
routingId ObjectId no The routing defining operations. $lookupRoutingrouting.
quantityPlanned number yes 0 Units to produce. Drives required-material math.
quantityCompleted number no 0 Good units produced so far.
quantityScrapped number no 0 Scrapped units; incremented by scrap records (§4.5).
status ProductionOrderStatus no (enum) DRAFT DRAFT/RELEASED/IN_PROGRESS/COMPLETED/CANCELLED.
source ProductionOrderSource no (enum) MANUAL MANUAL/SALES_ORDER/MRP.
startDate number (unix) no Planned/actual start.
dueDate number (unix) no Due date.
completionDate number (unix) no Set when marked COMPLETED.
salesOrderId ObjectId no Source sales order (when source=SALES_ORDER).
plannedMaterialCost number no 0 Planned cost buckets — stored, not auto-computed.
plannedLaborCost number no 0
plannedOverheadCost number no 0
plannedTotalCost number no 0
actualMaterialCost number no 0 Actual cost buckets — stored, not auto-computed (no live writer).
actualLaborCost number no 0
actualOverheadCost number no 0
actualTotalCost number no 0
notes string no Free text.
instructions string no Work instructions.
attachments object[] no [] Embedded { fileName, fileUrl, fileType, uploadedAt } (SOP/drawings).
// production.scheme.ts
export enum ProductionOrderStatus { DRAFT="DRAFT", RELEASED="RELEASED", IN_PROGRESS="IN_PROGRESS", COMPLETED="COMPLETED", CANCELLED="CANCELLED" }
export enum ProductionOrderSource { MANUAL="MANUAL", SALES_ORDER="SALES_ORDER", MRP="MRP" }
// $lookupItem/$lookupBom/$lookupRouting → item/bom/routing (all unwound); applied by repo find/findById/findOne/page

2.2 ProductionMaterial (collection production_materials)

The per-order component requirement. One row per component.

field type required default description
productionOrderId ObjectId no Parent order. $lookupProductionOrder.
itemId ObjectId no Component item. $lookupItem.
quantityRequired number no 0 = BOM line.quantity × order.quantityPlanned (set by the admin seed; §6.2).
quantityIssued number no 0 Qty actually issued to the floor.
quantityReturned number no 0 Qty returned unused.
uomId ObjectId no UOM (masters collection). $lookupUom.
warehouseId ObjectId no Source branch/warehouse.
unitCost number no 0 Component unit cost.
totalCost number no 0 Extended cost.
issuedDate number (unix) no When issued.
status ProductionMaterialStatus no (enum) PENDING PENDING/PARTIALLY_ISSUED/FULLY_ISSUED.
export enum ProductionMaterialStatus { PENDING="PENDING", PARTIALLY_ISSUED="PARTIALLY_ISSUED", FULLY_ISSUED="FULLY_ISSUED" }

2.3 ProductionOperation (collection production_operations)

The per-order operation (a copy of the routing operation for this order, plus actuals).

field type required default description
productionOrderId ObjectId no Parent order. $lookupProductionOrder.
sequenceNo number no Step order (copied from routing op).
operationName string no Step label.
workCenterId ObjectId no Work center running it. $lookupWorkCenter.
plannedSetupHours number no 0 From routing op setupTimeHours.
plannedRunHours number no 0 From routing op runTimePerUnitHours (seeded as-is — see gotchas).
actualSetupHours number no 0 Captured actuals.
actualRunHours number no 0 Captured actuals.
plannedStartDate / plannedEndDate number (unix) no Schedule.
actualStartDate / actualEndDate number (unix) no Set by startOperation/completeOperation (un-wired helpers).
laborCost number no 0 Captured labor cost for the step (actualRunHours × workCenter.costRatePerHour if a rebuild wires it).
overheadCost number no 0 Captured overhead for the step.
status ProductionOperationStatus no (enum) PENDING PENDING/IN_PROGRESS/COMPLETED/SKIPPED.
instructions string no Work instructions.
attachments object[] no [] Embedded files.
export enum ProductionOperationStatus { PENDING="PENDING", IN_PROGRESS="IN_PROGRESS", COMPLETED="COMPLETED", SKIPPED="SKIPPED" }

2.4 ProductionOutput (collection production_outputs)

A finished-goods completion event. Creating one drives the FG Stock IN.

field type required default description
productionOrderId ObjectId no Parent order (read to find itemId + branch for the receipt).
quantityGood number no 0 Good units produced. >0 triggers costingSvc.postReceipt → Stock IN (§4.4).
quantityScrap number no 0 Scrap on this event (recorded here; the GL scrap leg is separate — §4.5).
completionDate number (unix) no Completion timestamp.
batchNumber string no Lot/batch.
serialNumber string no Serial.
warehouseId ObjectId no Receiving branch/warehouse.
notes string no Free text.

2.5 ScrapRecord (collection scrap_records)

field type required default description
productionOrderId ObjectId yes Order the scrap belongs to. $lookupProductionOrder.
operationId ObjectId no Operation where scrap occurred.
itemId ObjectId yes Scrapped item. $lookupScrapItemitem.
quantity number yes Scrapped qty. Added to order quantityScrapped (§4.5).
reasonCode ScrapReasonCode yes (enum) See enum below.
reasonDescription string no Free text.
scrapCost number no 0 Cost of scrap. If >0 and companyId set → posts the scrap GL leg.
scrapDate number (unix) no When scrapped.
recordedBy ObjectId no User (stamped from session).
export enum ScrapReasonCode {
  MATERIAL_DEFECT="MATERIAL_DEFECT", MACHINE_ERROR="MACHINE_ERROR", OPERATOR_ERROR="OPERATOR_ERROR",
  TOOLING_WEAR="TOOLING_WEAR", SETUP_WASTE="SETUP_WASTE", QUALITY_REJECT="QUALITY_REJECT",
  DAMAGED_IN_HANDLING="DAMAGED_IN_HANDLING", OTHER="OTHER",
}

2.6 Relationships & scoping

Item ─< ProductionOrder >─ BOM (bomId)                       ProductionOutput ─> ProductionOrder
  │            │           >─ Routing (routingId)                  └─ on create → Stock(type=IN) for order.itemId
  │            │           >─ SalesOrder (salesOrderId)
  │            ├─< ProductionMaterial (productionOrderId) ─> Item, UOM
  │            ├─< ProductionOperation (productionOrderId) ─> WorkCenter
  │            └─< ScrapRecord (productionOrderId) ─> Item, Operation
  └─ (FG receipt + material backflush both write the shared inventory/stock ledger)
  • All children reference the order by productionOrderId (referenced, not embedded).
  • The materials/operations are snapshots seeded from the BOM/routing — they are independent copies, so editing the BOM later does not retro-change an existing order's materials.
  • Tenant scope via companyId (+ branchId). Soft-delete excludes rows automatically.

3. API surface

All GraphQL (code-first). No REST. The production-order resolver is @ApInitGqlAuthorize() (class level); productionOrderPage adds @ApGqlAuthorize({ includeBranchQuery: false }) (company-wide list). The material/operation/output resolvers follow the same shape; the scrap resolver is @ApGqlAuthorize().

Operation Type Input Returns Notes
productionOrderPage Query ProductionOrderPageInput (skip,take,keyword,sortBy,sortOrder,status,itemId) ProductionOrderPageResult (with item/bom/routing joined) not branch-filtered
findProductionOrder Query _id: ID! ProductionOrder (joined)
createProductionOrder Mutation productionOrder: CreateProductionOrderInput! ProductionOrder resolver auto-numbers + stamps createdBy; @AuditMeta CREATE
updateProductionOrder Mutation _id: String!, productionOrder: UpdateProductionOrderInput! ProductionOrder stamps updatedBy; this is how status is changed (incl. "complete"); @AuditMeta UPDATE
deleteProductionOrder Mutation _id: String! Boolean soft delete; @AuditMeta DELETE
deleteProductionOrders Mutation _ids: [String!]! Boolean loops delete
productionMaterialPage / findProductionMaterial / create… / update… / delete… / deleteProductionMaterials Q/M analogous DTOs ProductionMaterial(s) CRUD only
productionOperationPage / findProductionOperation / create… / update… / delete… / deleteProductionOperations Q/M analogous ProductionOperation(s) CRUD only
productionOutputPage / findProductionOutput / create… / update… / delete… / deleteProductionOutputs Q/M analogous ProductionOutput(s) createProductionOutput triggers Stock IN
createScrapRecord / updateScrapRecord / deleteScrapRecord / findScrapRecord / scrapRecordPage Q/M CreateScrapRecordInput (stamps recordedBy/companyId/branchId from user) ScrapRecord(s) create posts scrap GL + bumps qtyScrapped
wipDashboard Query WipDashboardResult sums actual* cost of RELEASED/IN_PROGRESS orders (manufacturing/accounting)
costVarianceReport Query CostVarianceResult actualTotalCost − plannedTotalCost over COMPLETED orders

CommonProductionOrderInput requires itemId, quantityPlanned, startDate, dueDate (others nullable). UpdateProductionOrderInput = PartialType(...) adds status, quantityCompleted, quantityScrapped, completionDate, and the four actual* cost fields — these are settable only via update.

There is no releaseProductionOrder / completeProductionOrder / issueMaterial / startOperation / completeOperation mutation in schema.gql. Those service methods are unreachable from the API; status/qty/cost changes all go through updateProductionOrder.


4. Business rules & calculations

4.1 Status lifecycle (descriptive, not enforced)

        ┌──────────────────────── CANCELLED (terminal)
        │
DRAFT ──┴─▶ RELEASED ──▶ IN_PROGRESS ──▶ COMPLETED (terminal)

These are the intended transitions, but nothing in the BE enforces them. status is a plain enum field set by updateProductionOrder. The admin only:

  • shows a "Complete Order" button when status is RELEASED or IN_PROGRESS, which calls updateProductionOrder(_id, { status: COMPLETED, completionDate: today }) — a direct field set, not the service complete() method;
  • treats COMPLETED/CANCELLED as read-only (isReadOnly), hiding edit/complete actions.

There is no guard preventing e.g. DRAFT → COMPLETED, editing a completed order via API, or skipping RELEASED. A rebuild that needs a real state machine must add it.

4.2 The (un-wired) release/complete automation — backflush flushing

ProductionOrderService defines two methods that are not exposed as mutations but encode the intended material-consumption automation, driven by each BOM line's FlushingMethod (MANUAL/FORWARD/BACKWARD):

// production.service.ts
public async release(orderId) {           // intended: DRAFT → RELEASED
  return this.withRetryTransaction('release_production_order', async () => {
    const order = await this.update(orderId, { status: "RELEASED" });
    await this.autoConsumeMaterials(orderId, FlushingMethod.FORWARD);   // flush FORWARD lines now
    return order;
  });
}
public async complete(orderId) {          // intended: → COMPLETED
  return this.withRetryTransaction('complete_production_order', async () => {
    const order = await this.update(orderId, { status: "COMPLETED" });
    await this.autoConsumeMaterials(orderId, FlushingMethod.BACKWARD);  // flush BACKWARD lines now
    return order;
  });
}

private async autoConsumeMaterials(orderId, flushingMethod) {
  const order = await repo.findById(orderId); if (!order?.bomId) return;
  const bomLines = await bomLineSvc.repo.find({ bomId: order.bomId });
  for (const line of bomLines) {
    if (line.flushingMethod !== flushingMethod) continue;
    const requiredQty   = (line.quantity || 0) * (order.quantityPlanned || 0);
    const scrapMultiplier = 1 + (line.scrapPercent || 0) / 100;
    const totalQty      = requiredQty * scrapMultiplier;                // ← consumption qty formula
    if (totalQty > 0) await stockSvc.backflushConsume(line.componentItemId, totalQty, order.branchId);
  }
}

Consumption qty per component = bomLine.quantity × order.quantityPlanned × (1 + scrapPercent/100). FORWARD lines are intended to flush on release, BACKWARD lines on completion, MANUAL lines never auto-flush (issued by hand). Because release()/complete() aren't wired to the API, this automation does not run in the current system — material rows are created/seeded manually and stock is moved only via backflushConsume if a caller invokes these methods (none does over GraphQL).

Known TODO in code: the Mongo session is not propagated into backflushConsume/costingSvc, so the stock writes would not join the order's transaction even if these ran (see comments + gotchas).

4.3 Material consumption → Stock OUT (backflushConsume)

When material is backflushed, the inventory ledger writer (documented fully in ../inventory/stock.md) appends an OUT row and costs it via the FIFO/AVG/STD costing engine:

// inventory/stock/stock.service.ts → backflushConsume(itemId, qty, branchId)
const { unitCost } = await this.costingSvc.postIssue(itemId, qty, branchId);   // consume cost layers
const stockOut = await this.stockRepo.create({
  itemId, branchId, type: "OUT", kind: "Stock",
  grossQuantity: qty, netQuantity: qty, wasteQuantity: 0,
  cost: unitCost * qty, avgCost: unitCost, status: "Available", fixed: true,
});
// also mutates legacy denormalized counters on the item:
await itemSvc.update(itemId, { stockOut: item.stockOut + qty, netQuantity: item.netQuantity - qty });

Key facts for a rebuild:

  • The material OUT row has kind: "Stock" (generic), not a manufacturing-specific kind — it is indistinguishable from a manual stock-out by kind alone (link via the absent order ref).
  • Unit cost comes from CostingService.postIssue (FIFO consumes oldest layers; AVERAGE/STANDARD use item.cost). On-hand at the branch drops by qty.
  • This is the only stock writer in the system that also mutates the legacy item.stockOut/netQuantity denormalized counters — every other inventory flow trusts the ledger. (See stock §4.4.)

4.4 Finished-goods output → Stock IN (ProductionOutputService.create)

Creating a production output is the wired FG-receipt path. It runs in a retry transaction and posts a costing receipt for the order's finished item:

// production/output/output.service.ts
public override async create(data) {
  return this.withRetryTransaction('create_production_output', async () => {
    const output = await super.create(data);
    if (data.productionOrderId && data.quantityGood > 0) {
      const order = await productionOrderModel.findById(data.productionOrderId).lean();
      if (order?.itemId) {
        const branchId = (data.branchId || order.branchId)?.toString();
        await this.costingSvc.postReceipt(order.itemId, data.quantityGood, 0,   // ← unitCost = 0 (!)
                                          `production-output-${output._id}`, branchId);
      }
    }
    return output;
  });
}

What postReceipt does (see stock §4.6): for FIFO it pushes a cost layer; for AVERAGE it's effectively a no-op on cost; for STANDARD it posts a price variance if unitCost ≠ item.cost. For the FG item, the standard inventory IN row is written through the costing/receipt path so on-hand of the finished item rises by quantityGood at the branch.

Gotcha (cost of FG = 0): the receipt is posted with unitCost = 0. The finished good is received at zero unit cost (no roll-up of consumed material + labor + overhead into the FG cost). A faithful rebuild that wants proper FG costing must compute the produced unit cost (Σ material + labor + overhead ÷ goodQty) and pass it here instead of 0. As-is, FG inventory value from production is not costed.

4.5 Scrap → qty bump + the one live GL leg (createScrapRecord)

// production/scrap/scrap-record.service.ts
public async createScrapRecord(model) {
  const record = await this.scrapRecordRepo.create(model);
  if (model.productionOrderId) {
    const order = await productionOrderSvc.findById(model.productionOrderId);
    if (order) await productionOrderSvc.update(order._id, {
      quantityScrapped: (order.quantityScrapped || 0) + (model.quantity || 0),   // running scrap total
    });
    if (model.scrapCost && model.companyId) {
      await journalSvc.postScrapRecording(model.productionOrderId, model.scrapCost, model.companyId, model.recordedBy);
    }
  }
  return record;
}
  • Always bumps order.quantityScrapped by model.quantity.
  • If scrapCost > 0 and companyId present, posts the scrap journal (DR Scrap Expense / CR WIP) — the only manufacturing GL leg actually invoked in the codebase. Failure is caught and logged (never blocks the scrap record). This call is not wrapped in a transaction (the record is created, then the order update + journal run after).
  • Scrap does not write a stock row (it does not return scrapped material to inventory).

4.6 Manufacturing GL legs (the WIP flow — mostly dormant)

ManufacturingJournalService resolves accounts by conventional name in the company's chart of accounts (see table) and posts balanced DR/CR pairs via the finance journal engine. If either account is missing, the entry is skipped with a warning (production never blocked).

Entry (method) Debit Credit When intended Wired?
Material issuance (postMaterialIssuance) WIP Raw Materials Inventory on material issue No (defined, never called)
Labor recording (postLaborRecording) WIP Manufacturing Overhead on labor capture No
Overhead absorption (postOverheadAbsorption) WIP Overhead Applied on overhead capture No
Production completion (postProductionCompletion) Finished Goods Inventory WIP on completion No
Scrap recording (postScrapRecording) Scrap Expense WIP on scrap record Yes (§4.5)
Variance recognition (postVarianceRecognition) variance acct / WIP (by sign) WIP / variance acct at close No

Variance sign convention (when wired): variance > 0 (unfavorable) → DR the variance account, CR WIP; variance < 0 (favorable) → DR WIP, CR the variance account; amount = |variance|; zero variances skipped. Account-type → GL-name map:

WIP → "Work-in-Progress (WIP)"            RAW_MATERIALS_INVENTORY → "Raw Materials Inventory"
FINISHED_GOODS_INVENTORY → "Finished Goods Inventory"   MANUFACTURING_OVERHEAD → "Manufacturing Overhead"
OVERHEAD_APPLIED → "Overhead Applied"     SCRAP_EXPENSE → "Scrap Expense"
MATERIAL_USAGE_VARIANCE / LABOR_EFFICIENCY_VARIANCE / OVERHEAD_VARIANCE / SUBCONTRACTING_EXPENSE → like-named

Net live accounting effect of production today: only the scrap leg (DR Scrap Expense / CR WIP) posts. WIP is never debited by material/labor/overhead in the current code, so the WIP credit on scrap can drive WIP negative — the full WIP cycle is designed but not connected. The wipDashboard query computes "WIP" purely from the orders' actual* cost fields (not from GL balances), and those fields have no live writer.

4.7 Cost capture (intended formulas)

The order's actual cost buckets are meant to accumulate:

actualMaterialCost  = Σ consumed material (qty × unitCost)         // from backflush / issued materials
actualLaborCost     = Σ operations (actualRunHours × workCenter.costRatePerHour)   // labor portion
actualOverheadCost  = Σ operations (overhead applied)              // overhead portion
actualTotalCost     = actualMaterialCost + actualLaborCost + actualOverheadCost

None of these are computed automatically in the current code — they are settable via updateProductionOrder and displayed read-only. The work-center costRatePerHour and routing standard times (see routing-work-center) are the inputs a rebuild would multiply.

4.8 Transactionality

  • createProductionOutput and the (un-wired) release/complete use withRetryTransaction. Known gap: the session is not propagated into backflushConsume/costingSvc.postReceipt/postIssue (explicit TODO comments) — so the inventory writes do not actually join the order's transaction. A rebuild must implement setSession() to fan the session into stock + costing services.
  • createScrapRecord is not transactional (record → order update → journal run sequentially; a journal failure is swallowed).
  • Plain order/material/operation CRUD is single-document.

5. Permissions

  • Production order, material, operation, output: class-level @ApInitGqlAuthorize(); list queries add @ApGqlAuthorize({ includeBranchQuery: false }). Mutations carry @AuditMeta({ module: "production", collection: <coll> }). Created rows stamp createdBy/updatedBy from the session user.
  • Scrap: @ApGqlAuthorize() (standard JWT + RBAC). Create stamps recordedBy, createdBy, companyId, branchId from the session.
  • WIP/variance dashboards (manufacturing/accounting): @ApGqlAuthorize().
  • No per-operation CASL action declarations beyond the class guards. See permissions-access.

6. Flows

6.1 Create a production order

  1. Admin opens Manufacturing → Production Order (/manufacturing/production-order) → "Create".
  2. Form supplies item, BOM, routing, planned qty, start/due dates, source, notes/instructions/attachments.
  3. saveProductionOrder("", payload)createProductionOrder. Resolver auto-numbers (PO-… if blank), stamps createdByProductionOrderService.create (status defaults DRAFT). Audit CREATE.

6.2 Seed materials & operations (admin-side, from BOM/routing)

  1. On the order detail (Materials tab): "Seed from BOM" → findOneBom(bomId) → for each BOM line, createProductionMaterial({ productionOrderId, itemId: line.componentItemId, quantityRequired: line.quantity × quantityPlanned }).
  2. (Operations tab): "Seed from Routing" → findOneRouting(routingId) → for each routing op, createProductionOperation({ productionOrderId, sequenceNo, operationName, workCenterId, plannedSetupHours: op.setupTimeHours, plannedRunHours: op.runTimePerUnitHours }). (These are admin loops over the CRUD mutations — there is no server-side "explode BOM/routing" call.)

6.3 Material consumption (Stock OUT)

  • Live path: none automatic. Material rows track quantityIssued, but issuing does not by itself write stock. Stock OUT only happens if backflushConsume is invoked — which only the un-wired release()/complete() do (§4.2). In a wired/rebuilt system: per FORWARD line on release / BACKWARD on completion → Stock(type=OUT, kind="Stock") at order.branchId, qty = line.quantity × quantityPlanned × (1+scrap%), costed via postIssue (§4.3).

6.4 Finished-goods output (Stock IN) — happy path

  1. On the order detail (Output tab): "Add Output" → createProductionOutput({ productionOrderId, quantityGood, quantityScrap?, warehouseId?, batchNumber?, serialNumber?, completionDate? }).
  2. ProductionOutputService.create (retry txn): writes the output, then if quantityGood > 0 reads the order, and costingSvc.postReceipt(order.itemId, quantityGood, 0, "production-output-…", branchId)finished item on-hand rises at the branch (Stock IN), costed at 0 (§4.4 gotcha).

6.5 Complete the order

  1. Admin clicks Complete Order (visible when RELEASED/IN_PROGRESS).
  2. updateProductionOrder(_id, { status: COMPLETED, completionDate: today }) — a direct field set. No backflush, FG receipt, or completion GL is triggered by this (the service complete() automation is not reachable). FG receipt must be done via the Output tab (§6.4) separately.

6.6 Record scrap

  1. Create a scrap record (item, qty, reason code, optional cost/operation).
  2. createScrapRecord → writes record, bumps order.quantityScrapped += qty, and if scrapCost > 0 posts DR Scrap Expense / CR WIP (§4.5). No stock row written.

6.7 Unhappy paths

  • Missing required input (itemId/quantityPlanned/startDate/dueDate on create; scrap quantity/ reasonCode/itemId) → GraphQL non-null error.
  • Output with quantityGood = 0 → output row written, no Stock IN.
  • Scrap GL accounts not configured → journal skipped with a warning (record still saved).
  • Backflush of a component with insufficient cost layers (FIFO) → postIssue falls back per costing rules; a backflush exception is caught and logged in autoConsumeMaterials (does not abort the loop).

7. Admin UI

Pages: src/pages/manufacturing/production-order/index.tsx (list) + [id].tsx (detail). The admin module folder is manufacturing/production (the route is production-order).

List (page.tsx + components/table.tsx): keyword + status + item filters; columns Order Number / Item / Status (tag: DRAFT default, RELEASED blue, IN_PROGRESS orange, COMPLETED green, CANCELLED red) / Qty Planned / Qty Completed / dates / Actions (edit, delete, view-detail).

Detail (detail.tsx): tabbed — Info / Materials / Operations / Output.

  • Info: order info grid + a Cost Summary card showing all eight planned/actual cost fields (read-only, toFixed(2)), work instructions + attachment links. "Complete Order" button (when RELEASED/IN_PROGRESS) → confirm modal → updateProductionOrder({ status: COMPLETED, completionDate }). isReadOnly (COMPLETED/CANCELLED) hides edit/complete.
  • Materials tab: ProductionMaterialTable + create + a "Seed from BOM" action (§6.2). Loads via useProductionMaterialState().fetchMaterials({ productionOrderId }).
  • Operations tab: ProductionOperationTable + create + "Seed from Routing" action (§6.2).
  • Output tab: ProductionOutputTable + CreateProductionOutput (Yup: quantityGood ≥ 0.001) → triggers the FG Stock IN on save.

Contexts: useProductionOrderState (orders CRUD), and per-tab useProductionMaterialState / useProductionOperationState / useProductionOutputState (each with fetch…, save…, delete…). All follow the standard context-owns-state pattern; saveX(_id, payload) switches create/update on _id.

Scrap UI: manufacturing/production/scrap module (table + create with reason-code select) — typically surfaced via the quality/production reporting area.

WIP / variance: wipDashboard and costVarianceReport feed the manufacturing dashboard/reports.


8. Dependencies & integrations

  • Reads: BOM (BomLineService for component lines + flushing method + scrap%), Routing/Work Center (operations + costRatePerHour), item.
  • Writes inventory: inventory/stock via StockService.backflushConsume (material OUT) and CostingService.postReceipt/postIssue (FG IN cost + material issue cost). The ProductionOrderModule imports StockModule + BomModule (forwardRef).
  • Writes GL: ManufacturingJournalService → finance journal (JournalEntryService.addEntry) + account (AccountService to resolve account ids by name). Only the scrap leg is live.
  • Called by: MRP (can originate orders with source=MRP); sales orders (source=SALES_ORDER, salesOrderId); manufacturing dashboard/reports and accounting dashboards read order cost/status.
  • No cron/queue/external service. Attachments are file URLs (see files-assets-upload).

9. Gotchas & project-specific rules

  • Release/complete automation is not wired to the API. release()/complete() (and the backflush flushing they drive) exist only as service methods with no GraphQL mutation. Status changes go through updateProductionOrder; the admin "Complete Order" just sets the field. Material consumption does not happen automatically on status change in the live system.
  • issueMaterial / startOperation / completeOperation are dead helpers — defined on the material/operation services but not exposed as mutations. Material/operation actuals are set via plain update.
  • Finished goods are received at cost 0. createProductionOutput calls postReceipt(..., unitCost=0). No material+labor+overhead roll-up into FG cost. Fix this in a rebuild for correct inventory valuation.
  • Only scrap posts to the GL. Material-issuance, labor, overhead, completion, and variance journal methods are implemented but never called. WIP is therefore only ever credited (by scrap), never debited, in current code — the WIP cycle is incomplete.
  • wipDashboard/costVarianceReport read the order's stored actual*/planned* cost fields, not GL balances or computed costs — and those fields have no live writer, so they reflect only what someone set via update. Don't treat them as authoritative without wiring cost capture.
  • Transactions don't reach inventory/costing. Explicit TODOs: the order's Mongo session isn't fanned into backflushConsume/costingSvc, so even when those run they commit outside the order transaction. Implement setSession() to fix.
  • Material/operation rows are snapshots seeded by the admin looping CRUD over BOM/routing — there is no server-side BOM/routing explosion. Editing the BOM/routing later does not update existing orders.
  • Backflush material OUT uses kind: "Stock" (generic), not a manufacturing kind, and is the only writer that mutates the legacy item.stockOut/netQuantity counters (every other flow trusts the ledger). It is also not linked back to the production order via orderId on the stock row.
  • Scrap does not return material to stock and createScrapRecord is non-transactional (a journal failure is swallowed; the record + qty bump still persist).
  • plannedRunHours is seeded as the routing's per-unit run time (runTimePerUnitHours), not per-unit × qty — so the seeded planned run hours are per unit, not the order total. A rebuild capturing labor must multiply by quantityPlanned.
  • No state-transition guards. Any status → any status is accepted by updateProductionOrder, including editing/cancelling completed orders via API (the read-only lock is admin-UI only).