The five undecided B-gaps owed since Phase 1: rework flow · co/by-products · multi-plant netting · currency basis · WIP physical count
Module: Manufacturing (Moon ERP) · Phases P0–P6 SHIPPED on feat/manufacturing · This document closes the P1 exit gate (spec-addenda track) ·
Source analysis §B5/B6/B11/B19/B20 · Each addendum: context → options → recommended v1 stance → schema/action sketch → target phase.
B5 Rework / Repair Order Flow & Costing IMPORTANT
The spec names order_type=Rework, routing_type=Repair and a rework_quantity field, but never defines the flow: how rework quantity spawns or links an order, how it re-enters routing, how its cost is treated, and the normal-vs-abnormal spoilage line.
1 Context — what the spec left unspecified
Spawn semantics: does failed/scrap quantity at a confirmation auto-create a rework order, or is rework always a manual order keyed back to the parent? No link field exists.
Re-entry into routing: a repaired unit usually re-enters at a specific operation (e.g. re-run Op 30 only), not the whole routing. There is no partial-routing entry concept.
Costing: rework adds labor/overhead (and sometimes material) on top of an already-costed unit. Is that extra cost capitalised into FG, charged to a rework variance, or expensed? Undefined.
Normal vs abnormal spoilage: normal (expected) spoilage is a product cost; abnormal spoilage is a period expense. The spec has scrap quantity on confirmations but no normal-loss threshold to split the two.
2 Decision options
deferOption A — Auto-spawn rework orders. On a confirmation with failed quantity, the system creates a linked child rework order automatically and re-pegs material. Powerful, but needs a parent/child link, partial-routing entry, and re-pegging — a large net-new surface.
v1Option B — Manual rework order + parent link + standard rail. Rework is a normal production order (order_type=Rework) that the user creates and links to a parent. It uses the existing Issue → Confirm → Receive → Close rail; cost accumulates in WIP exactly like any order. Minimal schema (one link column + one enum value).
laterOption C — Repair-in-place (no new order). Re-open the original order, add a repair confirmation against an existing operation, accumulate extra conversion into the same WIP. Cleanest accounting but requires re-opening closed orders and partial-routing re-entry — defer.
3 Recommended v1 stance
RECOMMEND — Option B: manual, linked rework order on the existing rail
A rework order is just a production order with order_type=Rework and a parent_order_id. It reuses every landed action — IssueMaterials, ConfirmOperation, ReceiveFinishedGoods, CloseProductionOrder — with their existing GL posting. No new accounting path. Rework conversion cost is capitalised into the reworked unit's FG cost (same WIP→FG flow already shipped). Normal-vs-abnormal spoilage is handled by the existing variance disposition policy (v1: expense to P&L, per B16): the un-recovered scrap value already lands in the period via the close-time variance, so abnormal spoilage is implicitly expensed without a new mechanism.
Documented v1 simplification (state loudly)
v1 does not auto-spawn rework from a failed confirmation, does not support partial-routing re-entry (rework runs its own short routing), and does not compute a separate normal-loss threshold — normal vs abnormal spoilage both flow through the existing variance-to-P&L policy. A dedicated normal-loss % and capitalise-vs-expense switch is a P7+ costing-precision item.
4 Schema / action impact sketch
// Production migration (additive, idempotent, sqlite-safe)
production_orders:
parent_order_id BIGINT NULL FK→production_orders.id // rework links to parent// order_type enum already includes 'Rework' (named in spec); no new enum migration if string-backed// No new Action. Reuses the landed rail:IssueMaterials → ConfirmOperation → ReceiveFinishedGoods → CloseProductionOrder// Rework conversion capitalises into FG via the existing WIP→FG posting.// FE: Orders screen shows a "Rework" chip + "Parent: PRD-…" link when parent_order_id set.
Touchpoint
Change
Risk
Schema
+1 nullable self-FK column on production_orders
Low additive
Actions
None new — reuse landed rail
None
GL
None new — existing WIP→FG + variance posting
None
FE
Rework chip + parent link on Orders screen
Low
5 Target phase
v1 — documented now, thin column in a future Production migration The parent_order_id column + Rework chip is a small follow-up (P7 backlog). The flow itself needs zero new code because it rides the shipped rail. Auto-spawn / partial-routing / normal-loss threshold → P7+ costing precision.
B6 Co-product vs By-product + Allocation Method IMPORTANT
P2/P3 shipped graded joint-products with NRV allocation only. The spec leaves open: by-product NRV-credit treatment, an allocation-method enum (market value / physical units / fixed ratio), and a BOM output-structure for secondary products.
1 Context — what the spec left unspecified
What shipped (P2/P3): a confirmation can yield a grade distribution (A/B/C); total cost is allocated across grades by NRV (grade.qty × grade.price ÷ Σ), higher-priced grade absorbs more cost. Single grade collapses to standard single-output.
Co-product vs by-product: all current outputs are treated as co-products (each carries allocated cost). A by-product is a minor incidental output that should instead credit the main output's cost at its net realisable value (NRV-credit), not absorb a share.
Allocation-method config: NRV is hard-wired. Real factories also use physical-units (split by quantity/weight), market-value (split by sales value at split-off), or a fixed ratio. No enum exists to choose.
BOM output structure: the BOM has a single primary output; there's no declared list of secondary co/by-product outputs with their expected ratios.
2 Decision options
v1Option A — Keep NRV grades; add a by-product NRV-credit flag + a method enum that defaults to NRV. Minimal: a per-line output_role enum{co_product,by_product} and a BOM/setting allocation_method enum{nrv,physical,market,fixed} defaulting to nrv (today's behaviour). By-product lines credit WIP at NRV instead of absorbing.
deferOption B — Full multi-output BOM structure now. Declare all secondary outputs with expected ratios on the BOM, validate yields against them, drive allocation from the declared structure. Correct long-term but a large BOM-schema change touching explosion + costing — too heavy for v1.
laterOption C — Leave as-is (NRV grades only). Cheapest, but by-products would wrongly absorb main-product cost and over/under-state FG — not acceptable for factories with real by-products.
3 Recommended v1 stance
RECOMMEND — Option A: by-product role flag + allocation-method enum (default NRV)
The landed NRV allocation stays the default and untouched. Add two small declarations:
allocation_method on the BOM (or a production setting) — enum {nrv, physical, market, fixed}, default nrv, so existing behaviour is byte-for-byte unchanged.
output_role on each receipt/grade line — enum {co_product, by_product}, default co_product. A by_product line does not absorb a cost share; instead its NRV credits WIP (reducing the main output's absorbed cost), then is received into stock at that NRV. This is the textbook by-product NRV-credit method and reuses the existing grade-line posting structure.
Documented v1 simplification (state loudly)
v1 implements only the nrv branch fully (the default) plus the by-product NRV-credit case. The physical/market/fixed enum values are declared but routed to the NRV implementation with a docblock until P7 wires their math — the enum exists so data captured now is forward-compatible. No expected-ratio validation on the BOM in v1.
4 Schema / action impact sketch
// Production migration (additive)
mfg_boms:
allocation_method ENUM('nrv','physical','market','fixed') DEFAULT 'nrv'
mfg_goods_receipt_lines:
output_role ENUM('co_product','by_product') DEFAULT 'co_product'
// Costing action (extends the SHIPPED NRV allocator — does not replace it):allocateJointCost(totalCost, lines, method='nrv'):
byproducts = lines.where(output_role='by_product')
coproducts = lines.where(output_role='co_product')
netCost = totalCost − Σ(byproducts.qty × byproducts.nrv) // NRV-creditswitch method:
'nrv' (default) → existing P2/P3 NRV split of netCost over coproducts
'physical'|'market'|'fixed' → // v1: fall through to NRV + Log::info (docblock)
Touchpoint
Change
Risk
Schema
+1 enum on mfg_boms, +1 enum on receipt lines (both defaulted)
Low additive, default = current behaviour
Costing
Extend landed NRV allocator with by-product credit + method switch
Output-role selector + method label on receipt/BOM
Low
5 Target phase
v1 partial — enum + by-product NRV-credit default-NRV enum and by-product credit are a self-contained costing follow-up (P7 costing precision). Full multi-output BOM structure with expected-ratio validation and the physical/market/fixed math → P8+.
B11 Multi-plant / Warehouse Netting IMPORTANT
The MPS carries a plant_id, but MRP (shipped P4) nets against a single on_hand. There is no warehouse dimension and no transfer-order sourcing — it must be reconciled with Moon's existing branch + warehouse model.
1 Context — what the spec left unspecified
What shipped (P4): MRP nets net = demand − supply − on_hand per product per bucket, with golden-fixture coverage and cross-bucket consumption. on_hand is a single company-wide figure — no per-warehouse split.
Moon already has the dimension: Inventory stock balances are per warehouse, and warehouses belong to branches. So a warehouse/branch netting dimension exists in the host — MRP just doesn't consume it yet.
Plant ambiguity: the spec's plant_id has no Moon equivalent. In Moon a "plant" maps most naturally to a branch (or a designated manufacturing warehouse), not a new entity.
Transfer sourcing: when warehouse A is short but warehouse B has surplus, real MRP raises an internal transfer suggestion rather than a buy/make. No transfer-order sourcing exists.
2 Decision options
v1Option A — Single planning scope = one branch's manufacturing warehouse; map "plant"→branch. MRP runs at a chosen branch_id and nets against that branch's manufacturing-warehouse on-hand. No cross-warehouse pooling, no transfer suggestions. Reuses existing branch scoping everywhere else in Moon.
deferOption B — Multi-warehouse pooled netting + transfer sourcing now. MRP nets across a configured set of warehouses and emits transfer-order suggestions for internal rebalancing. This is the full multi-plant model — needs sourcing rules, transfer-order generation, and per-warehouse time-phased supply. Too large for v1.
laterOption C — Keep company-wide single on_hand (today). Simplest, but over-states availability for multi-branch factories (counts stock in branches that can't feed this line) → wrong nets. Not acceptable as the documented end-state.
3 Recommended v1 stance
RECOMMEND — Option A: branch-scoped MRP, "plant" = branch, single source warehouse
Treat a "plant" as a branch (Moon's system-of-record for site separation, consistent with the B17 cost-center decision that Accounting/host owns the cross-cutting dimension). MRP accepts an optional branch_id scope and nets against that branch's designated manufacturing warehouse on-hand (falling back to company-wide when no branch given — preserving today's behaviour). No new plant entity, no transfer sourcing, no warehouse-pooling in v1. This aligns with how Inventory, Sales and the rest of the manufacturing rail already stamp and scope by branch.
Documented v1 simplification (state loudly)
v1 nets against one warehouse per planning run (the branch's manufacturing warehouse, or company-wide on no branch). It does not pool multiple warehouses, does not emit internal transfer-order suggestions, and introduces no separate plant entity — "plant" is an alias for "branch". Multi-warehouse pooled netting + transfer sourcing is explicitly P8+ (pairs with time-phased supply already deferred in P4).
4 Schema / action impact sketch
// No new schema — reuse Inventory warehouses + branches.// ComputeMrp / netting engine gains an optional scope:RunMrp(branch_id?):
warehouse = branch_id
? branch.manufacturing_warehouse // designated mfg WH for the branch
: null // null → company-wide on_hand (today's path)
onHand = StockService.balance(product, warehouse) // per-WH when scoped
net = demand − supply − onHand // unchanged formula// production setting: production.default_planning_branch_id (nullable)// FE: MRP screen gains a branch filter (reuses BranchContext pattern).
v1 — branch scope as a thin engine option (backward-compatible) Branch-scoped netting is a P7-backlog enhancement to the shipped MRP engine (null branch = current behaviour, zero regression). Pooled multi-warehouse netting + transfer-order sourcing → P8+ (with time-phased supply).
B19 Multi-currency Costing Basis NICE-TO-HAVE
Material price currency basis is undefined. Moon's CreateJournalEntry already handles FX, but standard costs should be declared base-currency-only.
1 Context — what the spec left unspecified
What shipped: the standard-cost roll-up (P3) computes std_material/labor/overhead/total and all GL goes through CreateJournalEntry, which resolves the base currency via currencies.is_base and posts FX on a line's currency_id (nullable → base).
Ambiguity: if a material's purchase price is captured in a foreign currency, in what currency is the standard cost declared and rolled up? Mixing currencies inside a single std-cost build-up would corrupt the roll-up.
Industry norm: standard costs are declared in one currency — the company base currency — and FX is handled only at actual posting time (purchase price variance absorbs the rate movement). This is exactly what Moon's GL already does.
2 Decision options
v1Option A — Standard costs are base-currency-only; FX handled at actual posting. The roll-up assumes every component cost is expressed in (or converted to) the company base currency at build time. Foreign-currency purchase prices are converted to base at the prevailing rate when the standard is set; runtime FX variance is absorbed by GL as today.
deferOption B — Per-currency standard costs. Maintain standards in multiple currencies with explicit revaluation. Heavyweight, rarely used, and conflicts with the single-figure roll-up — defer indefinitely.
3 Recommended v1 stance
RECOMMEND — Option A: standard cost is base-currency-only (declare loudly)
Declare that all standard costs are expressed in the company base currency (currencies.is_base). When a material's source price is in a foreign currency, it is converted to base at standard-set time using the host exchange rate; the roll-up never mixes currencies. Runtime FX differences are not a costing concern — they land as a normal purchase/price variance through the already-shipped CreateJournalEntry FX path. This matches P3's roll-up exactly and needs no schema and no new action — it is a stated policy plus a docblock on the roll-up.
Documented v1 simplification (state loudly)
v1 holds no per-currency standard cost and performs no standard-cost revaluation on rate movement. The base-currency figure is authoritative; rate drift is expensed/absorbed via GL variance, not re-standardised. Per-currency standards and scheduled revaluation are out of scope (P8+ / likely never for v1 factories).
4 Schema / action impact sketch
// No schema change. Policy + guard only.// Standard-cost roll-up (SHIPPED P3) gains a docblock + optional guard:rollUpStandardCost(product):
/** v1: standard costs are BASE-CURRENCY ONLY (currencies.is_base). Foreign source prices are converted to base at set-time via the host rate. Runtime FX → purchase/price variance through CreateJournalEntry. */
base = Currency.where(is_base=true).first()
// component costs assumed already in base; convert at capture if sourced FX// CreateJournalEntry already resolves is_base + posts FX per line.currency_id (unchanged).
None — existing CreateJournalEntry FX path unchanged
None
FE
Show "(base currency)" label on standard-cost screens
Low
5 Target phase
v1 — policy ratified now (no code beyond a docblock/label) This is a documentation-grade decision; the shipped P3 roll-up and GL already behave this way. Per-currency standards + revaluation → deferred indefinitely (P8+).
B20 WIP Physical Count Procedure NICE-TO-HAVE
No period-end WIP inventory / count procedure is defined. WIP is currently a purely perpetual balance accumulated by issues and relieved by receipts; there is no way to physically count shop-floor WIP and reconcile a difference.
1 Context — what the spec left unspecified
What shipped: WIP is a derived/perpetual figure — IssueMaterials + ConfirmOperation debit WIP, ReceiveFinishedGoods/CloseProductionOrder relieve it. The book WIP balance per order is always computable, but never verified against the floor.
The gap: at period-end, finance wants to confirm that the GL WIP control account equals the physical work-in-process on the floor (partial assemblies, issued-but-unconsumed material, in-progress operations). Real WIP shrinks (scrap not booked, miscounts) so a count + adjustment is standard.
No count document: Inventory has stock counts for finished/raw stock, but WIP lives on production orders, not in a countable warehouse location — so the existing stock-count flow doesn't reach it.
2 Decision options
v1Option A — Reconciliation report only (book WIP per open order), no count document. Provide a period-end "Open-order WIP" report: per order, the accumulated WIP (issued material + confirmed conversion − received FG), totalled to the GL WIP control. Finance eyeballs and, if needed, books a manual JE. Cheapest; reuses everything shipped.
laterOption B — WIP count document + auto-adjustment JE. A countable WIP snapshot per order/operation, user enters physical findings, system books a WIP write-down/up JE for the variance. Proper, but net-new document + GL action.
deferOption C — Treat WIP as a virtual warehouse location and reuse Inventory stock count. Elegant reuse, but forces WIP into the Inventory schema (the exact thing every phase avoided — "zero Inventory schema risk"). Reject for v1.
3 Recommended v1 stance
RECOMMEND — Option A: a read-only period-end WIP reconciliation report
v1 ships a WIP reconciliation report: for every open production order, compute book WIP = (issued material value + confirmed labor/overhead) − (FG received value), list per order, and total it against the GL WIP control account balance. Any difference is surfaced for finance to investigate and adjust via a normal manual journal entry. This is fully derivable from already-shipped data (issue/confirm/receipt postings) — no schema, no new GL action — and gives finance the period-end visibility they need. A formal count-and-auto-adjust document is the next step, not v1.
Documented v1 simplification (state loudly)
v1 provides visibility, not enforcement: it reports book WIP vs the GL control account but does not capture a physical count and does not auto-post a WIP adjustment JE. Reconciling a real difference is a manual journal entry by finance. The countable WIP document + automatic write-down/up posting is a later phase. WIP is never pushed into the Inventory schema (preserves the zero-Inventory-risk rule held across P0–P6).
4 Schema / action impact sketch
// No schema, no new posting action. A read-only aggregator + endpoint:GET production/reports/wip-reconciliation?as_of=YYYY-MM-DD
per open order:
bookWip = Σ(issue.value) + Σ(confirmation.labor+overhead) − Σ(receipt.value)
report:
rows[] = { order, product, bookWip, status }
total = Σ bookWip
glControlBalance = Account(production.wip_account_id).balanceAsOf(as_of)
difference = total − glControlBalance // finance investigates// perm: production.costing · FE: "WIP Reconciliation" report screen (read-only table + total).
Touchpoint
Change
Risk
Schema
None
None
Reporting
+1 read-only aggregator endpoint (book WIP vs GL control)
Low read-only
GL
None auto-posted; manual JE by finance for differences
None
FE
"WIP Reconciliation" report screen
Low
5 Target phase
v1 — read-only reconciliation report The aggregator fits the existing reporting pattern (like the variance / WIP-aging reports flagged in B24) and is a clean P7-backlog item. Count document + auto-adjustment JE → P8+.
Closing Summary
Each gap resolves to a thin, additive, backward-compatible v1 stance that reuses landed P0–P6 rails. Nothing here forces a new GL path, an Inventory-schema change, or a re-architecture.
Gap
Title
Severity
RECOMMENDED v1 stance
New schema?
New GL?
Target phase
B5
Rework / repair flow
Important
Manual rework order + parent_order_id, rides the shipped Issue→Confirm→Receive→Close rail; spoilage via existing variance-to-P&L
+1 nullable FK
No
P7 backlog
B6
Co/by-product + method
Important
By-product NRV-credit flag + allocation_method enum defaulting to NRV (current behaviour); non-NRV methods declared, routed to NRV until later
+2 enums (defaulted)
No
P7 costing
B11
Multi-plant netting
Important
"Plant" = branch; optional branch_id scope nets one mfg warehouse; null = today's company-wide path; no pooling / transfers
None (+setting)
No
P7 / P8 pooling
B19
Currency basis
Nice
Standard costs are base-currency-only; FX absorbed as purchase/price variance through CreateJournalEntry (already behaves this way)
None
No
v1 policy now
B20
WIP physical count
Nice
Read-only period-end WIP reconciliation report (book WIP vs GL control); manual JE for differences; no Inventory-schema touch
None
No
P7 report / P8 count doc
P1 exit gate — CLOSED
With these five one-pagers signed off, every Direction-B gap named without a decision row now has a ratified v1 stance. The common thread: document the simplification loudly, reuse the shipped rail, defer anything needing a new schema dimension to a named phase. This completes the spec-addenda track that gated Phase-1 exit.
Manufacturing B-Gap Addenda · Moon ERP · branch feat/manufacturing · P0–P6 SHIPPED ·
Closes the P1 exit gate (analysis §B5/B6/B11/B19/B20). Stances are v1 recommendations consistent with the landed rails — not yet implemented code.