dodil/erp-method

modules/manufacturing/module.yaml

# Manufacturing (lean): the module source.
#
# PROVENANCE. Written 2026-10-25 in the wave-3 spec pass (erp-method-w3, spec/wave3-v1). Version 1 SIGNED
# by seemo 2026-10-25 (signoff/manufacturing/; wave 3). Written from:
#   - inventory v5, read first so this module keeps no stock: inventory owns stock, lots, moves, valuation (average or
#     standard per company, inv_standard_price dated per company and product, INV-48), the account map and lot trace
#     (GET /lots/{product_id}/{lot_code}/trace); manufacturing owns only what turns some products into others
#   - product v5, read first so this module keeps no item master: product owns the item, its units and the production
#     unit role (PRD-29), the site data (procurement_type make and planning_method mrp are RESERVED until M7 there), and
#     rounding (GET /products/{product_id}/round-proposal); manufacturing owns bills of materials, routings and work
#     centres
#   - gl v6 (journals, cost centres, periods); purchasing v5 (replenishment stays purchasing's and inventory's: this
#     module publishes dependent requirements and never raises a purchase request); sales-order v7 (demand)
#   - the motivating case: a coffee-shop chain that roasts its own beans. Green beans become roasted beans with a
#     weight loss of 14 to 18% (moisture and chaff); roasted beans are packed into bags and consumed in the shops
#   - IAS 2 Inventories: para 10-15 (cost of conversion, fixed overhead on normal capacity), para 14 (by-products at net
#     realisable value deducted from the main product's cost), para 16 (abnormal waste expensed), para 21 (standard cost
#     as a measurement technique); IFRS for SMEs section 13; Regulation (EC) No 178/2002 art. 18 (food traceability one
#     step back and one step forward)
#   - Odoo 17 mrp (mrp.bom, mrp.bom.line, mrp.bom.byproduct, mrp.routing.workcenter, mrp.workcenter, mrp.production,
#     mrp.workorder) as the migration source; SAP S/4HANA PP and PP-PI (process orders) as a parity reference only
#
# THE DEPENDENCY DIRECTION, decided here and written in pos too:
#   - pos depends on manufacturing, OPTIONALLY. A drink's recipe is a bill of materials of usage sale here; pos reads it
#     through GET /boms/{product_id}/explode (MFG-30) and freezes the BOM version on its receipt line. Without
#     manufacturing, pos keeps its own recipe table (pos POS-15) and works the same.
#   - manufacturing never calls pos and never reads pos tables. The shops' consumption is demand for MRP-lite only
#     through pos's demand_history extension, when it exists; until then shop demand is entered as a forecast.
#
# The decisions a plant controller checks first:
#   - a production order freezes a copy of its BOM version and routing at release; later BOM changes never change it
#   - stock moves only through inventory's routes; every movement has its own move_ref, so an issue, its reversal and an
#     output receipt never collide
#   - output is valued at standard (or at the planned cost on an average-cost company) and the order's remaining WIP
#     balance at close is the variance, split into material usage (including yield) and activity, posted once
#   - normal weight loss is part of the product's cost; scrap beyond it is expensed (IAS 2.16)

# Version 2 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 1 stays the signed one until it is signed):
#   - MFG-21 and its story examples follow gl v9's id rule: source_document '<order>:activity:<op>:<n>[:reverse]'
#     for a confirmation (as erp-brick-manufacturing 0.2.0 builds it), the order for its settlement; journal_id =
#     derived_id(source_document, event)
#   - PROCESS change (production_order): reverse_entry's effect names the output reversal route (inventory v7,
#     production_output_reversal); MFG-20 gains its seam row. No step is renamed or removed
#   - permissions: mfg:scrap:approve, held by the planner, approves scrap above scrap_approval_threshold (MFG-14);
#     the build borrowed mfg:order:release
#   - PROCESS change (the migration run, seemo's decided answer 4, pattern §11): load writes staging rows only; sign
#     is the first write to the ledger, through the normal use cases; discard deletes staging rows only; a signed
#     run is reversed, never deleted. Effects added; no step renamed or removed
#   - AI consent (pattern §4, decided answer 3): explain_variance is the owner's mode plus a role (mfg:order:close);
#     no per-person consent, it reads no one's personal data
#   - planning rules are per product and site rows (decided answer 6): mfg_planning_rule (lot_size_rule daily |
#     weekly | monthly, default weekly; production_days); make-or-buy read from product's site row; MFG-08's sales
#     lines sit in one ISO week

id: manufacturing
name: Production (bills of materials and production orders)
short: Production
version: 2
maturity: tested                     # version 1 signed by seemo 2026-10-25; erp-brick-manufacturing 0.2.0 builds it (slices A and B, tested, not deployed); the latest version is a draft for re-sign
summary: >-
  Make your own products: write down what goes into each one and how it is made, plan what to make from what is
  ordered and sold, run production orders on the shop floor (issue the ingredients, record the time and what came
  out, including what was lost or scrapped), trace every batch back to its ingredients and forward to its customers,
  and see what each batch really cost against its standard.

decisions:
  - proposed: "a production order copies its BOM version and routing at release; a BOM change after release never changes the order"
    status: "for sign-off"
  - proposed: "normal weight loss is planned as the BOM's yield (output base quantity per input), so it is part of the standard cost; anything below the planned yield is a material usage variance; scrap reported with a reason is expensed as abnormal loss"
    status: "for sign-off"
  - proposed: "the standard cost roll-up is proposed here and released into inventory's standard prices by a cost accountant who did not run it; inventory revalues"
    status: "for sign-off"
  - proposed: "MRP-lite proposes planned production orders only; component demand is published as dependent requirements for inventory's and purchasing's replenishment, never as purchase requests from here"
    status: "for sign-off"
  - proposed: "pos depends on manufacturing optionally (BOM usage sale as the drink's recipe); manufacturing never calls pos"
    status: "for sign-off"

facets:
  domain: operations
  industries: [food-and-beverage, light-manufacturing, any]
  customer_words:
    - "we roast our own beans"
    - "bill of materials"
    - "recipe"
    - "production orders"
    - "works orders"
    - "batch"
    - "yield"
    - "weight loss"
    - "what did this batch cost"
    - "trace a batch"
    - "recall"
    - "MRP"
  replaces: [spreadsheets, Odoo Manufacturing, SAP PP, Katana, MRPeasy]
  pillars: [sql, graph]                # graph: batch genealogy and BOM where-used
  first_module_fit: low
  depends_on:
    - module: product
      why: every BOM line and every output is a product with its units; the production unit role and the site's planning data are product's
      required: true
    - module: inventory
      why: components leave stock and output enters it through inventory, which owns lots and valuation; without it there is nothing to issue or receive
      required: true
    - module: gl
      why: activity costs, WIP and variances post to the ledger; the work centre's cost centre is gl's
      required: recommended
    - module: sales-order
      why: open order lines are the demand MRP-lite plans against
      required: optional
  feeds: [inventory, pos, gl]
  owns_master_data: [bom, routing, work_centre]
  references_master_data: [product, unit, warehouse, location, lot, standard_price, cost_centre, company]

actors:
  - id: process_engineer
    name: Process engineer / head roaster
    does: writes bills of materials and their versions, routings and work centres, the planned yield
    sees: every BOM, routing and work centre
    permissions: [mfg:bom:read, mfg:bom:write, mfg:bom:release, mfg:routing:write, mfg:work_centre:write, mfg:order:read]
  - id: planner
    name: Production planner
    does: runs MRP-lite, firms planned orders, creates and releases production orders, enters forecasts
    sees: every plan and order
    permissions: [mfg:bom:read, mfg:plan:run, mfg:plan:firm, mfg:forecast:write, mfg:order:read, mfg:order:create, mfg:order:release, mfg:order:cancel, mfg:scrap:approve]
  - id: operator
    name: Roaster / production operator
    does: issues components, records operation times, reports output, by-products and scrap with a reason, reverses their own wrong entries the same day
    sees: released orders of their work centres
    permissions: [mfg:bom:read, mfg:order:read, mfg:order:issue, mfg:order:confirm, mfg:order:report_output, mfg:order:scrap, mfg:order:reverse]
  - id: cost_accountant
    name: Cost accountant
    does: runs and approves cost roll-ups, releases standard costs to inventory, reviews and closes orders, explains variances
    sees: every order's cost
    permissions: [mfg:bom:read, mfg:order:read, mfg:cost:run, mfg:cost:approve, mfg:order:complete, mfg:order:close, mfg:report:read, mfg:migration:read]
    note: "never approves a roll-up they ran, nor closes an order whose variance approval they asked for: email AND sub"
  - id: quality
    name: Quality / food safety lead
    does: blocks a batch, runs a trace back to the ingredients and forward to the customers and shops
    sees: every lot's genealogy
    permissions: [mfg:bom:read, mfg:order:read, mfg:trace:read]
  - id: owner
    name: The owner (or whoever the owner names)
    does: holds the controls (over-issue tolerance, release with shortages, variance approval threshold, AI step modes), signs the migration reconciliation
    sees: everything
    permissions: [mfg:order:read, mfg:report:read, mfg:settings:controls, mfg:migration:read, mfg:migration:sign]
    note: "anyone else holding mfg:settings:controls is a configuration error the access test refuses"
  - id: system
    name: The system
    does: runs MRP-lite on request or from the worker, explodes BOMs for pos and the planner, sends issues and receipts to inventory and postings to gl from pending rows, marks plans, roll-ups and unreleased orders stale, derives genealogy, loads an Odoo migration
    sees: everything, through its own service account
    permissions: [mfg:bom:explode, mfg:plan:read_requirements, mfg:plan:derive, mfg:outbox:dispatch, mfg:genealogy:derive, mfg:number_series:claim, mfg:migration:load, mfg:migration:reconcile]
    note: "exact grants to manufacturing's service client, never a wildcard; mfg:bom:explode is also granted to pos's client and mfg:plan:read_requirements to inventory's; the client holds product:product:read, inv:availability:read, inv:valuation:read, inv:stock:read, gl:journal:post, gl:cost_centre:read, gl:period:read"

stories:
  # ── master data ──────────────────────────────────────────────────────────────────────────
  - id: MFG-01
    kind: person
    actor: process_engineer
    story: As the head roaster, I want each work centre written down with its capacity and what an hour costs, so that orders are planned on real machines and costed at real rates.
    given: 'gl company UK01, cost centre ROASTERY; inventory warehouse ROASTERY with locations ROASTERY/RAW, ROASTERY/WIP, ROASTERY/FG'
    when: 'I create work centre ROASTER-1 (batch capacity 15.000 KGM, 8 hours a day, machine rate 45.0000 GBP an hour, labour rate 22.5000 GBP an hour, cost centre ROASTERY) and PACK-1 (labour 22.5000 an hour)'
    then: >-
      work_centre_id is the customer's code, fixed once an order uses it; rates are NUMERIC(19,4) per hour, dated rows
      (a new rate from a later date, never edited in place); the cost centre must exist in gl (GET /cost-centres/{cost_centre});
      a rate change marks every cost roll-up using it stale (MFG-18)
    screen: Work centres
    reference: not built
  - id: MFG-02
    kind: person
    actor: process_engineer
    story: As the head roaster, I want a roast written as a bill of materials with its planned yield, so that 100 kg of green beans is planned to give 84 kg of roasted beans and the weight loss is costed into the product.
    given: 'products GREEN-ETH (base unit KGM, lot-tracked, purchased), ROAST-ETH (KGM, lot-tracked, made), CHAFF (KGM, by-product, net realisable value 0.00)'
    when: 'I write BOM ROAST-ETH usage production version 1, valid from 2026-11-16: output base quantity 84.000 KGM; component GREEN-ETH 100.000 KGM; by-product CHAFF 1.200 KGM; and release it'
    then: >-
      the planned yield is 84.000 / 100.000 = 84.00%, shown, never typed separately; quantities are NUMERIC(19,4) in the
      product's production unit and convert exactly to its base unit (product GET /products/{product_id}/convert) or are
      refused; a component that is the output itself, directly or through another BOM, is refused (a cycle, found by a graph
      walk); valid_from is after today; a released version is never edited: a change is version 2 with its own valid_from,
      and two versions of one BOM never overlap in validity
    screen: Bills of materials
    reference: not built
  - id: MFG-03
    kind: person
    actor: process_engineer
    story: As the head roaster, I want the steps to make a product (roast, cool, pack) written as a routing on work centres, so that time and cost are planned per batch.
    given: 'BOM ROAST-ETH v1; work centre ROASTER-1 (15.000 KGM a batch)'
    when: 'I write routing ROAST-ETH v1: op 10 roast on ROASTER-1, setup 10 minutes, run 20 minutes per batch of 15.000 KGM input; op 20 cool and degas, 12 hours waiting, no work centre time'
    then: >-
      for 100.000 KGM input the plan is ceil(100 / 15) = 7 batches, 10 + 7 x 20 = 150 minutes on ROASTER-1, costed
      2.5 h x (45.0000 + 22.5000) = 168.75 GBP; waiting time plans the date but costs nothing; the routing is versioned
      and dated like the BOM
    screen: Bills of materials
    reference: not built
  - id: MFG-04
    kind: person
    actor: process_engineer
    story: As the head roaster, I want the packing of roasted beans into retail bags as its own BOM, so that a bag's cost and batch follow from the roast it came from.
    given: 'ROAST-ETH; products BAG-ETH-250 (C62), POUCH-250 (C62), LABEL-ETH (C62)'
    when: 'I write BOM BAG-ETH-250 usage production v1: output 1 C62; components ROAST-ETH 0.250 KGM, POUCH-250 1 C62, LABEL-ETH 1 C62; routing op 10 pack on PACK-1, run 1 minute per bag'
    then: >-
      the BOM is a second level under ROAST-ETH; MRP-lite explodes BAG-ETH-250 demand into ROAST-ETH demand and then GREEN-ETH
      dependent requirements; the where-used of GREEN-ETH lists ROAST-ETH and BAG-ETH-250 (graph walk up)
    screen: Bills of materials
    reference: not built
  - id: MFG-05
    kind: person
    actor: process_engineer
    story: As the head roaster, I want a drink's recipe written as a BOM of usage sale, so that the shops' tills take the right beans, milk and cup out of stock without anyone keeping a second recipe.
    given: 'pos installed with recipe_source manufacturing; products LATTE-REG (sold, not stocked), ROAST-HOUSE, MILK-WHOLE, MILK-OAT, CUP-12OZ, LID-12OZ; modifier MOD-OAT'
    when: 'I write BOM LATTE-REG usage sale v1 valid from 2026-12-01: output 1 C62; ROAST-HOUSE 0.018 KGM, MILK-WHOLE 0.250 LTR, CUP-12OZ 1, LID-12OZ 1; modifier MOD-OAT replaces MILK-WHOLE with MILK-OAT 0.250 LTR'
    then: >-
      a usage-sale BOM is never produced: it has no routing and no production order, and is exploded at sale by pos
      (MFG-30); version and validity work as for production BOMs; pos's own recipe table becomes read-only once
      recipe_source is manufacturing
    screen: Bills of materials
    reference: not built
  # ── costing ──────────────────────────────────────────────────────────────────────────────
  - id: MFG-06
    kind: person
    actor: cost_accountant
    story: As the cost accountant, I want a standard cost rolled up from the BOM, the routing and the components' standard prices, so that a roasted kilo has a cost before the first order is closed.
    given: 'UK01 on standard cost (inventory INV-48); GREEN-ETH standard 6.2000 GBP per KGM from 2026-11-01; BOM ROAST-ETH v1 (100.000 in, 84.000 out, CHAFF at 0.00); routing 168.75 for one batch run'
    when: 'I run cost roll-up CR-2026-12 for UK01, effective 2026-12-01'
    then: >-
      ROAST-ETH = (100.000 x 6.2000 + 168.75 - 0.00 by-product value) / 84.000 = 788.75 / 84 = 9.3899 GBP per KGM (NUMERIC 19,4,
      divided once at the end), split material 7.3810 and conversion 2.0089 per KGM for variance analysis; BAG-ETH-250 rolls up
      from ROAST-ETH at 9.3899 x 0.250 plus its components and packing; a component with no standard price stops the run
      with the product named; the result is a proposal (status computed) until approved and released (MFG-07)
    screen: Costing
    reference: not built
    customer_varies: whether fixed overhead is absorbed in the work centre rates (IAS 2.13 on normal capacity) or expensed
  - id: MFG-07
    kind: person
    actor: cost_accountant
    story: As the cost accountant, I want a second person to approve the roll-up before it becomes inventory's standard price, so that one mistyped rate does not revalue the stock.
    given: 'CR-2026-12 computed by cost accountant A (sub a1)'
    when: 'A tries to approve it under a second email; cost accountant B approves it and releases it'
    then: >-
      A is refused (gate second_person); B's approval releases each product's standard to inventory as a dated standard
      price from 2026-12-01 (inventory's POST /standard-prices, INV-60), and inventory revalues stock on hand on that date
      (its INV-48); a roll-up that became stale before release (MFG-18) cannot be released; a released roll-up is never
      changed: the next is a new run
    screen: Costing
    reference: not built
  # ── planning ─────────────────────────────────────────────────────────────────────────────
  - id: MFG-08
    kind: person
    actor: planner
    story: As the planner, I want planned production orders proposed from what is ordered, what the shops will use and what is in stock, so that I firm a plan instead of building one.
    given: >-
      warehouse ROASTERY on 2026-11-16; BAG-ETH-250: open sales-order lines 1,200 C62 due 2026-11-24 to 2026-11-29 (one ISO week; v2: was to 11-30, which weekly buckets split), on hand 300; planning rule for (BAG-ETH-250, ROASTERY) and (ROAST-ETH, ROASTERY): lot size weekly (the default), production days Monday to Friday; both make (product's site rows);
      ROAST-ETH: shop forecast 180.000 KGM a week (entered, until pos's demand history exists), on hand 60.000, safety stock 40.000
      (product site data), lot size rounding multiple 84.000 KGM (product GET /products/{product_id}/round-proposal, role production)
    when: I run MRP-lite for 4 weeks
    then: >-
      BAG-ETH-250 net requirement 900 C62 gives one planned order of 900 due 2026-11-23 (one day before the first need, the
      routing's lead time); that needs 225.000 KGM ROAST-ETH, added to the shops' 720.000 for 4 weeks: gross 945.000, net of
      60.000 on hand plus 40.000 safety stock gives 925.000, rounded UP to the next multiple of 84.000: 12 x 84.000 =
      1,008.000 KGM (11 would be 924.000, 1.000 short), spread by week of need; each ROAST-ETH planned order publishes GREEN-ETH
      dependent requirements (100/84 of the output) for replenishment (MFG-27); planned orders are proposals: a re-run
      replaces the unfirmed ones and keeps the firmed ones. v2 (seemo's decided answer 6, SAP's MRP views per plant): the
      lot-size rule (daily | weekly | monthly), the production days and make-or-buy are per product and site rows, not
      code: a product on a daily rule nets each production day, a monthly one each calendar month; a due date that falls
      on a non-production day moves to the production day before
    screen: Planning
    reference: not built
  - id: MFG-09
    kind: person
    actor: planner
    story: As the planner, I want to firm a planned order and turn it into a production order, so that the plan the roastery works to does not move under it.
    given: 'planned order ROASTERY:ROAST-ETH:2026-11-16:run 7 for 84.000 KGM'
    when: 'I firm it and convert it'
    then: >-
      production order UK01-MO-2026-00118 for 84.000 KGM ROAST-ETH, due 2026-11-16, BOM v1 and routing v1 named (copied at
      release), status created; the planned order is converted and never proposed again; a second convert is refused
      (already converted, the order named); a production order can also be created by hand with the same fields
    screen: Planning
    reference: not built
  - id: MFG-10
    kind: person
    actor: planner
    story: As the planner, I want to release an order only when its BOM and routing are valid and its components are there, so that the roaster never starts a batch that cannot finish.
    given: 'UK01-MO-2026-00118 created; GREEN-ETH available 140.000 KGM at ROASTERY (inventory GET /availability); ROASTER-1 load on 2026-11-16: 5.5 of 8 hours'
    when: 'I release it on 2026-11-16'
    then: >-
      the BOM v1 and routing v1 are copied onto the order (frozen); planned component 100.000 KGM GREEN-ETH is available; the
      2.5 hours fit (8.0 hours capacity); the order is released; a shortage refuses the release unless release_with_shortage
      is on (then a warning is recorded); a capacity overload warns, never blocks (lean: no finite scheduling); a released order
      never returns to created
    screen: Orders
    reference: not built
  # ── the shop floor ───────────────────────────────────────────────────────────────────────
  - id: MFG-11
    kind: person
    actor: operator
    story: As the roaster, I want to issue the green beans I put in the roaster by scanning their lot, so that stock and the batch's ingredients are right.
    given: 'UK01-MO-2026-00118 released; GREEN-ETH lot G-2611-ETH-07 at ROASTERY/RAW 140.000 KGM; over_issue_tolerance 5%'
    when: 'I issue 100.000 KGM of lot G-2611-ETH-07, and later 3.000 more; then I try to issue 4.000 more'
    then: >-
      each issue is sent to inventory from a pending row with its own move_ref mfg:UK01-MO-2026-00118:issue:1:<seq> (seq 1, 2),
      moving stock to the order's WIP and posting WIP against stock valuation at inventory's value (620.00 for 100.000 at
      standard 6.2000); the 3.000 is within 5% (105.000 total allowed) and recorded as over-issue; the 4.000 more (107.000)
      is refused above tolerance; the lots issued are kept on the order for genealogy; quantities zero or negative are 422
    screen: Orders
    reference: not built
  - id: MFG-12
    kind: person
    actor: operator
    story: As the roaster, I want to record how long each step took, so that the batch carries the cost of the roaster and my time.
    given: 'op 10 of UK01-MO-2026-00118 planned 150 minutes'
    when: 'I confirm op 10 with 160 minutes (machine and labour), final'
    then: >-
      confirmation UK01-MO-2026-00118:10:1 costs 160 / 60 x (45.0000 + 22.5000) = 180.00 GBP, posted to gl from a pending row:
      debit wip 180.00 (the order), credit activity_absorption 180.00 (cost centre ROASTERY), source_document
      "UK01-MO-2026-00118:activity:10:1" (the confirmation itself), journal_id derived_id(source_document, "activity") (v2:
      gl's one rule; the order alone would give every confirmation one id); a second confirmation of op 10 is :2 with its
      own journal; a wrong one is reversed by a mirror confirmation (source_document "UK01-MO-2026-00118:activity:10:1:reverse"),
      never edited
    screen: Orders
    reference: not built
  - id: MFG-13
    kind: person
    actor: operator
    story: As the roaster, I want to report the roasted beans that came out, with their new batch number, and the chaff, so that the stock exists and its weight loss is visible.
    given: 'UK01-MO-2026-00118: 100.000 KGM green issued (the 3.000 over-issue reversed), planned output 84.000 KGM'
    when: 'I report 82.500 KGM ROAST-ETH as lot R-20261116-01 at ROASTERY/FG and 1.100 KGM CHAFF, final'
    then: >-
      the output is received through inventory (move_ref mfg:UK01-MO-2026-00118:output:1) at standard 9.3899 = 774.67 GBP
      (82.500 x 9.3899, rounded once), WIP credited; the chaff is received at 0.00 (net realisable value, IAS 2.14) as its own
      move (mfg:...:byproduct:1:1); the yield is 82.50% against 84.00% planned, shown on the order; the output lot names its
      input lot G-2611-ETH-07 (genealogy, MFG-16); a report above 110% of plan is refused as a typo unless the planner
      confirms it
    screen: Orders
    reference: not built
  - id: MFG-14
    kind: person
    actor: operator
    story: As the roaster, I want to report a burnt batch as scrap with a reason, so that the loss is seen as a loss and not hidden in the yield.
    given: 'UK01-MO-2026-00119: 100.000 KGM GREEN-ETH issued (620.00); planned output 84.000'
    when: 'I report 78.000 KGM good output and 6.000 KGM scrapped with reason BURNT (a roast that went too far)'
    then: >-
      good output is received at standard (78.000 x 9.3899 = 732.41); the 6.000 KGM scrap is recorded on the order with its
      reason and never received; at close its standard value (6.000 x 9.3899 = 56.34) is posted as abnormal_scrap, an
      expense (IAS 2.16), and only the rest of the WIP balance is the usage variance; scrap above scrap_approval_threshold
      (50.00 at standard) needs the planner's approval, never the operator's own (v2: permission mfg:scrap:approve, held
      by the planner; the approver is not the reporter, email AND sub)
    screen: Orders
    reference: not built
  - id: MFG-15
    kind: person
    actor: cost_accountant
    story: As the cost accountant, I want to close an order with its variance split and posted once, so that each batch's cost against standard is in the books and explained.
    given: 'UK01-MO-2026-00118 completed: WIP debit 620.00 (components) + 180.00 (activity) = 800.00, credit 774.67 (output at standard); standard for 82.500 KGM output: material 82.500/84.000 x 620.00 = 608.93, conversion = 774.67 - 608.93 = 165.74, the rest of the output''s standard value'
    when: 'I close it on 2026-11-18, and the close is retried after a timeout'
    then: >-
      WIP balance 25.33 posted as material_usage_variance 11.07 (620.00 - 608.93, including the 1.500 KGM below planned yield)
      and activity_variance 14.26 (180.00 - 165.74), journal_id derived_id("UK01-MO-2026-00118", "settle") (one per order), crediting
      wip 25.33 so the order's WIP is exactly 0.00; the split always sums to the balance (the rounding difference goes to the
      last category); on an average-cost company the balance instead goes to inventory as a valuation adjustment on what
      is still on hand from the order (inventory's POST /valuation-adjustments, source_kind production_settlement), the rest to cost of sales; the retry is gl's replay;
      a closed order never reopens: a late cost is a post-close adjustment order line, settle:2
    screen: Orders
    reference: not built
  - id: MFG-16
    kind: person
    actor: quality
    story: As the quality lead, I want to trace a batch of green beans forward to every roasted batch, bag, shop and customer, and a bag back to its beans, so that a recall takes minutes.
    given: 'green lot G-2611-ETH-07 issued to UK01-MO-2026-00118 (output R-20261116-01) and UK01-MO-2026-00121 (output R-20261117-02); R-20261116-01 packed into BAG-ETH-250 lot B-20261117-01; bags delivered to 3 customers and shipped to 4 shops'
    when: 'I trace G-2611-ETH-07 forward, and B-20261117-01 back'
    then: >-
      the genealogy graph (input lot -> order -> output lot) answers forward R-20261116-01, R-20261117-02, B-20261117-01,
      and for each output lot inventory's GET /lots/{product_id}/{lot_code}/trace answers where it went (customers, shops,
      on hand); back from B-20261117-01 to R-20261116-01 to G-2611-ETH-07 and its supplier receipt; nothing unaccounted;
      the trace is derived from the orders' issues and outputs, never typed (Regulation (EC) No 178/2002 art. 18)
    screen: Traceability
    reference: not built
  # ── system ───────────────────────────────────────────────────────────────────────────────
  - id: MFG-17
    kind: system
    actor: system
    trigger: on request from Planning, or from the composition's worker (no server-side scheduler)
    story: The system runs MRP-lite, so that planned orders follow demand without a planner rebuilding the plan.
    given: the demand, stock, site data and BOMs of MFG-08
    when: the run starts, and starts again while the first is running
    then: >-
      the run takes run_no n+1 with a plain INSERT (the second start loses with 23505 and answers "a run is in progress");
      it explodes level by level (low-level code from the BOM graph), nets, rounds through product, and writes planned orders
      keyed [warehouse_id, product_id, need_date, run_no]; unfirmed orders of run n are superseded, firmed ones kept; the
      result is marked stale as soon as a sales order, forecast, stock or BOM changes after the run (MFG-18)
    reference: not built
  - id: MFG-18
    kind: system
    actor: system
    trigger: when a BOM version, routing, work centre rate, component standard price, demand or stock changes
    story: The system marks plans, cost roll-ups and unreleased orders stale, so that nobody releases or costs from yesterday's data.
    given: 'CR-2026-12 computed; BOM ROAST-ETH v2 released valid from 2026-12-01 with output 85.000 KGM'
    when: v2 is released
    then: >-
      CR-2026-12 (which used v1 for 2026-12-01) is stale and cannot be released; unreleased orders due on or after 2026-12-01
      show "BOM v2 now valid"; released orders keep their frozen v1; the last MRP run shows stale with the cause
    reference: not built
  # ── integrations ─────────────────────────────────────────────────────────────────────────
  - id: MFG-20
    kind: integration
    actor: system
    trigger: when an issue, output, by-product or reversal is recorded
    story: Components leave and output enters stock through inventory, which owns the move, the lot and its value.
    given: an issue or output row committed with its move_ref
    when: the outbox dispatches it, twice
    then: inventory records the move, values it and posts WIP against stock itself; the repeat is inventory's replay on move_ref; manufacturing keeps the move id and value, never writes inv_ tables
    owner: inventory
    reference: not built
  - id: MFG-21
    kind: integration
    actor: system
    trigger: when an activity is confirmed and when an order is settled
    story: Activity absorption, scrap and variances post to gl.
    given: a committed confirmation or settlement
    when: manufacturing posts
    then: gl posts the balanced journal and answers replay for the same body
    owner: gl
    reference: not built
  - id: MFG-22
    kind: integration
    actor: system
    trigger: when a BOM is written, at planning and at costing
    story: Products, units, conversions, site planning data and rounding come from product.
    given: 'ROAST-ETH with production unit KGM and site ROASTERY procurement make'
    when: manufacturing reads it
    then: it reads the product, converts through product and rounds lot sizes through product; it stores none of them
    owner: product
    reference: not built
  - id: MFG-23
    kind: integration
    actor: system
    trigger: when MRP-lite reads demand
    story: Open sales-order lines are demand.
    given: 'open lines for BAG-ETH-250'
    when: MRP-lite runs
    then: it reads the open quantity per product, warehouse and date from sales-order; it never reads so_ tables
    owner: sales-order
    reference: not built
  - id: MFG-27
    kind: integration
    actor: system
    trigger: when replenishment reads demand
    story: Component demand from planned and released orders is published for inventory's and purchasing's replenishment.
    given: 'planned orders needing 1,200.000 KGM GREEN-ETH in four weeks'
    when: inventory's replenishment asks
    then: manufacturing answers the dependent requirements per component, warehouse and date; it never raises a purchase request
    owner: manufacturing
    reference: not built
  - id: MFG-30
    kind: integration
    actor: system
    trigger: when pos sells a product whose recipe source is manufacturing
    story: A usage-sale BOM is exploded for a till, so that the shop's stock follows what was sold.
    given: 'BOM LATTE-REG usage sale v1 valid from 2026-12-01'
    when: pos asks for 1 LATTE-REG with MOD-OAT on 2026-12-02
    then: manufacturing answers bom_id LATTE-REG:sale, version 1 and components ROAST-HOUSE 0.018 KGM, MILK-OAT 0.250 LTR, CUP-12OZ 1, LID-12OZ 1; the same request answers the same; manufacturing never calls pos
    owner: manufacturing
    reference: not built
  # ── controls and migration ───────────────────────────────────────────────────────────────
  - id: MFG-31
    kind: person
    actor: owner
    story: As the owner, I want the controls on the shop floor held by me, so that a planner cannot release orders with missing beans or widen the over-issue tolerance.
    given: 'over_issue_tolerance 5%, release_with_shortage off, scrap_approval_threshold 50.00'
    when: a planner tries to switch release_with_shortage on; I raise the tolerance to 8% from 2026-12-01
    then: the planner is refused (mfg:settings:controls); mine is a dated row after today
    screen: Settings
    reference: not built
  - id: MFG-32
    kind: person
    actor: owner
    story: As the owner moving off Odoo, I want the bills of materials, work centres and the open and closed production orders carried and reconciled, so that I can sign that nothing was lost.
    given: 'Odoo 17 mrp with 42 BOMs, 3 work centres, 6 open and 380 done orders in 2025 and 2026; inventory''s and product''s migrations signed'
    when: the migration loads, reconciles and I sign
    then: >-
      BOMs become released versions (version 1 valid from the cut-over), work centres with their costs_hour as rates, done
      orders as read-only closed history with their lots, open orders finished in Odoo first (recommended) or re-created
      here as created orders; counts and output quantities per product and month equal Odoo's
    screen: Migration
    reference: not built

processes:
  - key: make_to_stock
    pcf_id: null                       # APQC 4.3 Produce/manufacture/deliver product (4.3.x): id to confirm
    pcf_name: Produce product (schedule, issue, produce, record, close)
    reference_names: ["Plan to Produce", "Make to Stock"]
    brick: manufacturing
  - key: product_costing
    pcf_id: null
    pcf_name: Perform product costing and analyse variances
    reference_names: ["Product cost planning", "Cost object controlling"]
    brick: manufacturing

workflows:
  - entity: bom_version
    states: [draft, released, obsolete]
    terminal: [obsolete]
    transitions:
      - { key: write_bom, name: Write a BOM version, from: none, to: draft, by: process_engineer, gate: "quantities convert exactly; no cycle (graph walk); components stocked or phantom" }
      - { key: release_bom, name: Release the version, from: draft, to: released, by: process_engineer, gate: "valid_from after today; no overlap with another released version of the BOM", effect: "cost roll-ups and unreleased orders using the BOM marked stale (MFG-18)" }
      - { key: obsolete_bom, name: Make it obsolete, from: released, to: obsolete, by: process_engineer, requires: valid_to, gate: "no unreleased order or firmed plan names it after valid_to" }
  - entity: production_order
    states: [created, released, in_progress, completed, closed, cancelled]
    terminal: [closed, cancelled]
    transitions:
      - { key: create_order, name: Create the order, from: none, to: created, by: [planner, system], gate: "a released BOM and routing valid on the due date", effect: "production_order_id claimed from the company's yearly series" }
      - { key: release_order, name: Release the order, from: created, to: released, by: planner, gate: "components available (or release_with_shortage); BOM and routing valid on the release date", effect: "BOM and routing copied onto the order and frozen" }
      - { key: issue_components, name: Issue components, from: [released, in_progress], to: in_progress, by: operator, gate: "within over_issue_tolerance; the lot available", effect: "inventory issue from a pending row (MFG-11)" }
      - { key: confirm_operation, name: Confirm an operation, from: [released, in_progress], to: in_progress, by: operator, effect: "activity journal from a pending row (MFG-12)" }
      - { key: report_output, name: Report output, from: [released, in_progress], to: in_progress, by: operator, gate: "at most 110% of plan unless confirmed", effect: "inventory receipt of the output lot and by-products (MFG-13)" }
      - { key: reverse_entry, name: Reverse a wrong entry, from: in_progress, to: in_progress, by: operator, requires: reason, gate: "the entry not reversed already; the operator's own entry on the same business date, else a planner", effect: "a mirror movement with its own ref (…:reverse); v2: an output or by-product goes back out through inventory POST /goods-issues/from-source, source_kind production_output_reversal (inventory v7), an issue comes back as a receipt naming reverses_move_ref" }
      - { key: complete_order, name: Technically complete, from: in_progress, to: completed, by: [operator, cost_accountant], effect: "no further issues or outputs; costs may still arrive" }
      - { key: close_order, name: Close and settle, from: completed, to: closed, by: cost_accountant, gate: "every pending outbox row of the order sent; a variance above variance_approval_threshold approved by another person (email AND sub)", effect: "scrap and variances posted, WIP 0.00 (MFG-15)" }
      - { key: cancel_order, name: Cancel, from: [created, released], to: cancelled, by: planner, requires: reason, gate: "no movement or confirmation recorded" }
    readiness:
      - { transition: release_order, gate: components_available, reason: "short: named with quantities", fix: "wait for the receipt, or the owner allows release with shortages" }
      - { transition: release_order, gate: bom_valid, reason: "no released BOM version valid on the date", fix: "release a version" }
      - { transition: close_order, gate: outbox_empty, reason: "movements not yet confirmed by inventory or gl: named", fix: "wait, or retry the failed ones" }
      - { transition: close_order, gate: variance_approved, reason: "the variance {amount} is above {threshold}", fix: "another cost accountant approves it" }
    note: "a completed order never returns to in_progress; a cost that arrives after close is a post-close settlement (settle:n)"
  - entity: planned_order
    states: [proposed, firmed, converted, superseded]
    terminal: [converted, superseded]
    transitions:
      - { key: propose_planned_order, name: Proposed by MRP-lite, from: none, to: proposed, by: system }
      - { key: firm_planned_order, name: Firm, from: proposed, to: firmed, by: planner, effect: "kept by later runs" }
      - { key: convert_planned_order, name: Convert to a production order, from: [proposed, firmed], to: converted, by: planner, effect: "production order created (MFG-09)" }
      - { key: supersede_planned_order, name: Superseded by a later run, from: proposed, to: superseded, by: system }
  - entity: cost_rollup
    states: [computed, approved, released, stale]
    terminal: [released, stale]
    transitions:
      - { key: run_rollup, name: Run the roll-up, from: none, to: computed, by: cost_accountant, gate: "every component has a standard price on the effective date" }
      - { key: approve_rollup, name: Approve, from: computed, to: approved, by: cost_accountant, approval: true, gate: "the approver did not run it (email AND sub)" }
      - { key: release_rollup, name: Release to inventory, from: approved, to: released, by: cost_accountant, gate: "not stale; effective date after today", effect: "standard prices handed to inventory from pending rows" }
      - { key: mark_rollup_stale, name: Stale, from: [computed, approved], to: stale, by: system, gate: "an input changed (MFG-18)" }
    readiness:
      - { transition: approve_rollup, gate: second_person, reason: "you ran this roll-up", fix: "ask another cost accountant" }
  - entity: mfg_migration_run
    states: [loaded, reconciled, signed, discarded]
    terminal: [signed, discarded]
    transitions:
      - { key: load_mfg_from_odoo, name: Load Odoo's manufacturing, from: none, to: loaded, by: system, gate: "product and inventory migrations signed", effect: "v2 (pattern §11, seemo's decided answer 4): staging rows only (mfg_migration_row, keyed by the source id); nothing written to the ledger, balances or journals" }
      - { key: reconcile_mfg_migration, name: Reconcile against Odoo, from: loaded, to: reconciled, by: system }
      - { key: sign_mfg_reconciliation, name: Sign the reconciliation, from: reconciled, to: signed, by: owner, approval: true, gate: "every check equal or explained", effect: "v2 (decided answer 4): the staged rows written through this module's normal use cases, idempotent on the source key; the first write to the ledger; a signed run is later reversed by reversing entries, never deleted" }
      - { key: discard_mfg_migration, name: Discard the run, from: [loaded, reconciled], to: discarded, by: owner, requires: reason, effect: "v2 (decided answer 4): the run's staging rows deleted, nothing else (no ledger row was written)" }

entities:
  - id: work_centre
    meaning: a machine or a bench where work is done and costed
    natural_key: work_centre_id VARCHAR (ROASTER-1)
    key_attributes: [company_code, warehouse_id, cost_centre (gl), batch_capacity, capacity_unit_code, hours_per_day, status]
    master_data: SHARED, sole writer
    reference_table: mfg_work_centre
  - id: work_centre_rate
    meaning: what an hour of a work centre costs, by kind, from a date
    natural_key: [work_centre_id, rate_kind (machine | labour | overhead), valid_from]
    key_attributes: [rate_amount (NUMERIC 19,4 per hour), currency]
    master_data: module-owned
    reference_table: mfg_work_centre_rate
  - id: bom
    meaning: what goes into one product (production) or what one sold product consumes (sale)
    natural_key: bom_id = "<product_id>:<usage>[:<alternative>]" (ROAST-ETH:production)
    key_attributes: [product_id, usage (production | sale), alternative, company_code]
    relationships: ["-> bom_version"]
    master_data: SHARED, sole writer (pos reads usage sale)
    reference_table: mfg_bom
  - id: bom_version
    meaning: one dated version of a BOM, released or not
    natural_key: [bom_id, version]
    key_attributes: [valid_from, valid_to, output_quantity, output_unit_code, planned_yield_pct (derived), status, released_by, released_sub]
    master_data: module-owned
    reference_table: mfg_bom_version
  - id: bom_line
    meaning: one component, by-product or modifier rule of a BOM version
    natural_key: [bom_id, version, line_no]
    key_attributes: [kind (component | by_product | modifier), product_id, quantity (NUMERIC 19,4), unit_code, unit_numerator, unit_denominator, replaces_product_id, nrv_unit_cost (by-product, NUMERIC 19,4), operation_no]
    master_data: module-owned
    reference_table: mfg_bom_line
    note: "the BOM is also a graph (product -> component edges) walked for cycles, low-level codes and where-used"
  - id: routing
    meaning: the steps to make a product, on work centres, dated and versioned
    natural_key: [product_id, routing_version]
    key_attributes: [valid_from, operations (op_no, work_centre_id, setup_minutes, run_minutes, per_quantity, wait_hours)]
    master_data: module-owned
    reference_table: mfg_routing
  - id: production_order
    meaning: an instruction to make a quantity of a product by a date, and its record of what happened
    natural_key: production_order_id = "<company_code>-MO-<yyyy>-<nnnnn>" (claimed from the company's yearly series; migrated odoo:<db>:mrp.production:<id>)
    key_attributes: [product_id, warehouse_id, planned_quantity, unit_code, due_on, status, bom_id, bom_version, routing_version, frozen_bom_json, frozen_routing_json, planned_order_ref, yield_pct (derived), wip_balance (derived)]
    master_data: module-owned
    reference_table: mfg_production_order
  - id: order_movement
    meaning: one issue, output, by-product or reversal on an order, as sent to inventory
    natural_key: move_ref = "mfg:<production_order_id>:<issue | output | byproduct>:<line_no>:<seq>[:reverse]"
    key_attributes: [kind, product_id, lot_code, quantity, unit_code, location_id, inventory_move_id, value, reason_code, recorded_by, recorded_sub, business_date]
    master_data: module-owned
    reference_table: mfg_order_movement
  - id: confirmation
    meaning: time recorded on one operation of an order
    natural_key: [production_order_id, op_no, confirmation_no]
    key_attributes: [machine_minutes, labour_minutes, final (bool), cost, journal_id, reverses_confirmation_no]
    master_data: module-owned
    reference_table: mfg_confirmation
  - id: scrap
    meaning: output lost with a reason, expensed as abnormal loss
    natural_key: [production_order_id, scrap_no]
    key_attributes: [quantity, reason_code, standard_value, approved_by, approved_sub]
    master_data: module-owned
    reference_table: mfg_scrap
  - id: settlement
    meaning: "the close of an order: scrap and variances posted, WIP to zero"
    natural_key: [production_order_id, settlement_no]
    key_attributes: [wip_debit, wip_credit, abnormal_scrap, material_usage_variance, activity_variance, journal_id, settled_by, settled_sub, approved_by, approved_sub]
    master_data: module-owned
    reference_table: mfg_settlement
  - id: lot_genealogy
    meaning: which input lots went into which output lot, through which order
    natural_key: [output_product_id, output_lot_code, input_product_id, input_lot_code, production_order_id]
    key_attributes: [input_quantity, output_quantity]
    master_data: "module-owned (derived from order movements; also graph edges lot -> order -> lot)"
    reference_table: mfg_lot_genealogy
  - id: planned_order
    meaning: a proposal from MRP-lite to make something by a date
    natural_key: [warehouse_id, product_id, need_date, run_no]
    key_attributes: [quantity, unit_code, status, cause_json (the demand it covers), production_order_id]
    master_data: module-owned
    reference_table: mfg_planned_order
  - id: dependent_requirement
    meaning: a component needed by a planned or released order on a date
    natural_key: [warehouse_id, product_id, need_date, source_ref]
    key_attributes: [quantity, unit_code, source_kind (planned_order | production_order), run_no]
    master_data: "module-owned (derived per run; read by replenishment)"
    reference_table: mfg_dependent_requirement
  - id: forecast
    meaning: demand entered by the planner (the shops' weekly use, until pos publishes it)
    natural_key: [warehouse_id, product_id, week_start]
    key_attributes: [quantity, unit_code, entered_by]
    master_data: module-owned
    reference_table: mfg_forecast
  - id: cost_rollup
    meaning: a standard-cost calculation for a company and effective date
    natural_key: rollup_id = "<company_code>-CR-<yyyy>-<mm>[-<n>]"
    key_attributes: [effective_on, status, ran_by, ran_sub, approved_by, approved_sub, lines (product_id, material, conversion, by_product_credit, total per unit, NUMERIC 19,4)]
    master_data: module-owned
    reference_table: mfg_cost_rollup
  - id: outbox
    meaning: a side effect waiting for a neighbour after the local commit
    natural_key: [target, ref]
    key_attributes: [payload, status, attempts, last_error]
    master_data: module-owned
    reference_table: mfg_outbox
  - id: settings
    meaning: the controls and defaults, dated
    natural_key: [key, valid_from]
    key_attributes: [value, set_by]
    master_data: module-owned
    reference_table: mfg_settings

master_data:
  owns: [mfg_bom, mfg_work_centre]
  references:
    - entity: product
      owner: product
      how: "product_id on BOMs, lines, orders; units, conversion and rounding through product's routes"
      if_absent: "nothing to make"
    - entity: lot
      owner: inventory
      how: "lot_code on movements; the lot exists in inventory, which answers its trace"
      if_absent: "no batch traceability"
    - entity: standard_price
      owner: inventory
      how: "components' standard prices read through GET /valuation/products/{product_id}; roll-up results handed to inventory"
      if_absent: "no roll-up; output valued at planned cost"
    - entity: cost_centre
      owner: gl
      how: "cost_centre on a work centre, GET /cost-centres/{cost_centre}"
      if_absent: "activities are not posted"
  warning: >-
    A production module that keeps its own stock of components, its own item master or its own standard prices is how
    the roastery's kilos and the books' kilos disagree. Stock and its value are inventory's; the item is product's;
    manufacturing owns only the recipe, the route and the order.

jobs:
  - story: MFG-17
    trigger: on request, or from the composition's worker
    runs: MRP-lite
    where_options: [in the request, the composition's worker, external scheduler]
    idempotent: "run_no claimed with a plain INSERT; planned orders keyed [warehouse_id, product_id, need_date, run_no]"
  - story: MFG-18
    trigger: when an input changes
    runs: mark roll-ups, plans and unreleased orders stale
    where_options: [in the request that changed it]
    idempotent: "a flag; recompute is a pure function of the inputs"
  - story: MFG-20
    trigger: after each movement commits
    runs: dispatch pending outbox rows to inventory and gl
    where_options: [in the same request after commit, the composition's worker]
    idempotent: "move_ref for inventory, journal_id for gl"

seams:
  - story: MFG-20
    with: inventory
    owner: inventory
    route: "POST /goods-issues/from-source"
    permission: inv:issue:intake
    request: '{source_kind: production_issue, source_id: production_order_id, company_code, location_id, issued_on, account_purpose: wip, lines: [{move_ref: "mfg:<production_order_id>:issue:<line_no>:<seq>", product_id, quantity (string), unit_code, lot_code (optional)}]}'
    response: "201 {issue_id, moves: [{move_ref, move_id, quantity_base, lot_code, value}]}; 200 the same for known move_refs; 409 {gate: on_hand, reason, fix}; 422 {gate, reason, fix}"
    rule: "the same route pos uses (source_kind pos_sale); inventory posts WIP against stock valuation and answers each move's value and lot; idempotent on move_ref"
    status: "satisfied by inventory v6 (INV-58, issue_from_source)"
  - story: MFG-20
    with: inventory
    owner: inventory
    route: "POST /goods-issues/from-source"
    permission: inv:issue:intake
    request: '{source_kind: production_output_reversal, source_id: production_order_id, company_code, location_id, issued_on, account_purpose: wip, lines: [{move_ref: "<output move_ref>:reverse", reverses_move_ref, product_id, quantity, unit_code, lot_code}]}'
    rule: "v2 (2026-10-26): reverse_entry of an output or by-product; valued at the reversed move's value; refused by inventory when the lot no longer holds it"
    status: "satisfied by inventory v7 (INV-58, production_output_reversal)"
  - story: MFG-20
    with: inventory
    owner: inventory
    route: "POST /receipts"
    permission: inv:receipt:intake
    request: '{move_ref: "mfg:<production_order_id>:output:<seq>" | "mfg:<production_order_id>:byproduct:<line_no>:<seq>", source_kind: production_receipt | by_product_receipt, source_id: production_order_id, company_code, product_id, quantity (string), unit_code, location_id, received_on, lot_code (the new output lot), unit_cost (string, NUMERIC 19,4 per base unit: standard, the actual on an average-cost company, or NRV for a by-product), currency, reverses_move_ref (optional), input_lots: [{product_id, lot_code, quantity (string, base unit)}]}'
    response: "201 {move_id, quantity_base, value}; 200 the same for a repeated move_ref; 422 {gate, reason, fix}"
    rule: "inventory receives the output lot at the given cost and credits WIP; input_lots let its trace follow production"
    status: "satisfied by inventory v6 (INV-59, receive_from_source, with lot links for the trace)"
  - story: MFG-15
    with: inventory
    owner: inventory
    route: "POST /valuation-adjustments"
    permission: inv:valuation:post
    request: '{adjustment_ref: "mfg:<production_order_id>:settle:<n>", source_kind: production_settlement, production_order_id, company_code, product_id, amount (string, NUMERIC 18,2, signed), currency, on (the close date)}'
    response: "201 {adjustment_ref, on_hand_amount, cost_of_sales_amount}; 200 the same for a repeated adjustment_ref; 422 {gate: values_at_average, reason, fix}"
    rule: "on an average-cost company only (a standard-cost company's variances post to gl, MFG-15): the order's variance is spread over what is still on hand from its output, the rest to cost of sales"
    status: "satisfied by inventory v6 (INV-57 row for manufacturing, source_kind production_settlement)"
  - story: MFG-07
    with: inventory
    owner: inventory
    route: "POST /standard-prices"
    permission: inv:standard_price:intake
    request: '{company_code, product_id, valid_from (after today), standard_price (string, NUMERIC 19,4 per base unit), source_ref: "mfg:<rollup_id>", released_by, released_sub}'
    response: "201 {company_code, product_id, valid_from, standard_price, lifecycle: scheduled}; 200 the same for a repeat; 409 {gate: standard_price_taken, reason, fix}; 422 {gate: starts_after_today | values_at_standard, reason, fix}"
    rule: "inventory writes its own dated standard price and revalues on that date (its INV-48); idempotent on (company_code, product_id, valid_from, source_ref)"
    status: "satisfied by inventory v6 (INV-60, intake_standard_price)"
  - story: MFG-06
    with: inventory
    owner: inventory
    route: "GET /valuation/products/{product_id}?company_code=&as_of="
    permission: inv:valuation:read
    request: "path product_id; query company_code, as_of (the roll-up's effective date)"
    rule: "the component's standard price (or average) used by the roll-up"
    status: "exists in inventory v5 (INV-27)"
  - story: MFG-10
    with: inventory
    owner: inventory
    route: "GET /availability/{product_id}?warehouse_id="
    permission: inv:availability:read
    request: "path product_id; query warehouse_id"
    rule: "the gate components_available at release; MRP-lite's on-hand"
    status: "exists in inventory v5 (INV-27)"
  - story: MFG-16
    with: inventory
    owner: inventory
    route: "GET /lots/{product_id}/{lot_code}/trace"
    permission: inv:stock:read
    request: "path product_id, lot_code"
    rule: "where each output lot went (customers, shops, on hand); manufacturing adds the input -> output step from its genealogy"
    status: "exists in inventory v5 (INV-40)"
  - story: MFG-21
    with: gl
    owner: gl
    route: "POST /journal-entry/journals"
    permission: gl:journal:post
    request: '{journal_id: derived_id(source_document, event), source_module: manufacturing, source_document: "<production_order_id>:activity:<op_no>:<n>[:reverse]" (event activity) | production_order_id (event settle; a later adjustment after close, n >= 2, "<production_order_id>:settle:<n>"), event: activity | settle, journal_date, currency, company_code, lines: [{purpose: wip | activity_absorption | abnormal_scrap | material_usage_variance | activity_variance, cost_centre, debit, credit}]}'
    rule: "one journal per confirmation and per settlement; manufacturing never writes gl_ tables. v2: gl's one id rule (journal_id = derived_id(source_document, event)) holds, so a confirmation is its own source_document; the order alone would give every confirmation of it one id, and gl would answer the second as a replay of the first"
    status: "satisfied by gl v7 (GL-30 row for source_module manufacturing with these purposes)"
  - story: MFG-01
    with: gl
    owner: gl
    route: "GET /cost-centres/{cost_centre}"
    permission: gl:cost_centre:read
    request: "path cost_centre"
    rule: "a work centre's cost centre exists and is open"
    status: "exists in gl v6 (GL-61)"
  - story: MFG-21
    with: gl
    owner: gl
    route: "GET /periods/open"
    permission: gl:period:read
    request: "query date, company_code"
    rule: "a confirmation or settlement in a closed period waits with gl's reason"
    status: "exists in gl v6 (GL-32)"
  - story: MFG-22
    with: product
    owner: product
    route: "GET /products/{product_id}?on="
    permission: product:product:read
    request: "path product_id; query on"
    rule: "units, roles (production, issue), lot tracking, kind"
    status: "exists in product v5 (PRD-28)"
  - story: MFG-22
    with: product
    owner: product
    route: "GET /products/{product_id}/convert?quantity=&from_unit=&to_unit="
    permission: product:product:read
    request: "query quantity, from_unit, to_unit"
    rule: "every BOM quantity converts exactly to the base unit"
    status: "exists in product v5 (PRD-21)"
  - story: MFG-22
    with: product
    owner: product
    route: "GET /products/{product_id}/round-proposal?site_id=&quantity=&unit_code=&on="
    permission: product:product:read
    request: "query site_id (the warehouse), quantity, unit_code, on"
    rule: "MRP-lite's lot size rounding, role production"
    status: "exists in product v5 (PRD-38)"
  - story: MFG-22
    with: product
    owner: product
    route: "GET /product-sites?site_id=&planning=&on="
    permission: product:product:read
    request: "query site_id, planning active, on"
    rule: "safety stock, lot size and procurement type make per site"
    status: "exists in product v5 (PRD-39); make and mrp accepted by product v6 when manufacturing is installed (gate manufacturing_installed)"
  - story: MFG-23
    with: sales-order
    owner: sales-order
    route: "GET /demand?product_id=&warehouse_id=&from=&to="
    permission: so:demand:read              # machine, held by manufacturing's service client
    request: "query product_id, warehouse_id, from, to (YYYY-MM-DD), limit, offset"
    response: "200 {product_id, warehouse_id, from, to, demand: [{needed_on, sales_order_id, line_no, open_quantity, unit_code, open_base, confirmed}], more}"
    rule: "open quantity per product, warehouse and requested date; no prices"
    status: "satisfied by sales-order v8 (SO-50)"
  - story: MFG-27
    with: inventory
    owner: manufacturing
    route: "GET /dependent-requirements?product_id=&warehouse_id=&from=&to="
    permission: mfg:plan:read_requirements
    request: "query product_id, warehouse_id, from, to"
    response: "200 {product_id, warehouse_id, requirements: [{required_on, quantity (string, base unit), source: planned | released, production_order_id (released only)}]}"
    rule: "offered by manufacturing; inventory's replenishment adds it to demand and asks purchasing as it does today (POST /purchase-requests/from-replenishment)"
    status: "offered by manufacturing v1; read by inventory v6 (INV-61)"
  - story: MFG-30
    with: pos
    owner: manufacturing
    route: "GET /boms/{product_id}/explode?usage=sale&quantity=&on="
    permission: mfg:bom:explode
    request: "path product_id; query usage (sale | production), quantity (string), unit_code, modifiers (comma-separated product ids), on (YYYY-MM-DD)"
    response: "200 {bom_id, version, valid_from, components: [{product_id, quantity (string, base unit), unit_code}]}; 404 when no released version is valid on the date"
    rule: "offered by manufacturing to pos (POS-31); deterministic for the same query"
    status: "specified in manufacturing v1 and pos v1 (this wave)"

tool_names:                            # shared.yaml conventions.tool_names (2026-10-25): the migration family's names, so this brick composes beside the others; the transitions keep their keys
  - { tool: load_mfg_from_odoo, route: "the mfg_migration_run workflow's transition (route chosen at build)" }
  - { tool: reconcile_mfg_migration, route: "the mfg_migration_run workflow's transition (route chosen at build)" }
  - { tool: discard_mfg_migration, route: "the mfg_migration_run workflow's transition (route chosen at build)" }
  - { tool: get_mfg_migration_map, route: "GET /migration-map/{db}/{model}" }   # the one map route, shared.yaml conventions.odoo_migration.map_route

screens:
  - { id: work_centres, name: Work centres, stories: [MFG-01], actors: [process_engineer] }
  - { id: boms, name: Bills of materials, stories: [MFG-02, MFG-03, MFG-04, MFG-05], actors: [process_engineer, planner, cost_accountant], note: "versions on a timeline, the yield, where-used (graph), the routing beside it" }
  - { id: costing, name: Costing, stories: [MFG-06, MFG-07], actors: [cost_accountant], note: "roll-ups with material and conversion per unit, stale causes, approval and release" }
  - { id: planning, name: Planning, stories: [MFG-08, MFG-09], actors: [planner], note: "demand, stock, planned orders by week, the cause of each, firm and convert; forecasts" }
  - { id: orders, name: Orders, stories: [MFG-10, MFG-11, MFG-12, MFG-13, MFG-14, MFG-15], actors: [planner, operator, cost_accountant], note: "the shop-floor view per work centre (scan a lot, confirm a time, report output) and the cost view per order (plan, actual, yield, variance)" }
  - { id: traceability, name: Traceability, stories: [MFG-16], actors: [quality], note: "a lot forward and back, as a graph" }
  - { id: settings, name: Settings, stories: [MFG-31], actors: [process_engineer, owner], note: "every config_row below lives here; the controls to the owner only" }
  - { id: migration, name: Migration, stories: [MFG-32], actors: [owner, cost_accountant] }

permissions:
  namespace: mfg
  catalog:
    - mfg:bom:read
    - mfg:bom:write
    - mfg:bom:release
    - mfg:routing:write
    - mfg:work_centre:write
    - mfg:plan:run
    - mfg:plan:firm
    - mfg:forecast:write
    - mfg:order:read
    - mfg:order:create
    - mfg:order:release
    - mfg:order:cancel
    - mfg:order:issue
    - mfg:order:confirm
    - mfg:order:report_output
    - mfg:order:scrap
    - mfg:scrap:approve            # v2: approve scrap above scrap_approval_threshold (MFG-14); was borrowed from mfg:order:release
    - mfg:order:reverse
    - mfg:order:complete
    - mfg:order:close
    - mfg:cost:run
    - mfg:cost:approve
    - mfg:trace:read
    - mfg:report:read
    - mfg:settings:controls      # tolerances, release with shortage, approval thresholds, AI step modes
    - mfg:migration:read
    - mfg:migration:sign
    - mfg:plan:read_requirements # machine: inventory's replenishment client
    - mfg:bom:explode            # machine: pos's client and the system
    - mfg:plan:derive            # machine
    - mfg:outbox:dispatch        # machine
    - mfg:genealogy:derive       # machine
    - mfg:number_series:claim    # machine
    - mfg:migration:load         # machine
    - mfg:migration:reconcile    # machine
  machine:
    - mfg:plan:read_requirements
    - mfg:bom:explode
    - mfg:plan:derive
    - mfg:outbox:dispatch
    - mfg:genealogy:derive
    - mfg:number_series:claim
    - mfg:migration:load
    - mfg:migration:reconcile
  row_visibility:
    rule: "operators see released orders of their work centres; everyone else with mfg:order:read sees every order; costs need mfg:report:read or the cost accountant's role"
    enforced: "in every use case; the shop-floor view never returns cost fields"
    not_found_not_forbidden: true

config_rows:
  - table: mfg_settings
    changes: "how the shop floor is controlled; every row dated (valid_from after today; the first row of a key may start today)"
    settings:
      - { key: over_issue_tolerance_pct,  type: decimal, default: "5",     changes: "issue above plan allowed up to this percent; mfg:settings:controls" }
      - { key: release_with_shortage,     type: bool,    default: false,   changes: "release an order whose components are short; mfg:settings:controls" }
      - { key: output_plausibility_pct,   type: decimal, default: "110",   changes: "an output report above this percent of plan needs a planner's confirmation" }
      - { key: scrap_approval_threshold,  type: decimal, default: "50.00", changes: "scrap above this value at standard needs a planner (mfg:scrap:approve); mfg:settings:controls" }
      - { key: variance_approval_threshold, type: decimal, default: "250.00", changes: "a closing variance above this needs a second cost accountant; mfg:settings:controls" }
      - { key: mrp_horizon_weeks,         type: int,     default: 4,       changes: "how far MRP-lite plans" }
  - { table: mfg_settings, changes: "each AI step's mode (ai_steps: off | suggest)" }
  - { table: mfg_work_centre_rate, changes: "what an hour of each work centre costs" }
  - { table: mfg_scrap_reason, changes: "scrap reasons (BURNT, UNDER_ROASTED, SPILLED) and whether each is abnormal" }
  - { table: mfg_forecast, changes: "the weekly demand the planner enters" }
  - table: mfg_planning_rule            # v2 (seemo's decided answer 6): per product and site, dated; natural key (product_id, site_id, valid_from)
    changes: "how MRP-lite buckets and schedules one product at one site; a product with no row takes the defaults"
    settings:
      - { key: lot_size_rule,    type: str, default: "weekly",          changes: "daily | weekly (ISO weeks, Monday first) | monthly: the bucket net requirements are summed in, one planned order per bucket" }
      - { key: production_days,  type: str, default: "mon,tue,wed,thu,fri", changes: "the days the site produces this product; a planned order's due and start dates move to the production day before" }
      - { key: make_or_buy,      type: str, default: "from product",     changes: "make | buy: read from product's site row procurement_type (PRD-39), the one dated per-product per-site source; this row only names it and is never a second copy (only make is planned here)" }
  # BOMs and routings are master data with versions and release, not config rows: they change only through release.
in_code_not_rows:
  - "that a released BOM version is never edited and versions of one BOM never overlap"
  - "that an order's BOM and routing are frozen at release"
  - "that stock moves only through inventory, each movement with its own move_ref"
  - "that an order's WIP is exactly 0.00 after settlement and the variance split sums to the balance"
  - "that scrap is expensed, never capitalised into the output"
  - "that nobody approves a roll-up they ran or a variance they settled (email AND sub)"
  - "that 'today' is the business date in business_timezone"

ai_steps:
  - key: explain_variance
    benefit: "a cost accountant closing an order sees the likely cause of a variance (a lower yield on one green lot, a longer roast after a profile change, an over-issue not reversed)"
    mode: suggest
    modes_allowed: ["off", suggest]
    safe_for_auto: false
    inputs: [the order's movements, confirmations, yield, the same product's last 20 orders, the input lots' suppliers]
    output: "up to three causes with their evidence; the accountant decides"
    fallback: "the variance split alone"
    consent: "v2 (pattern §4, seemo's decided answer 3): the owner's mode (a dated mfg:settings:controls row, off | suggest) plus a role: whoever holds mfg:order:close sees the finding; no per-person consent, because it reads business data and no one's personal data; nothing is posted or changed by the step"
    audit: "mfg_ai_finding: model, inputs digest, finding, decision, who, when"
    eval: "orders with seeded causes; accuracy of the first cause"
    note: "an assistant beside the process, not a step in the signed lifecycle"

extension_points:
  - { name: quality_inspection, what: "inspection lots on output (moisture, roast colour) that block a lot until passed; inventory's stock type quality holds it meanwhile" }
  - { name: finite_scheduling, what: "sequencing orders on a work centre by capacity; v1 warns on overload and never schedules" }
  - { name: subcontracting, what: "already purchasing's and inventory's (INV-47): a BOM can be read by purchasing's subcontract order later" }
  - { name: co_products, what: "two main products from one order with a cost split by ratio; v1 has one main product and by-products at NRV" }
  - { name: demand_history, what: "pos's GET /sales-history as MRP-lite demand, replacing the entered forecast" }

tests:
  acceptance: "one per story id above, named for it"
  access:
    - "an operator cannot release, close, cost, change BOMs, or see cost fields"
    - "a planner cannot close an order or approve a roll-up; a cost accountant cannot approve a roll-up they ran or a variance they settled"
    - "anyone but the owner holding mfg:settings:controls is refused at configuration"
    - "mfg:bom:explode and mfg:plan:read_requirements are refused to every person and to clients other than pos's and inventory's"
    - "the machine permissions are refused to every person role and to a wildcard grant"
  invariants:
    - "claim keys: an issue (…:issue:<line>:<seq>), its reversal (…:reverse), an output (…:output:<seq>), a by-product (…:byproduct:<line>:<seq>), an activity journal (activity:<op>:<n>), its reversal (activity:<op>:<n>:reverse) and a settlement (the order; a later one <order>:settle:<n>) never share a key, so a reversal is never answered as the original's replay and an output is never taken for a by-product"
    - "side effects (inventory moves, gl journals, standard prices to inventory) are sent only from pending outbox rows written in the same transaction as the movement; an order closes only when its outbox is empty; a rolled-back movement sends nothing"
    - "state machines never go backwards: released never returns to created, completed never to in_progress, closed is terminal, a released BOM version is never draft again, a released roll-up never changes; each tested by attempting the reverse"
    - "self-approval: a roll-up's approver did not run it; a variance's approver did not settle it; a scrap above threshold is not approved by the operator who reported it (email AND sub)"
    - "the business date is in business_timezone: a confirmation at 00:30 BST on 2026-07-15 (23:30 UTC on the 14th) is dated 2026-07-15; BOM validity and roll-up effective dates compare against that date"
    - "money and quantities bounded: a quantity, rate, cost or variance negative where it cannot be, above NUMERIC(18,2) (rates and unit costs NUMERIC(19,4)), or a quantity that does not convert exactly answers 422 {gate: bounds}, never a 500"
    - "results marked stale: a BOM, routing, rate, standard price, demand or stock change marks the roll-ups, MRP runs and unreleased orders that used it stale; a stale roll-up cannot be released; released orders keep their frozen copy"
    - "no statement growth with unrelated rows: an order's cost report, WIP balance and genealogy contain only that order's movements; a lot's trace only that lot's edges; inserting another order's movements changes none of them (tested by seeding unrelated rows)"
    - "after settlement the order's WIP is 0.00 exactly and material_usage_variance + activity_variance + abnormal_scrap = the WIP balance"
    - "a BOM graph has no cycle; MRP-lite's low-level codes come from the same graph"
    - "MRP-lite run twice on unchanged inputs proposes the same planned orders"
  scenarios:
    - id: SC-MFG-01
      name: roast a batch from plan to close
      stories: [MFG-08, MFG-09, MFG-10, MFG-11, MFG-12, MFG-13, MFG-15]
      given: ROAST-ETH standard 9.3899, GREEN-ETH 6.2000
      when: 100.000 KGM is issued, 160 minutes confirmed, 82.500 KGM and 1.100 KGM chaff reported, the order closed
      then: WIP 800.00 debit, 774.67 credit; variances 11.07 material and 14.26 activity; WIP 0.00
    - id: SC-MFG-02
      name: a burnt batch
      stories: [MFG-14, MFG-15]
      given: 100.000 KGM issued
      when: 78.000 good and 6.000 scrapped BURNT are reported and the order closed with 150 minutes
      then: abnormal_scrap 56.34 expensed; the remaining balance is the usage variance; WIP 0.00
    - id: SC-MFG-03
      name: recall a green lot
      stories: [MFG-16]
      given: G-2611-ETH-07 in two roasts and one bag lot
      when: traced forward
      then: every roasted and bagged lot, every customer and shop named; back from the bag lot to the supplier receipt
    - id: SC-MFG-04
      name: a till sells a latte through manufacturing's BOM
      stories: [MFG-05, MFG-30]
      given: BOM LATTE-REG usage sale v1 and pos with recipe_source manufacturing
      when: pos sells one oat latte on 2026-12-02
      then: inventory receives the consumption of ROAST-HOUSE 0.018, MILK-OAT 0.250, a cup and a lid; the receipt line names LATTE-REG:sale v1
    - id: SC-MFG-05
      name: a BOM change after a roll-up
      stories: [MFG-06, MFG-18, MFG-07]
      given: CR-2026-12 computed with BOM v1
      when: v2 is released for 2026-12-01 and the accountant tries to release CR-2026-12
      then: refused stale; a new roll-up with v2 gives (620.00 + 168.75) / 85 = 9.2794 per KGM

scale:
  reads: "the tables reader serves 16 concurrent reads; MRP-lite reads demand, stock and site data per low level in one query each; the shop floor reads one order"
  hot_paths: ["MRP-lite explosion", "the BOM explode for pos (cached per bom_id, version and modifiers)", "order close"]
  workers: "the outbox dispatcher in the composition's worker; MRP-lite on request"
  volumes: "a roastery with 50 BOMs and 30 orders a week plans in seconds; pos's explode is a cached read, about 32,000 a day for a 40-shop chain"

platform_constraints:
  - constraint: "No read-your-writes inside an open transaction."
    today: "a movement commits, then its WIP balance and genealogy are derived in a second transaction"
  - constraint: "graph_khop() takes an integer-literal start node and resolves only as a top-level SELECT."
    today: "BOM and genealogy walks start from a node id derived from the product or lot key, one walk per query; cycles are checked by a walk from the new component before release"
  - constraint: "A plain INSERT of a committed key is rejected (23505)."
    today: "order numbers, MRP run numbers, confirmations and settlements are plain INSERTs; the loser re-reads"

customer_varies:
  - question: Is the weight lost in roasting normal (part of the cost of roasted beans), and how much is normal?
    changes: the BOM's output quantity per input (planned yield)
    standard: "IAS 2 para 16 (abnormal waste expensed), para 10-12 (normal loss in the cost of conversion)"
    typical:
      specialty_roaster: 15 to 18% loss, planned per origin and roast profile
      commodity_roaster: 12 to 15% loss
  - question: What is chaff (or another by-product) worth to you, and do you sell it?
    changes: nrv_unit_cost on by-product lines
    standard: "IAS 2 para 14 (by-products at net realisable value, deducted from the main product's cost)"
  - question: Do your machine and labour rates include overheads (rent, energy), and on what capacity?
    changes: mfg_work_centre_rate kinds
    standard: "IAS 2 para 12-13 (fixed overhead on normal capacity; unallocated overhead expensed)"
  - question: Is your stock valued at standard or at average cost?
    changes: "inventory's valuation_method per company (its INV-48); here: output at standard and variances, or settlement as a valuation adjustment"
  - question: How far ahead do you plan, and do the shops' sales drive the roastery?
    changes: mrp_horizon_weeks, forecasts or pos's demand history
  - question: Who may release an order when beans are short, and how much over the plan may a roaster issue?
    changes: release_with_shortage, over_issue_tolerance_pct

variation_points:
  - node: Task_make_to_stock_close_order
    op: narrow
    default: "a variance above 250.00 approved by a second cost accountant"
    depth: 1
    guideline: "warn - a large variance closed by the person who settled it hides the cause"
  - node: Task_make_to_stock_release_order
    op: on_off
    default: "release refused when components are short"
    depth: 1
    guideline: "warn - a released order that cannot finish holds a work centre and WIP"
  - node: Task_make_to_stock_report_output
    op: row_value
    default: "scrap is expensed"
    depth: 1
    requirement: "refuse - capitalising abnormal scrap into the output breaks IAS 2 para 16"

migration:
  odoo:
    runs_after: [product, inventory, gl]
    reads:
      - model: mrp.workcenter
        fields: [id, name, code, costs_hour, time_efficiency, capacity, default_capacity, resource_calendar_id, company_id]
        filter: "active"
        why: "work centres; costs_hour becomes the machine rate"
      - model: mrp.bom / mrp.bom.line / mrp.bom.byproduct
        fields: [id, product_tmpl_id, product_id, product_qty, product_uom_id, type (normal | phantom | subcontract), code, active, company_id; line: product_id, product_qty, product_uom_id, operation_id; byproduct: product_id, product_qty, cost_share]
        filter: "active BOMs"
        why: "BOMs: normal -> usage production; phantom -> usage sale (a kit exploded when sold); subcontract stays purchasing's"
      - model: mrp.routing.workcenter
        fields: [id, bom_id, name, workcenter_id, sequence, time_cycle_manual, time_mode]
        filter: "operations of the BOMs above"
        why: "routings (Odoo 17 keeps operations on the BOM)"
      - model: mrp.production
        fields: [id, name, product_id, product_qty, qty_produced, product_uom_id, bom_id, state (draft | confirmed | progress | to_close | done | cancel), date_start, date_finished, lot_producing_id, move_raw_ids, move_finished_ids, company_id]
        filter: "state done within the door's years; open states stop the run unless the door says re-create"
        why: "closed orders as history with lots; open ones decided at the door"
      - model: mrp.workorder
        fields: [id, production_id, workcenter_id, duration, duration_expected, state]
        filter: "workorders of the orders above"
        why: "actual minutes per operation as confirmations"
    maps:
      - { from: "mrp.workcenter", to: "work_centre", rule: "work_centre_id = code or named at the door; costs_hour -> machine rate from the cut-over" }
      - { from: "mrp.bom (normal | phantom)", to: "bom + bom_version 1", rule: 'bom_id = "<product_id>:<production | sale>"; valid_from the cut-over; products through product''s GET /migration-map' }
      - { from: "mrp.bom.byproduct", to: "bom_line (by_product)", rule: "Odoo's cost_share is not NRV: nrv_unit_cost decided at the door" }
      - { from: "mrp.production (done)", to: "production_order (closed)", rule: 'production_order_id = "odoo:<db>:mrp.production:<id>"; output lot from lot_producing_id; issued lots from move_raw_ids'' move lines, kept as genealogy' }
      - { from: "mrp.workorder", to: "confirmation", rule: "duration minutes; cost not recomputed" }
    door:
      - "which BOMs are production and which are kits sold in the shops (phantom)"
      - "open manufacturing orders: finished in Odoo (recommended) or re-created here as created orders"
      - "by-products' net realisable value"
      - "how many closed years to carry for traceability (recommended: the shelf life of the product plus one year)"
    reconcile:
      - { check: "BOMs carried", odoo: "search_count of active mrp.bom", ours: "bom rows", must: equal }
      - { check: "output quantity per product and month", odoo: "SUM(qty_produced) of done mrp.production by date_finished month", ours: "output movements of migrated orders", must: equal }
      - { check: "genealogy of output lots", odoo: "consumed lots per lot_producing_id", ours: "lot_genealogy", must: equal }
      - { check: "phantom BOMs mapped to usage sale", odoo: "type phantom", ours: "usage sale", must: explained }
    not_carried:
      - "Odoo's manufacturing journal entries: gl's migration carries them"
      - "Odoo's work centre productivity logs beyond duration"
      - "unbuild orders (mrp.unbuild): history only, as notes"
    idempotent_on: 'production_order_id = "odoo:<db>:mrp.production:<id>"; bom_id from the product and usage; lines on [bom_id, version, line_no]; mfg_migration_map on [odoo_db, odoo_model, odoo_id]'

needs: []                              # wave 3: every route this spec needed in an existing module is now in that module (seam statuses say which version)

reference:
  # `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
  # `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
  repos:
    - name: erp-brick-manufacturing
      role: brick
      https: https://git.dodil.io/dodil/erp-brick-manufacturing.git
      ssh: git@git.dodil.io:dodil/erp-brick-manufacturing.git
      tag: v0.2.0
      read_first: [README.md, AGENTS.md]
    - name: erp-platform
      role: platform
      https: https://git.dodil.io/dodil/erp-platform.git
      ssh: git@git.dodil.io:dodil/erp-platform.git
      tag: v0.9.13
      read_first: [README.md, AGENTS.md]
  packages: []
  contracts: []
  build_guide: null
  known_gaps:
    - "nothing is built; this is version 1 of the spec, unsigned"
    - "the needs above are change requests in inventory, product, sales-order and gl"
    - "no finite scheduling, no co-products with a cost split, no quality inspection lots (extension points)"
    - "APQC ids are null until verified against PCF v8.0"

# SAP S/4HANA PP / PP-PI parity at mid-market scope. covered | different | deferred | not_planned.
sap_parity:
  - { sap: "Bills of material with validity and alternatives (CS01)", ours: covered, where: "bom, bom_version, MFG-02" }
  - { sap: "Sales BOM / phantom assemblies", ours: covered, where: "usage sale, MFG-05, MFG-30" }
  - { sap: "Routings / master recipes and work centres (CA01, C201, CR01)", ours: covered, where: "routing, work_centre, MFG-01, MFG-03" }
  - { sap: "Production orders / process orders: release, goods issue 261, confirmation, goods receipt 101", ours: covered, where: "production_order workflow, MFG-10 to MFG-13" }
  - { sap: "Co-products and by-products", ours: different, where: "bom_line by_product at NRV", why: "by-products at net realisable value (IAS 2.14); co-products with a cost split are an extension" }
  - { sap: "Scrap and yield (component scrap, assembly scrap, operation yield)", ours: different, where: "planned yield as output per input; scrap reported with a reason", why: "one planned yield per BOM version covers the roasting case; per-operation scrap is not specified" }
  - { sap: "Batch management and where-used (MB56)", ours: covered, where: "lot_genealogy, MFG-16, inventory trace" }
  - { sap: "Standard cost estimate and release (CK11N, CK40N, CK24)", ours: covered, where: "cost_rollup, MFG-06, MFG-07, inventory standard price" }
  - { sap: "Order settlement and variance categories (KKS2, KO88)", ours: covered, where: "settlement, MFG-15", why: "two categories (material usage incl. yield, activity) plus abnormal scrap" }
  - { sap: "MRP (MD01 / MRP Live) with planned orders and dependent requirements", ours: covered, where: "MFG-08, MFG-17, planned_order, dependent_requirement", why: "lean: production proposals only; procurement proposals stay with inventory and purchasing" }
  - { sap: "Capacity planning and finite scheduling (CM01, PP/DS)", ours: deferred, milestone: "M8", where: "extension_points.finite_scheduling", why: "overload warns; sequencing is worth it only with several shared machines" }
  - { sap: "Quality management in production (inspection lots type 03/04)", ours: deferred, milestone: "M8", where: "extension_points.quality_inspection", why: "inventory's quality stock type holds a lot meanwhile" }
  - { sap: "Repetitive manufacturing and kanban", ours: not_planned, where: "", why: "outside the batch-production case this lean module targets" }