Manufacturing Accounting & Reporting — WIP/variance GL posting, cost dashboards, and operational reports
This covers three sibling sub-modules that all read (and one writes) the production order as the cost ledger: (1) Accounting — auto-posting manufacturing GL legs (WIP, finished goods, scrap, variances) via the finance journal engine, plus a live WIP / cost-variance dashboard; (2) Dashboard — manufacturing KPI tiles (output, WIP value, overdue, status counts); (3) Reports — seven date-ranged operational reports (production summary, WIP aging, BOM cost, quality, capacity, yield, MRP exceptions). The whole thing reduces to one idea: the
production_orderscollection carries the planned/actual cost fields and quantities; accounting posts the GL movements between standard WIP→FG→COGS accounts, and dashboard/reports are just aggregations over those same numbers.
Source: BE src/modules/manufacturing/accounting, src/modules/manufacturing/dashboard, src/modules/manufacturing/reports · Admin src/modules/manufacturing/accounting, src/modules/manufacturing/dashboard, src/modules/manufacturing/reports
⚠️ Wiring status — read first. The accounting
ManufacturingJournalServicedefines six GL posting methods (material issuance, labor, overhead absorption, production completion, scrap, variance recognition), but only one —postScrapRecording— is actually called anywhere (fromproduction/scrap/scrap-record.service.ts). The other five exist with no caller: production completion does not auto-post a WIP→FG entry, material backflush does not auto-post a WIP debit, and there is no variance-recognition posting at completion. So the GL automation that ships is: scrap only. The WIP/variance dashboard (wipDashboard/costVarianceReport) and all reports read the production order's stored cost fields directly — they do not depend on those journal entries existing. See §4.
1. Purpose & scope
- Accounting (
accounting/): translate manufacturing events into double-entry GL journal entries against a fixed set of manufacturing accounts, by delegating to the finance journal engine. Plus two read-only dashboards (wipDashboard,costVarianceReport) computed from production-order cost fields. - Dashboard (
dashboard/): a singlemanufacturingDashboardquery returning headline KPIs (today/month output, WIP value, overdue orders, order-status counts) from the production-order collection. - Reports (
reports/): seven aggregation queries over production orders + quality inspections + mrp_results, surfaced in a tabbed admin reports page.
Explicitly does NOT:
- Own the production-order schema or the cost roll-up itself —
plannedTotalCost/actualTotalCostand the material/labor/overhead splits are set by the production module (BOM cost roll-up + operation/material recording). This module reads and posts, it does not compute the roll-up. - Implement standard-vs-actual cost capture per item (that is inventory costing — STANDARD/AVERAGE/FIFO/LIFO on stock issues). Manufacturing variance here is order-level
actual − planned, a different concept. - Post completion / material / labor / overhead / variance entries automatically (defined but unwired — see banner).
- Branch-scope the dashboards/reports — most aggregations are company-DB-wide with
deleted ≠ true(tenant isolation is at the DB level; see multi-tenancy).
2. Data model
There is no dedicated collection for any of the three sub-modules. They are stateless services over existing collections:
| Source collection | Read by | Key fields used |
|---|---|---|
production_orders |
all three | status, quantityPlanned/Completed/Scrapped, startDate/dueDate/completionDate, plannedMaterialCost/LaborCost/OverheadCost/TotalCost, actualMaterialCost/LaborCost/OverheadCost/TotalCost, embedded operations[] (plannedSetupHours/RunHours, actualSetupHours/RunHours, workCenterId) |
quality_inspections |
reports (qualitySummaryReport) |
quantityInspected/Passed/Failed, inspectionDate |
mrp_results |
reports (mrpExceptionReport) |
priority, isConverted, actionType, netRequirement, suggestedQuantity, suggestedDate, itemId |
items |
reports/dashboard ($lookup) |
name |
work_centers |
reports (capacityUtilizationReport) |
name |
finance accounts + account_transactions |
accounting (writes JE) | manufacturing GL accounts by name |
2.1 Production-order cost fields (the cost ledger — production/production.scheme.ts)
The whole cost model lives on the production order. Each has a planned (estimate) and actual (recorded) value, split three ways:
// production_orders — cost & quantity fields
quantityPlanned, quantityCompleted, quantityScrapped: number // (default 0)
plannedMaterialCost, plannedLaborCost, plannedOverheadCost, plannedTotalCost: number
actualMaterialCost, actualLaborCost, actualOverheadCost, actualTotalCost: number
status: ProductionOrderStatus // DRAFT | RELEASED | IN_PROGRESS | COMPLETED | CANCELLEDexport enum ProductionOrderStatus { DRAFT, RELEASED, IN_PROGRESS, COMPLETED, CANCELLED }
export enum ProductionOrderSource { MANUAL, SALES_ORDER, MRP }
totalWip/variance/variancePercentetc. are derived, never stored — computed in the resolver from these fields (see §4).
2.2 Manufacturing account & journal-entry types (accounting/account.types.ts)
The fixed set of manufacturing GL accounts and the entry types that move between them:
export enum ManufacturingAccountType {
WIP = "WIP",
RAW_MATERIALS_INVENTORY = "RAW_MATERIALS_INVENTORY",
FINISHED_GOODS_INVENTORY = "FINISHED_GOODS_INVENTORY",
MANUFACTURING_OVERHEAD = "MANUFACTURING_OVERHEAD",
OVERHEAD_APPLIED = "OVERHEAD_APPLIED",
MATERIAL_USAGE_VARIANCE = "MATERIAL_USAGE_VARIANCE",
LABOR_EFFICIENCY_VARIANCE = "LABOR_EFFICIENCY_VARIANCE",
OVERHEAD_VARIANCE = "OVERHEAD_VARIANCE",
SCRAP_EXPENSE = "SCRAP_EXPENSE",
SUBCONTRACTING_EXPENSE = "SUBCONTRACTING_EXPENSE",
}
export enum ManufacturingJournalEntryType {
MATERIAL_ISSUANCE, LABOR_RECORDING, OVERHEAD_ABSORPTION,
PRODUCTION_COMPLETION, SCRAP_RECORDING, VARIANCE_RECOGNITION,
}Account resolution is by name, not by ID config. ManufacturingJournalService maps each ManufacturingAccountType to a hard-coded GL account name (MANUFACTURING_ACCOUNT_NAMES, e.g. WIP → "Work-in-Progress (WIP)", FINISHED_GOODS_INVENTORY → "Finished Goods Inventory") and looks it up via AccountService.findOne({ accountName, companyId }). If the named account is not present in the company's chart of accounts, resolveAccountId logs a warning and returns null → the entry is skipped (production never blocked).
3. API surface
All resolvers are @ApGqlAuthorize() (JWT + access group). No mutations — every operation is a read query. No REST.
3.1 Accounting dashboards (accounting/accounting.resolver.ts)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
wipDashboard |
Query | — | WipDashboardResult {data: [WipDashboardItem], totalWipValue} |
JWT + access group |
costVarianceReport |
Query | — | CostVarianceResult {data: [CostVarianceItem]} |
JWT + access group |
WipDashboardItem: productionOrderId, orderNumber, status, materialCost, laborCost, overheadCost, totalWip. CostVarianceItem: productionOrderId, orderNumber, plannedCost, actualCost, variance, variancePercent.
The GL posting methods (
postMaterialIssuance…postVarianceRecognition) are service methods, not GraphQL operations — they are invoked internally (onlypostScrapRecordingactually is). There is nopostManufacturingJournalmutation.
3.2 Dashboard (dashboard/manufacturing-dashboard.resolver.ts)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
manufacturingDashboard |
Query | — | ManufacturingDashboardResult {productionOrdersByStatus, wipValue, overdueOrders, todayOutput, monthlyOutput} |
JWT + access group |
3.3 Reports (reports/manufacturing-report.resolver.ts)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
productionSummaryReport(startDate, endDate) |
Query | String!, String! |
ProductionSummaryResult |
JWT + access group |
wipAgingReport |
Query | — | WipAgingResult |
JWT |
bomCostReport(itemId?) |
Query | String |
BomCostResult |
JWT |
qualitySummaryReport(startDate, endDate) |
Query | String!, String! |
QualitySummaryResult |
JWT |
capacityUtilizationReport(startDate, endDate) |
Query | String!, String! |
CapacityUtilizationResult |
JWT |
yieldAnalysisReport(startDate, endDate) |
Query | String!, String! |
YieldAnalysisResult |
JWT |
mrpExceptionReport |
Query | — | MrpExceptionResult |
JWT |
Date args are strings parsed by DateUtils.startOfDayDate / endOfDayDate (timezone-aware day boundaries — see multi-tenancy timezone rule).
4. Business rules & calculations — the actual math
4.1 GL posting — ManufacturingJournalService (accounting/journal.service.ts)
Each posting method builds a ManufacturingJournalEntry (debit account type, credit account type, amount) and calls the private postEntry, which resolves both account IDs by name and delegates to JournalEntryService.addEntry with a balanced two-leg transaction (type: GENERAL, status: POSTED, ref MFG-<timestamp>, refId = productionOrderId):
| Method | Debit | Credit | Amount | Wired? |
|---|---|---|---|---|
postMaterialIssuance |
WIP | RAW_MATERIALS_INVENTORY | materialCost | ✗ no caller |
postLaborRecording |
WIP | MANUFACTURING_OVERHEAD | laborCost | ✗ no caller |
postOverheadAbsorption |
WIP | OVERHEAD_APPLIED | overheadCost | ✗ no caller |
postProductionCompletion |
FINISHED_GOODS_INVENTORY | WIP | totalCost | ✗ no caller |
postScrapRecording |
SCRAP_EXPENSE | WIP | scrapCost | ✅ called by ScrapRecordService.createScrapRecord |
postVarianceRecognition |
(see below) | (see below) | abs(variance) per type | ✗ no caller |
postEntry guards: returns false (skips) if amount === 0, or if either account name fails to resolve in the chart of accounts. Wrapped in try/catch — a failed JE logs an error and returns false; it never throws into the production flow.
Standard cost-flow these encode (textbook job-costing):
material issuance: DR WIP CR Raw Materials
labor recording: DR WIP CR Manufacturing Overhead (labor accrual)
overhead absorption: DR WIP CR Overhead Applied
production complete: DR Finished Goods CR WIP
scrap: DR Scrap Expense CR WIP ← the only one that fires
Variance recognition (postVarianceRecognition) — posts up to three independent entries (material / labor / overhead), each only if its variance ≠ 0. The sign decides direction (unfavorable = positive variance → expense; favorable = negative → credit back to WIP):
materialVariance > 0 (unfavorable): DR MATERIAL_USAGE_VARIANCE CR WIP amount = |variance|
materialVariance < 0 (favorable): DR WIP CR MATERIAL_USAGE_VARIANCE
(same pattern for labor → LABOR_EFFICIENCY_VARIANCE, overhead → OVERHEAD_VARIANCE)
4.2 Scrap posting flow (the one live GL path) — ScrapRecordService.createScrapRecord
1. create ScrapRecord row (production/scrap)
2. if model.productionOrderId:
order = productionOrderSvc.findById(...)
order.quantityScrapped += model.quantity // bump scrap qty on the order
3. if model.scrapCost && model.companyId:
try journalSvc.postScrapRecording(productionOrderId, scrapCost, companyId, recordedBy)
→ DR Scrap Expense, CR WIP, amount = scrapCost
catch → console.error (swallowed; scrap record still created)
So recording a scrap (a) increments the production order's quantityScrapped, and (b) if a scrapCost is given and the WIP+Scrap accounts exist, posts the scrap JE. The JE is best-effort.
4.3 WIP dashboard — wipDashboard (accounting.resolver.ts)
WIP value = sum of actual costs of orders that are still open (RELEASED or IN_PROGRESS):
orders = productionOrderSvc.find({ status: { $in: ["RELEASED","IN_PROGRESS"] } })
per order:
totalWip = actualMaterialCost + actualLaborCost + actualOverheadCost
totalWipValue = Σ totalWip
Note: this includes
RELEASEDorders, whereas the dashboard module'swipValue(§4.6) counts onlyIN_PROGRESS. The two WIP numbers use different filters and can differ.
4.4 Cost variance — costVarianceReport (accounting.resolver.ts)
Order-level variance for completed orders (actual vs. the original plan):
orders = productionOrderSvc.find({ status: "COMPLETED" })
per order:
planned = plannedTotalCost
actual = actualTotalCost
variance = actual − planned // + = over budget (unfavorable)
variancePercent = planned > 0 ? (variance / planned) × 100 : 0
This is the order-level cost variance (not the per-component material/labor/overhead variance that postVarianceRecognition would split). Admin colors positive (over) red, negative (under) green.
4.5 Manufacturing dashboard KPIs — manufacturingDashboard (dashboard/manufacturing-dashboard.service.ts)
Five aggregations over production_orders (all deleted ≠ true):
productionOrdersByStatus = group by status → count // all orders
wipValue = Σ actualTotalCost where status = IN_PROGRESS // (IN_PROGRESS only!)
overdueOrders = dueDate < now AND status ∉ {COMPLETED,CANCELLED}
→ $lookup item, daysOverdue = floor((now − dueDate)/86400000)
→ sort daysOverdue desc, limit 5
todayOutput = Σ quantityCompleted where completionDate ∈ [todayStart,todayEnd] AND status=COMPLETED
monthlyOutput = Σ quantityCompleted where completionDate ∈ [monthStart,monthEnd] AND status=COMPLETED
daysOverdue is computed in-aggregation; overdueOrders is capped at the top 5. Today/month boundaries via DateUtils.startOfDay/endOfDay and startOfMonth/endOfMonth.
4.6 Reports math (reports/manufacturing-report.service.ts)
All filter deleted ≠ true; date-ranged ones use DateUtils.startOfDayDate(startDate)…endOfDayDate(endDate).
productionSummaryReport(bycreatedAtin range): group by status →{count, totalQuantity = Σ quantityCompleted, totalCost = Σ actualTotalCost}; returnsordersByStatus[],totalQuantityProduced,totalCost.wipAgingReport(status IN_PROGRESS):ageInDays = (now − startDate)/86400000; bucket into0-7 / 8-14 / 15-30 / 30+ dayswith counts + order numbers per bucket.bomCostReport(itemId?): group orders byitemId→ sum planned costs:materialCost = Σ plannedMaterialCost, labor/overhead likewise,totalCost = material + labor + overhead; lookupsitem.name. (Uses planned, not actual.)qualitySummaryReport(overquality_inspectionsbyinspectionDate):totalInspected/Passed/Failed = Σ;passRate = passed/inspected × 100,failRate = failed/inspected × 100(2-dp). Empty range → all zeros. (This is the only consumer of quality inspection data.)capacityUtilizationReport(unwindoperations[]): perworkCenterId→plannedHours = Σ(plannedSetupHours + plannedRunHours),actualHours = Σ(actualSetupHours + actualRunHours),utilizationPercentage = actual/planned × 100; overallaverageUtilization = totalActual/totalPlanned × 100.yieldAnalysisReport(status COMPLETED or IN_PROGRESS, bycreatedAt): peritemId→totalPlanned = Σ quantityPlanned,totalGood = Σ quantityCompleted,totalScrap = Σ quantityScrapped;yieldPercentage = good/planned × 100,scrapPercentage = scrap/planned × 100; plus overall yield/scrap. (Scrap here = production-orderquantityScrapped, not qualityscrapQuantity— independent figures.)mrpExceptionReport(raw access tomrp_resultsviaproductionOrderModel.db.collection("mrp_results")): matchdeleted ≠ true, isConverted ≠ true, priority = "HIGH"→ lookup item → map to{itemId, itemName, exceptionType = actionType, message = "<actionType>: Net requirement of <netRequirement> units", suggestedAction = actionType, quantity = suggestedQuantity, date = suggestedDate.toISOString()}; limit 100. Surfaces unconverted HIGH-priority MRP results — but recall MRP currently never emits HIGH priority (always MEDIUM), so this report is effectively empty unless results are created with HIGH priority manually.
4.7 Side effects & transactionality
- Accounting: writes finance journal entries (only via the scrap path).
JournalEntryService.addEntryposts the balancedaccount_transactions. No Mongo transaction wraps the manufacturing side (the JE is best-effort, post-create). - Dashboard / Reports: read-only, zero side effects.
5. Permissions
- All resolvers
@ApGqlAuthorize()(JWT + access-group gated). No per-report CASL strings; no branch query (dashboards/reports are company-DB-wide). - The accounting GL posting carries no
@AuditMetaof its own — the audit trail of the resulting journal entry is whatever the finance journal module records. TheJournalEntryService.addEntrystampscreatedByfrom the passed user id. - Scrap, material, etc. mutations live in the production module and carry their own permissions/audit. See permissions, audit trail.
6. Flows
6.1 Scrap → GL (the live accounting flow)
- User records a scrap against a production order (production/scrap module) with
quantityand optionalscrapCost. ScrapRecordService.createScrapRecord→ creates the scrap row → incrementsproduction_orders.quantityScrapped.- If
scrapCost+companyIdpresent →ManufacturingJournalService.postScrapRecording→ resolve "Scrap Expense" + "Work-in-Progress (WIP)" accounts by name →JournalEntryService.addEntrypostsDR Scrap Expense / CR WIP. - Unhappy paths: missing GL accounts → JE skipped with a warning (scrap row still created); JE throws → caught, logged, swallowed.
6.2 View WIP / variance dashboard
- Admin → Manufacturing › Manufacturing Accounting → WIP tab →
wipDashboardquery → table of open orders (material/labor/overhead/total WIP) + total WIP banner. - Switch to Cost Variance tab →
costVarianceReport→ completed orders with planned vs actual vs variance % (red = over, green = under).
6.3 KPI dashboard / run a report
- Admin → Manufacturing › Dashboard →
manufacturingDashboardon mount → KPI tiles + status chips + overdue table. - Admin → Manufacturing › Reports → pick a report type tab → (for date-ranged reports) pick start/end → "Run Report" → the matching query runs → results render per report shape.
7. Admin UI
7.1 Manufacturing Accounting (zerp-admin/src/modules/manufacturing/accounting)
page.tsx: AntTabs(WIP Dashboard / Cost Variance).fetchWipon mount; tab change triggers the matching fetch.components/wip-dashboard.tsx: total-WIP banner +ApTable(Order #, StatusTag, Material, Labor, Overhead, Total WIP — all 2-dp).components/variance-report.tsx:ApTable(Order #, Planned, Actual, VarianceTagred/green, Variance %).context.tsx(useManufacturingAccountingState):wipItems/totalWipValue/varianceItems+fetchWip/fetchVariance(lazy, no-cache).gql/query.tsdefinesWIP_DASHBOARD/COST_VARIANCE_REPORT. Read-only — no mutations.
7.2 Dashboard (zerp-admin/src/modules/manufacturing/dashboard)
page.tsx(ManufacturingDashboardPage): 4 KPI tiles (Today's Output, Monthly Output, WIP Value, Overdue count) + Production-Orders-by-Status chips + Overdue OrdersApTable.fetchDashboardon mount.gql/query.ts:manufacturingDashboard(lazy, no-cache). Context exposesdata/loading/fetchDashboard.
7.3 Reports (zerp-admin/src/modules/manufacturing/reports)
page.tsx(ManufacturingReportsPage): seven report-type buttons (production / wip / bom-cost / quality / capacity / yield / mrp-exceptions). Date pickers shown only for the four date-ranged reports (needsDates = ['production','quality','capacity','yield']). "Run Report" disabled until dates set (when required).renderReport()switches the layout per active report (KPI cards / bucket cards /ApTables).context.tsx(useManufacturingReportsState):activeReport,reportData,dateRange,runReport()(switch dispatches to the matching lazy query). Changing report clearsreportData.gql/query.tsdefines all seven queries. Dates passed asString(timestamp).model.ts: per-report interfaces + a wideTReportDataunion (all fields optional, keyed by active report).
UI note: the reports admin is the only place the date-ranged reports are reachable; the WIP/variance dashboards live under the Accounting page; the KPI dashboard is standalone. There is no admin surface that triggers the GL posting methods — those fire only as a side effect of recording scrap in the production module.
8. Dependencies & integrations
- Production module (
../production): the cost ledger. Accounting/dashboard/reports all readproduction_orders; accounting is invoked fromproduction/scrap.ManufacturingJournalServiceis exported from the accounting module and imported by production's scrap service. - Finance modules (
src/modules/finance/...): accounting depends onJournalEntryService(finance/journal) to post entries andAccountService(finance/account) to resolve GL accounts by name. See finance. - Quality module (
./quality.md): reports imports theQualityInspectionschema forqualitySummaryReport. - MRP module (
./mrp.md):mrpExceptionReportreadsmrp_resultsdirectly via the raw collection handle. - Item / Work-center collections:
$lookup-joined for names. DateUtils(src/core/utils/date): timezone-aware day/month boundaries.- No cron, no events, no external services.
9. Gotchas & project-specific rules
- Only scrap posts to the GL. Five of six
ManufacturingJournalServicemethods have no caller. Completion does not auto-move WIP→FG; material/labor/overhead are not auto-debited to WIP; variances are not auto-recognized. If you need full job-cost GL automation, wire those methods at production completion/material/labor recording. - GL accounts are resolved by hard-coded English names (e.g.
"Work-in-Progress (WIP)"), not by config IDs. The chart of accounts must contain accounts with exactly those names per company, or the entry silently skips. This is brittle across locales/renames. - Two different WIP numbers.
wipDashboard.totalWipValuesums actual costs of RELEASED + IN_PROGRESS orders;manufacturingDashboard.wipValuesumsactualTotalCostof IN_PROGRESS only. They will disagree when released-but-not-started orders carry cost. - Two different scrap figures.
yieldAnalysisReport.totalScrap= production-orderquantityScrapped;qualitymodule'sscrapQuantityis separate and not aggregated here. Don't conflate them. - Variance has two meanings.
costVarianceReport= order-levelactualTotal − plannedTotal.postVarianceRecognition= per-component material/labor/overhead variance JEs (unwired). Neither is the inventory per-item PPV from costing. bomCostReportuses planned costs, whilewipDashboard/costVarianceReport/productionSummaryReportuse actual. Mixing planned and actual across reports is intentional but easy to misread.mrpExceptionReportfilterspriority = "HIGH", but MRP only ever emitsMEDIUMpriority (see mrp §9) — so this report is effectively empty in practice.- Date args are strings of unix timestamps (admin passes
String(timestamp)), parsed throughDateUtilsday-boundary helpers. Production summary/yield match oncreatedAt; WIP aging onstartDate; quality oninspectionDate— each report keys off a different date field. - Best-effort GL. The scrap JE is caught/swallowed; a failed posting does not roll back the scrap record or the
quantityScrappedbump (no shared transaction). - Company-DB-wide, not branch-scoped. Dashboards/reports aggregate across the whole tenant DB; there is no
branchIdfilter.
Cross-links
- Production orders (cost ledger, roll-up, completion/scrap recording):
../manufacturing/production module (production/production.scheme.ts,production/scrap/) - Planning that creates production orders:
./mrp.md - Quality inspections feeding
qualitySummaryReport:./quality.md - Inventory per-item costing (STANDARD/AVERAGE/FIFO/LIFO, PPV):
../inventory/costing.md - Finance journal engine + chart of accounts the JE posts to:
../finance/ - Timezone day-range rule, tenant DBs:
../../platform/multi-tenancy.md - Permissions / audit:
../../platform/permissions-access.md,../../platform/audit-trail.md