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_orders collection 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 ManufacturingJournalService defines six GL posting methods (material issuance, labor, overhead absorption, production completion, scrap, variance recognition), but only one — postScrapRecording — is actually called anywhere (from production/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 single manufacturingDashboard query 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 / actualTotalCost and 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 | CANCELLED
export enum ProductionOrderStatus { DRAFT, RELEASED, IN_PROGRESS, COMPLETED, CANCELLED }
export enum ProductionOrderSource  { MANUAL, SALES_ORDER, MRP }

totalWip / variance / variancePercent etc. 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 (postMaterialIssuancepostVarianceRecognition) are service methods, not GraphQL operations — they are invoked internally (only postScrapRecording actually is). There is no postManufacturingJournal mutation.

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 RELEASED orders, whereas the dashboard module's wipValue (§4.6) counts only IN_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 (by createdAt in range): group by status → {count, totalQuantity = Σ quantityCompleted, totalCost = Σ actualTotalCost}; returns ordersByStatus[], totalQuantityProduced, totalCost.
  • wipAgingReport (status IN_PROGRESS): ageInDays = (now − startDate)/86400000; bucket into 0-7 / 8-14 / 15-30 / 30+ days with counts + order numbers per bucket.
  • bomCostReport(itemId?): group orders by itemId → sum planned costs: materialCost = Σ plannedMaterialCost, labor/overhead likewise, totalCost = material + labor + overhead; lookups item.name. (Uses planned, not actual.)
  • qualitySummaryReport (over quality_inspections by inspectionDate): 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 (unwind operations[]): per workCenterIdplannedHours = Σ(plannedSetupHours + plannedRunHours), actualHours = Σ(actualSetupHours + actualRunHours), utilizationPercentage = actual/planned × 100; overall averageUtilization = totalActual/totalPlanned × 100.
  • yieldAnalysisReport (status COMPLETED or IN_PROGRESS, by createdAt): per itemIdtotalPlanned = Σ quantityPlanned, totalGood = Σ quantityCompleted, totalScrap = Σ quantityScrapped; yieldPercentage = good/planned × 100, scrapPercentage = scrap/planned × 100; plus overall yield/scrap. (Scrap here = production-order quantityScrapped, not quality scrapQuantity — independent figures.)
  • mrpExceptionReport (raw access to mrp_results via productionOrderModel.db.collection("mrp_results")): match deleted ≠ 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.addEntry posts the balanced account_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 @AuditMeta of its own — the audit trail of the resulting journal entry is whatever the finance journal module records. The JournalEntryService.addEntry stamps createdBy from 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)

  1. User records a scrap against a production order (production/scrap module) with quantity and optional scrapCost.
  2. ScrapRecordService.createScrapRecord → creates the scrap row → increments production_orders.quantityScrapped.
  3. If scrapCost + companyId present → ManufacturingJournalService.postScrapRecording → resolve "Scrap Expense" + "Work-in-Progress (WIP)" accounts by name → JournalEntryService.addEntry posts DR Scrap Expense / CR WIP.
  4. 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

  1. Admin → Manufacturing › Manufacturing Accounting → WIP tab → wipDashboard query → table of open orders (material/labor/overhead/total WIP) + total WIP banner.
  2. 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

  1. Admin → Manufacturing › DashboardmanufacturingDashboard on mount → KPI tiles + status chips + overdue table.
  2. 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: Ant Tabs (WIP Dashboard / Cost Variance). fetchWip on mount; tab change triggers the matching fetch.
  • components/wip-dashboard.tsx: total-WIP banner + ApTable (Order #, Status Tag, Material, Labor, Overhead, Total WIP — all 2-dp).
  • components/variance-report.tsx: ApTable (Order #, Planned, Actual, Variance Tag red/green, Variance %).
  • context.tsx (useManufacturingAccountingState): wipItems/totalWipValue/varianceItems + fetchWip/fetchVariance (lazy, no-cache). gql/query.ts defines WIP_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 Orders ApTable. fetchDashboard on mount.
  • gql/query.ts: manufacturingDashboard (lazy, no-cache). Context exposes data/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 clears reportData. gql/query.ts defines all seven queries. Dates passed as String(timestamp).
  • model.ts: per-report interfaces + a wide TReportData union (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 read production_orders; accounting is invoked from production/scrap. ManufacturingJournalService is exported from the accounting module and imported by production's scrap service.
  • Finance modules (src/modules/finance/...): accounting depends on JournalEntryService (finance/journal) to post entries and AccountService (finance/account) to resolve GL accounts by name. See finance.
  • Quality module (./quality.md): reports imports the QualityInspection schema for qualitySummaryReport.
  • MRP module (./mrp.md): mrpExceptionReport reads mrp_results directly 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 ManufacturingJournalService methods 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.totalWipValue sums actual costs of RELEASED + IN_PROGRESS orders; manufacturingDashboard.wipValue sums actualTotalCost of IN_PROGRESS only. They will disagree when released-but-not-started orders carry cost.
  • Two different scrap figures. yieldAnalysisReport.totalScrap = production-order quantityScrapped; quality module's scrapQuantity is separate and not aggregated here. Don't conflate them.
  • Variance has two meanings. costVarianceReport = order-level actualTotal − plannedTotal. postVarianceRecognition = per-component material/labor/overhead variance JEs (unwired). Neither is the inventory per-item PPV from costing.
  • bomCostReport uses planned costs, while wipDashboard/costVarianceReport/productionSummaryReport use actual. Mixing planned and actual across reports is intentional but easy to misread.
  • mrpExceptionReport filters priority = "HIGH", but MRP only ever emits MEDIUM priority (see mrp §9) — so this report is effectively empty in practice.
  • Date args are strings of unix timestamps (admin passes String(timestamp)), parsed through DateUtils day-boundary helpers. Production summary/yield match on createdAt; WIP aging on startDate; quality on inspectionDate — 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 quantityScrapped bump (no shared transaction).
  • Company-DB-wide, not branch-scoped. Dashboards/reports aggregate across the whole tenant DB; there is no branchId filter.