dodil/erp-brick-manufacturing

DODIL ERP brick: manufacturing

AGENTS.md

Instructions for agents working on this brick

Built from the manufacturing module spec (erp-method modules/manufacturing/module.yaml, version 1, signed 2026-10-25). Slices A and B are built (0.2.0): bills of materials and the production order workflow (release -> issue -> receive -> settle), work centres, routings, the cost roll-up, MRP-lite, confirmations, scrap, reversals, traceability, the Odoo slice and the explain_variance AI step. Read erp-platform's BRICKS.md before changing anything. The shape is erp-brick-payments' / erp-brick-product's. application/process.yaml is the signed workflows, copied; tests/test_process.py holds it equal to the spec AND to the signed BPMN hashes - a process change goes through erp-compose change propose and a new signature first.

Where a change goes

You are changing...It goes in
a business ruledomain/ (bom.py, production_order.py, settlement.py, values.py bounds) and its test in tests/test_domain.py / test_stories.py
who may do something, or which rowsapplication/use_cases.py - the permission on the declaration; _see_order for rows; never a role's name
a new action for people and agentsa new use case in application/use_cases.py - a route AND a tool (tool=False only for a machine seam)
a work centre, rate, routing or cost roll-upapplication/engineering.py (+ domain/routing.py, domain/costing.py); staleness is hub.mark_stale in use_cases.py
MRP-lite, planned orders, what replenishment readsapplication/planning.py (+ domain/mrp.py, the pure netting)
time on an operation, scrap, a reversalapplication/floor.py
a lot traceapplication/trace.py (one statement per level)
the Odoo sliceapplication/migration.py + infrastructure/odoo.py (the reader port)
the AI stepapplication/ai_step.py + infrastructure/ai_steps.py (ai.suggest); the contract in domain/ai.py
what is sent to inventory or gl, and how an answer is readapplication/dispatch.py (+ the payload in the use case that writes the outbox row)
how another module is reachedinfrastructure/seams.py only (product, inventory, gl)
a column or tableinfrastructure/tables.py, then python scripts/gen_schema.py (it regenerates schema.sql and migrations/00002_slice_b, and never touches the frozen 00001_init); AFTER release a NEW migrations/NNNNN_<name>/ (append; never drop or rename); tests/test_schema.py keeps them equal

Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Don't write to another brick's tables. Don't write an upsert by hand for an aggregate (erp_platform.repository); facts are plain INSERTs.

The rules that must not be undone

  • Stock and money never move here. A movement row and a PENDING mfg_outbox row commit together; dispatch.send tells inventory / gl AFTER the commit and records the answer in a second transaction. sent and rejected are final (the UPDATE refuses to move them back). A rolled-back movement sends nothing; an order closes only when no row of it is pending.
  • One claim key per stock or money operation (mfg_claim, plain INSERT, n = committed claims + 1; a loser is a 409): scopes issue (key <order>:<line>), output (<order>), byproduct (<order>:<line>), approval, settle; mfg_number_series for the order id. The move_ref is mfg:<order>:issue:<line>:<seq> / :output:<seq> / :byproduct:<line>:<seq>: an issue, an output and a by-product never share one.
  • A released BOM version is never edited. Validity is COMPUTED (a version holds from its valid_from until the next released version's), so versions never overlap; valid_from must be after today and after every released version. An order FREEZES a copy at release.
  • Normal weight loss is the BOM's planned yield, not a variance. Standard material for the output = planned quantity at the unit cost inventory valued the issue at, scaled to the output reported. The split always sums to the WIP balance (activity takes the rounding).
  • State machines only go forward. created -> released -> in_progress -> completed -> closed; cancelled from created/released with no movement.
  • Second approver: email AND sub. A variance above variance_approval_threshold (sum of absolute variance categories) needs approve_variance by someone other than whoever closes it; same person under either identity is refused.
  • 'Today' is the date in business_timezone (SettingsRepository.timezone, the one port; a zone change counts today in the zone BEFORE it).
  • Bounded -> 422 {gate: bounds}, never 500: quantities/rates NUMERIC(19,4) (domain/values.py), money NUMERIC(18,2); an answer inventory gives that no column holds is recorded rejected, not stored. A conversion that is not exact is refused by product (never rounded).
  • Input validation runs before the permission check in the doors, so a date is a plain string parsed INSIDE the use case.
  • Rows: an operator sees released / in-progress orders only; anything else is 404, never 403; the shop-floor view carries no cost fields.
  • Machine permissions (mfg:bom:explode, mfg:outbox:dispatch, ...) are exact grants to a service client; no wildcard reaches them.
  • Slice B rules. Time is costed at the work centre's DATED rates (machine+overhead on machine minutes, labour on labour minutes) and posted to gl from a pending row; a confirmation gl refuses is VOID (counts for nothing, shown). Scrap is never received: it is expensed at close at its standard value and counts toward the standard material, so only the rest of the balance is variance; scrap above scrap_approval_threshold waits for a planner who did not report it (email AND sub) and the order cannot close meanwhile. reverse_entry is the operator's OWN entry, the SAME business day, on an open order: an issue returns to stock (a receipt naming reverses_move_ref), a confirmation is mirrored by an opposite journal, scrap is voided; the reversal has its own reference (…:reverse, …:reverse:<n> after a refusal) and an output cannot be reversed. A cost roll-up is a PROPOSAL (computed) until a SECOND cost accountant approves it (= releases it: one pending inventory.standard_price row per product); a BOM, routing, rate or forecast change marks open roll-ups, the last MRP run and unreleased orders stale (flags, never rewrites); released ones keep what they froze. MRP-lite nets by ISO week (Monday), rounds the shortfall UP through product, is deterministic, and a re-run supersedes proposed orders and keeps firmed ones (they count as supply). The explain_variance step only ever writes mfg_ai_finding; the owner's mode is ai_explain_variance_mode.
  • Outbox rows for a roll-up use mfg_outbox.production_order_id = "rollup:<rollup_id>" (the column is the owning document's id).
  • tests/conftest.py checks every neighbour call against the callee's signed spec and WORKS AROUND four spec gaps, each held by a test in tests/test_seams.py that fails the day the spec closes it (and a fifth, the gl journal id, below).

Backlog (decided 2026-10-26, pending the spec PR)

seemo decided these on 2026-10-26; they are written into erp-method branch spec/gaps-wave-4. Build them once that PR is signed (the module's processes need a re-sign first), not before; until then the spec on main stands.

  • Planning settings as rows. mfg_planning_rule per product and site, dated: lot_size_rule daily | weekly | monthly (default weekly, today hard-coded weekly Monday netting) and production_days (default Monday to Friday; a due date on a non-production day moves to the day before); make-or-buy stays product's site procurement_type.
  • The journal_id form. gl v9 agrees with what is built: an activity journal's source_document is <order>:activity:<op>:<n>[:reverse], journal_id = derived_id(source_document, "activity"); a first settlement derived_id(order, "settle"), a later one <order>:settle:<n>.
  • The scrap permission. Scrap approval above scrap_approval_threshold needs mfg:scrap:approve (the planner), not mfg:order:release.
  • Output reversal. reverse_entry of an output or by-product calls inventory v7's POST /goods-issues/from-source with source_kind production_output_reversal and reverses_move_ref (drop output_not_reversible once inventory builds it).
  • Migration stages (load and reconcile stage, sign writes, discard deletes staging only: today discard only marks the run). explain_variance keeps the owner's mode plus a role (mfg:order:close); no per-person consent.
  • Shims to remove once signed: product lists manufacturing for /products/{id}, /convert, /round-proposal; inventory lists it for availability, valuation and the lot trace; gl for cost-centres and periods.

Before you commit

pytest -q --tb=short     # domain, stories, invariants, doors, seams, budget, process, schema, composition
ruff check src tests scripts
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_manufacturing/roles.yaml

Spec gaps found building slice B (the smallest honest thing was built; change requests are the spec owners')

  1. gl journal id vs its own prose. gl's request_schema says journal_id = derived_id(source_document, event) with event only activity | settle; its prose says derived_id("mfg:<order>", "activity:<op>:<n>"). Taking the schema literally gives EVERY confirmation of an order one id (gl would answer the second as a replay of the first). Activity journals therefore use source_document = "<order>:activity:<op>:<n>[:reverse]" (the confirmation itself) and derived_id(source_document, "activity"). Settlement stays derived_id(order, "settle"): one per order; a post-close settle:2 adjustment is still unspecified.
  2. No inventory route takes output back out. reverse_entry refuses an output/by-product (gate output_not_reversible). An issue's reversal is POST /receipts with source_kind production_receipt + reverses_move_ref (inventory's own rule text), sent with the issue's own unit cost (unit_cost is required by its schema).
  3. product lists none of manufacturing's calls (/products/{id}, /convert, /round-proposal) with with: manufacturing -> product is unchecked. GET /product-sites is listed; its body arrives as {"body": [...]} through the platform seam; paging is unspecified (one call per run).
  4. inventory lists availability and valuation for sales-order only (checked as that caller) and the lot trace only inside a combined GET /serials/... and GET /lots/.../trace row with: crm (not checked). gl lists GET /cost-centres for inventory and purchasing only.
  5. MRP rules the spec leaves open. Weekly (Monday) netting; lead time = the routing's work-centre minutes for THAT quantity plus waiting hours, rounded up to whole days, one day before the first need (the spec's "one day before"); open created/released orders count as supply, in_progress ones do not (their output is imminent stock); only products whose site row says procurement_type make are planned. The spec's MFG-08 sales lines (due 11-24 to 11-30) straddle two ISO weeks, which weekly netting would split into two orders; the test puts them in one week. The planned-order reference is <warehouse>:<product>:<due>:run <n> (the spec's example, with the space).
  6. Scrap approval has no permission in the catalog: a planner approves it with mfg:order:release. mfg_scrap_reason is settable by the owner only (mfg:settings:controls); only ABNORMAL reasons can be reported as scrap (normal loss is the yield).
  7. Staleness from outside is not pushed to us. A sales order, stock or component standard price changing marks nothing (no event seam); only a BOM, routing, work-centre rate or forecast change does. A run's staleness from those is a flag; re-running is the fix.
  8. Odoo slice. The gate "product and inventory migrations signed" is not checked (no route reads their status). The reader is a PORT (infrastructure/odoo.py); no live reader is shipped and product codes arrive already mapped. Work centres get capacity_unit_code "" (Odoo has none). History keeps Odoo's unit (not converted) and no value. discard_mfg_migration marks the run only: carried rows stay (a second load is idempotent on the map), so "discard" is not an undo.
  9. AI step. The consent in the spec is "nothing is posted": there is no per-person consent, only the owner's mode (off | suggest). ai.suggest's context is scalars, so lists and maps travel as JSON text. The input lots' suppliers are the bp_ids in inventory's trace history of each lot.
  10. Not built (slice B list is otherwise done): MFG-10's capacity-overload warning at release (no load query yet), finite scheduling, quality inspection and co-products (the spec's extension points), the live Odoo reader, a re-release of a standard price inventory rejected.
  11. Slice A's open question stands: release_with_shortage/controls are dated rows; valuation_method average posts a single adjustment, untested live.

Scenarios: SC-MFG-01 (tests/test_floor.py::test_sc_mfg_01_*), SC-MFG-02 (test_mfg_14_a_burnt_batch_*), SC-MFG-03 (tests/test_trace.py), SC-MFG-05 (tests/test_engineering.py::test_mfg_18_a_new_bom_version_*). SC-MFG-04 (a till sells a latte) is slice A's explode route plus pos.