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'squantityScrapped(§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
updateProductionOrdermutation. The service hasrelease()/complete()methods that flush materials, but neither is exposed as a GraphQL mutation — the admin "Complete Order" button just setsstatus: COMPLETEDdirectly (§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). TheissueMaterial/startOperation/completeOperationhelper methods exist but are not wired to any resolver. - Most manufacturing GL legs are not posted.
ManufacturingJournalServicedefines material-issuance, labor, overhead, completion, and variance entries, but onlypostScrapRecordingis 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. $lookupItem → item. |
bomId |
ObjectId | no | — | The BOM defining components. $lookupBom → bom. |
routingId |
ObjectId | no | — | The routing defining operations. $lookupRouting → routing. |
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/page2.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. $lookupScrapItem → item. |
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/completeOperationmutation inschema.gql. Those service methods are unreachable from the API; status/qty/cost changes all go throughupdateProductionOrder.
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
RELEASEDorIN_PROGRESS, which callsupdateProductionOrder(_id, { status: COMPLETED, completionDate: today })— a direct field set, not the servicecomplete()method; - treats
COMPLETED/CANCELLEDas 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). Becauserelease()/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 viabackflushConsumeif 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 useitem.cost). On-hand at the branch drops byqty. - This is the only stock writer in the system that also mutates the legacy
item.stockOut/netQuantitydenormalized 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 of0. 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.quantityScrappedbymodel.quantity. - If
scrapCost > 0andcompanyIdpresent, 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
wipDashboardquery 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
createProductionOutputand the (un-wired)release/completeusewithRetryTransaction. Known gap: the session is not propagated intobackflushConsume/costingSvc.postReceipt/postIssue(explicit TODO comments) — so the inventory writes do not actually join the order's transaction. A rebuild must implementsetSession()to fan the session into stock + costing services.createScrapRecordis 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 stampcreatedBy/updatedByfrom the session user. - Scrap:
@ApGqlAuthorize()(standard JWT + RBAC). Create stampsrecordedBy,createdBy,companyId,branchIdfrom 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
- Admin opens Manufacturing → Production Order (
/manufacturing/production-order) → "Create". - Form supplies item, BOM, routing, planned qty, start/due dates, source, notes/instructions/attachments.
saveProductionOrder("", payload)→createProductionOrder. Resolver auto-numbers (PO-…if blank), stampscreatedBy→ProductionOrderService.create(status defaultsDRAFT). Audit CREATE.
6.2 Seed materials & operations (admin-side, from BOM/routing)
- On the order detail (Materials tab): "Seed from BOM" →
findOneBom(bomId)→ for each BOM line,createProductionMaterial({ productionOrderId, itemId: line.componentItemId, quantityRequired: line.quantity × quantityPlanned }). - (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 ifbackflushConsumeis invoked — which only the un-wiredrelease()/complete()do (§4.2). In a wired/rebuilt system: per FORWARD line on release / BACKWARD on completion →Stock(type=OUT, kind="Stock")atorder.branchId, qty =line.quantity × quantityPlanned × (1+scrap%), costed viapostIssue(§4.3).
6.4 Finished-goods output (Stock IN) — happy path
- On the order detail (Output tab): "Add Output" →
createProductionOutput({ productionOrderId, quantityGood, quantityScrap?, warehouseId?, batchNumber?, serialNumber?, completionDate? }). ProductionOutputService.create(retry txn): writes the output, then ifquantityGood > 0reads the order, andcostingSvc.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
- Admin clicks Complete Order (visible when
RELEASED/IN_PROGRESS). updateProductionOrder(_id, { status: COMPLETED, completionDate: today })— a direct field set. No backflush, FG receipt, or completion GL is triggered by this (the servicecomplete()automation is not reachable). FG receipt must be done via the Output tab (§6.4) separately.
6.6 Record scrap
- Create a scrap record (item, qty, reason code, optional cost/operation).
createScrapRecord→ writes record, bumpsorder.quantityScrapped += qty, and ifscrapCost > 0posts DR Scrap Expense / CR WIP (§4.5). No stock row written.
6.7 Unhappy paths
- Missing required input (
itemId/quantityPlanned/startDate/dueDateon create; scrapquantity/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) →
postIssuefalls back per costing rules; a backflush exception is caught and logged inautoConsumeMaterials(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 (whenRELEASED/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 viauseProductionMaterialState().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 (
BomLineServicefor component lines + flushing method + scrap%), Routing/Work Center (operations +costRatePerHour), item. - Writes inventory:
inventory/stockviaStockService.backflushConsume(material OUT) andCostingService.postReceipt/postIssue(FG IN cost + material issue cost). TheProductionOrderModuleimportsStockModule+BomModule(forwardRef). - Writes GL:
ManufacturingJournalService→ financejournal(JournalEntryService.addEntry) +account(AccountServiceto 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 throughupdateProductionOrder; the admin "Complete Order" just sets the field. Material consumption does not happen automatically on status change in the live system. issueMaterial/startOperation/completeOperationare 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.
createProductionOutputcallspostReceipt(..., 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/costVarianceReportread the order's storedactual*/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. ImplementsetSession()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 legacyitem.stockOut/netQuantitycounters (every other flow trusts the ledger). It is also not linked back to the production order viaorderIdon the stock row. - Scrap does not return material to stock and
createScrapRecordis non-transactional (a journal failure is swallowed; the record + qty bump still persist). plannedRunHoursis 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 byquantityPlanned.- 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).