dodil/erp-brick-manufacturing
DODIL ERP brick: manufacturing
src/brick_manufacturing/application/floor.py
"""The shop floor, beyond issue and output (MFG-12, MFG-14, reverse_entry): time on an operation, scrap with a reason, and an operator's
own wrong entry put right the same day.
Time is costed at the work centre's dated rates and posted to gl from a pending row (debit wip, credit activity absorption on the work
centre's cost centre). Scrap is never received into stock: it is expensed at close at its standard value (IAS 2.16); a big one needs a
planner. A wrong entry is never edited: a MIRROR is recorded - a return to stock for an issue, an opposite journal for a confirmation, a
void for scrap - with its own reference, so a reversal is never answered as the original's replay."""
from decimal import Decimal
from erp_platform import work
from erp_platform.bricks import Refused
from erp_platform.seam import derived_id
from brick_manufacturing.application import use_cases as hub
from brick_manufacturing.application.inputs import Code, Qty, Rate0, Reason
from brick_manufacturing.domain import routing as rt
from brick_manufacturing.domain.errors import OutOfBounds, RuleBroken
from brick_manufacturing.domain.values import money, q4
from brick_manufacturing.infrastructure import seams
MFG = hub.MFG
_s = hub._s
def _journal(order, today, cur: str, doc: str, memo: str, debit: tuple, credit: tuple) -> dict:
"""A balanced two-line gl body: (purpose, amount, cost_centre or '') on each side. SPEC GAP: gl's request_schema says journal_id =
derived_id(source_document, event) with event only `activity | settle`, so source_document = the production order would give EVERY
confirmation of an order one id (gl would answer the second as a replay of the first). The source document is therefore the
confirmation itself (<order>:activity:<op>:<n>[:reverse]) and the id derived from it - unique, and the contract check holds."""
jid = derived_id(doc, "activity")
def ln(side: str, t: tuple) -> dict:
return {"purpose": t[0], side: _s(t[1]), **({"cost_centre": t[2]} if t[2] else {})}
return {"journal_id": str(jid), "source_module": "manufacturing", "source_document": doc,
"event": "activity", "company_code": order.company_code, "journal_date": today.isoformat(), "currency": cur, "memo": memo,
"lines": [ln("debit", debit), ln("credit", credit)]}
# ── operation confirmations (MFG-12) ────────────────────────────────────────────────────────────────────────
@MFG.use_case(kind="command", route="POST /production-orders/{production_order_id}/confirmations", permission="mfg:order:confirm", status=201)
def confirm_operation(caller: dict, production_order_id: Code, op_no: int, machine_minutes: Rate0, labour_minutes: Rate0,
final: bool = False) -> dict:
"""Record how long an operation of the order's routing took, machine and labour minutes. The time is costed at the work centre's rates
for the business date (machine and overhead rates on machine minutes, labour on labour minutes) and posted to gl from a pending row:
debit wip, credit activity absorption on the work centre's cost centre. A second confirmation of the same operation is the next number
with its own journal; a wrong one is reversed (reverse_entry), never edited."""
with hub.repository() as repo, hub._decide():
order, ver, loaded = hub._see_order(repo, caller, production_order_id)
today = hub._today(repo)
order.start_work()
ops = {o["op_no"]: o for o in order.frozen_routing.get("operations", [])}
if not ops:
raise RuleBroken(f"Order {production_order_id} has no routing: there is no operation to confirm", gate="routing")
op = ops.get(op_no)
if op is None or not op["work_centre_id"]:
raise RuleBroken(f"Operation {op_no} is not a step on a work centre of this order's routing", gate="operation")
if machine_minutes <= 0 and labour_minutes <= 0:
raise OutOfBounds("a confirmation records some time: machine or labour minutes above zero")
cost = rt.activity_cost(repo.masters.rates([op["work_centre_id"]]), op["work_centre_id"], today, machine_minutes, labour_minutes)
wc = repo.masters.work_centre(op["work_centre_id"])
key = f"{production_order_id}:{op_no}"
n = repo.claims.counts("confirm", [key])[key] + 1
ref = f"mfg:{production_order_id}:activity:{op_no}:{n}"
repo.claims.take([("confirm", key, n, ref)], hub._email(caller), work.now())
doc = f"{production_order_id}:activity:{op_no}:{n}"
jid = derived_id(doc, "activity")
repo.floor.add_confirmation({"production_order_id": production_order_id, "op_no": op_no, "confirmation_no": n,
"work_centre_id": op["work_centre_id"], "machine_minutes": machine_minutes,
"labour_minutes": labour_minutes, "is_final": final, "cost": cost, "journal_id": str(jid),
"reverses_confirmation_no": 0, "recorded_by": hub._email(caller), "recorded_sub": hub._sub(caller),
"business_date": today, "status": "recorded", "outbox_ref": ref}, work.now())
if cost > 0:
body = _journal(order, today, repo.settings.all(today)["currency"], doc, f"Activity {op_no}.{n} of {production_order_id}",
("wip", cost, ""), ("activity_absorption", cost, wc["cost_centre"]))
repo.outbox.add([{"target": "gl.journal", "ref": ref, "production_order_id": production_order_id, "payload": hub.dump(body)}], work.now())
repo.orders.save(order, ver, loaded)
sent = hub._after_commit(production_order_id) if cost > 0 else {"sent": 0, "rejected": [], "pending": 0}
with hub.repository() as repo, hub._decide():
order, ver, _ = hub._see_order(repo, caller, production_order_id)
return {**hub._order_view(repo, caller, order, ver), "confirmation_no": n, "sent": sent}
# ── scrap (MFG-14) ──────────────────────────────────────────────────────────────────────────────────────────
@MFG.use_case(kind="command", route="POST /scrap-reasons/{reason_code}", permission=hub.CONTROLS, status=201)
def set_scrap_reason(caller: dict, reason_code: Code, description: Reason, abnormal: bool = True) -> dict:
"""Name a scrap reason (BURNT, UNDER_ROASTED and SPILLED exist) and say whether it is ABNORMAL loss. Only abnormal loss can be reported
as scrap: the normal loss of the process is the BOM's planned yield, and scrap is never capitalised into the output."""
with hub.repository() as repo, hub._decide():
repo.floor.set_reason(reason_code.strip().upper(), description.strip(), abnormal, work.now())
return {"reasons": repo.floor.reasons()}
@MFG.use_case(kind="query", route="GET /scrap-reasons", permission="mfg:order:read")
def list_scrap_reasons(caller: dict) -> dict:
"""The scrap reasons and whether each is abnormal loss."""
with hub.repository() as repo:
return {"reasons": repo.floor.reasons()}
@MFG.use_case(kind="command", route="POST /production-orders/{production_order_id}/scrap", permission="mfg:order:scrap", status=201)
def report_scrap(caller: dict, production_order_id: Code, quantity: Qty, reason_code: Code) -> dict:
"""Report output that was lost with a reason (a burnt batch): it is recorded on the order and NEVER received into stock. At close its
standard value is posted as abnormal scrap, an expense (IAS 2.16), and only the rest of the WIP balance is variance. Above
scrap_approval_threshold at standard it waits for a planner's approval - never the operator's own - and the order cannot close until
it is decided."""
with hub.repository() as repo, hub._decide():
order, ver, loaded = hub._see_order(repo, caller, production_order_id)
today = hub._today(repo)
order.start_work()
code = reason_code.strip().upper()
reasons = repo.floor.reasons()
if code not in reasons:
raise RuleBroken(f"{code} is not a scrap reason ({', '.join(sorted(reasons))})", gate="scrap_reason")
if not reasons[code]:
raise RuleBroken(f"{code} is normal loss: that is the BOM's planned yield, not scrap - report the lower output", gate="normal_loss")
frozen = order.frozen
base = quantity * Decimal(frozen["output_quantity_base"]) / Decimal(frozen["output_quantity"])
if base != q4(base):
raise OutOfBounds(f"{quantity} {frozen['output_unit_code']} does not convert exactly to {frozen['output_base_unit_code']}")
cost = seams.unit_cost(order.product_id, order.company_code, today.isoformat())
if cost is None:
raise RuleBroken(f"Inventory holds no cost for {order.product_id}: the scrap cannot be valued", gate="standard_price")
value = money(base * cost, "the scrap's standard value")
threshold = Decimal(repo.settings.all(today)["scrap_approval_threshold"])
status = "pending_approval" if value > threshold else "approved"
key = production_order_id
n = repo.claims.counts("scrap", [key])[key] + 1
repo.claims.take([("scrap", key, n, f"mfg:{production_order_id}:scrap:{n}")], hub._email(caller), work.now())
repo.floor.add_scrap({"production_order_id": production_order_id, "scrap_no": n, "product_id": order.product_id,
"quantity": quantity, "unit_code": frozen["output_unit_code"], "quantity_base": base, "reason_code": code,
"standard_value": value, "status": status, "recorded_by": hub._email(caller), "recorded_sub": hub._sub(caller),
"approved_by": "", "approved_sub": "", "business_date": today}, work.now())
repo.orders.save(order, ver, loaded)
order, ver, _ = hub._see_order(repo, caller, production_order_id)
return {**hub._order_view(repo, caller, order, ver), "scrap_no": n, "scrap_status": status}
@MFG.use_case(kind="command", route="POST /production-orders/{production_order_id}/scrap/{scrap_no}/approve", permission="mfg:order:release")
def approve_scrap(caller: dict, production_order_id: Code, scrap_no: int) -> dict:
"""A PLANNER approves scrap above the threshold - never whoever reported it (email or sub). Until it is approved the order cannot close."""
with hub.repository() as repo, hub._decide():
order, _, _ = hub._see_order(repo, caller, production_order_id)
row = next((x for x in repo.floor.scraps(production_order_id) if x["scrap_no"] == scrap_no), None)
if row is None:
raise Refused(404, f"no scrap {scrap_no} on {production_order_id}")
if row["status"] != "pending_approval":
raise RuleBroken(f"Scrap {scrap_no} is {row['status']}: only scrap waiting for approval is approved", gate="state")
if hub._same_person(caller, row["recorded_by"], row["recorded_sub"]):
raise RuleBroken("You reported this scrap yourself: a planner approves it", gate="second_person", fix="ask another planner")
repo.floor.decide_scrap(production_order_id, scrap_no, "approved", hub._email(caller), hub._sub(caller))
order, ver, _ = hub._see_order(repo, caller, production_order_id)
return hub._order_view(repo, caller, order, ver)
# ── reverse_entry ───────────────────────────────────────────────────────────────────────────────────────────
@MFG.use_case(kind="command", route="POST /production-orders/{production_order_id}/reverse", permission="mfg:order:reverse", status=201)
def reverse_entry(caller: dict, production_order_id: Code, entry_ref: Code, reason: Reason) -> dict:
"""Put right your OWN wrong entry, the SAME business day, while the order is still open: an issue (mfg:<order>:issue:<line>:<seq>) goes back
to stock, a confirmation (mfg:<order>:activity:<op>:<n>) is mirrored by an opposite journal, scrap (mfg:<order>:scrap:<n>) is voided. The
original is never edited; the reversal has its own reference (…:reverse) and is sent to inventory or gl after the commit. An output cannot
be reversed here: inventory has no route to take received output back out."""
with hub.repository() as repo, hub._decide():
order, ver, loaded = hub._see_order(repo, caller, production_order_id)
today = hub._today(repo)
if not reason.strip():
raise OutOfBounds("a reversal needs a reason")
order.start_work()
parts = entry_ref.split(":")
if len(parts) < 4 or parts[0] != "mfg" or parts[1] != production_order_id:
raise Refused(404, f"{entry_ref} is not an entry of {production_order_id}")
kind = parts[2]
if entry_ref.endswith(":reverse") or ":reverse" in entry_ref:
raise RuleBroken("A reversal is not itself reversed: record the entry again", gate="reversal")
if kind == "issue" and len(parts) == 5:
outbox, sent_doc = _reverse_issue(repo, caller, order, entry_ref, today)
elif kind == "activity" and len(parts) == 5:
outbox, sent_doc = _reverse_confirmation(repo, caller, order, int(parts[3]), int(parts[4]), entry_ref, today)
elif kind == "scrap" and len(parts) == 4:
_reverse_scrap(repo, caller, order, int(parts[3]), entry_ref, today)
outbox = False
elif kind in ("output", "byproduct"):
raise RuleBroken("Inventory has no route to take received output back out: a wrong output cannot be reversed here",
gate="output_not_reversible", fix="ask the stock owner for an inventory adjustment")
else:
raise Refused(404, f"{entry_ref} is not an entry of {production_order_id}")
repo.orders.save(order, ver, loaded)
sent = hub._after_commit(production_order_id) if outbox else {"sent": 0, "rejected": [], "pending": 0}
hub._refuse_if_rejected(sent)
with hub.repository() as repo, hub._decide():
order, ver, _ = hub._see_order(repo, caller, production_order_id)
return {**hub._order_view(repo, caller, order, ver), "reversed": entry_ref, "sent": sent}
def _own_same_day(caller: dict, who: str, sub: str, day, today, what: str) -> None:
if not hub._same_person(caller, who, sub):
raise RuleBroken(f"{what} was recorded by someone else: you reverse only your own entries", gate="own_entry")
if day != today:
raise RuleBroken(f"{what} was recorded on {day}: a wrong entry is reversed the same business day ({today})", gate="same_day",
fix="ask a planner or cost accountant to correct it another way")
def _next_reverse(repo, caller: dict, entry_ref: str, ref_of) -> tuple:
"""(n, ref): reversals of one entry are numbered; a refused one may be tried again, a taken one is refused as already reversed."""
n = repo.claims.counts("reverse", [entry_ref])[entry_ref] + 1
ref = ref_of(n)
repo.claims.take([("reverse", entry_ref, n, ref)], hub._email(caller), work.now())
return n, ref
def _reverse_issue(repo, caller: dict, order, entry_ref: str, today) -> tuple:
movements = repo.movements.for_order(order.production_order_id)
orig = next((m for m in movements if m["move_ref"] == entry_ref and m["kind"] == "issue"), None)
if orig is None:
raise Refused(404, f"no issue {entry_ref} on {order.production_order_id}")
_own_same_day(caller, orig["recorded_by"], orig["recorded_sub"], orig["business_date"], today, "the issue")
if orig["status"] != "sent":
raise RuleBroken(f"The issue is {orig['status']} with inventory: nothing to return yet" if orig["status"] == "pending"
else "Inventory refused that issue: there is nothing to reverse", gate="not_taken")
if any(m["kind"] == "issue_reverse" and m["move_ref"].startswith(entry_ref + ":reverse") and m["status"] != "rejected" for m in movements):
raise RuleBroken("That issue is already reversed", gate="already_reversed")
n, ref = _next_reverse(repo, caller, entry_ref, lambda k: entry_ref + (":reverse" if k == 1 else f":reverse:{k}"))
unit_cost = q4(orig["value"] / orig["quantity_base"]) if orig["value"] is not None and orig["quantity_base"] else Decimal("0")
repo.movements.add([{"move_ref": ref, "production_order_id": order.production_order_id, "kind": "issue_reverse", "line_no": orig["line_no"],
"seq": orig["seq"], "product_id": orig["product_id"], "lot_code": orig["lot_code"], "quantity": orig["quantity"],
"unit_code": orig["unit_code"], "quantity_base": orig["quantity_base"], "base_unit_code": orig["base_unit_code"],
"location_id": orig["location_id"], "over_issue": False, "unit_cost": unit_cost, "status": "pending",
"recorded_by": hub._email(caller), "recorded_sub": hub._sub(caller), "business_date": today}], work.now())
body = {"move_ref": ref, "source_kind": "production_receipt", "source_id": order.production_order_id, "company_code": order.company_code,
"product_id": orig["product_id"], "quantity": _s(orig["quantity_base"]), "unit_code": orig["base_unit_code"],
"location_id": orig["location_id"], "received_on": today.isoformat(), "unit_cost": _s(unit_cost),
"currency": repo.settings.all(today)["currency"], "reverses_move_ref": entry_ref,
**({"lot_code": orig["lot_code"]} if orig["lot_code"] else {})}
repo.outbox.add([{"target": "inventory.receipt", "ref": ref, "production_order_id": order.production_order_id, "payload": hub.dump(body)}],
work.now())
return True, ref
def _reverse_confirmation(repo, caller: dict, order, op_no: int, no: int, entry_ref: str, today) -> tuple:
rows = repo.floor.confirmations(order.production_order_id)
orig = next((c for c in rows if c["op_no"] == op_no and c["confirmation_no"] == no), None)
if orig is None or orig["reverses_confirmation_no"]:
raise Refused(404, f"no confirmation {entry_ref} on {order.production_order_id}")
_own_same_day(caller, orig["recorded_by"], orig["recorded_sub"], orig["business_date"], today, "the confirmation")
if orig["status"] == "void":
raise RuleBroken("gl refused that confirmation's journal: there is nothing to reverse", gate="not_taken")
if any(c["reverses_confirmation_no"] == no and c["op_no"] == op_no and c["status"] != "void" for c in rows):
raise RuleBroken("That confirmation is already reversed", gate="already_reversed")
key = f"{order.production_order_id}:{op_no}"
new_no = repo.claims.counts("confirm", [key])[key] + 1
repo.claims.take([("confirm", key, new_no, f"mfg:{order.production_order_id}:activity:{op_no}:{no}:reverse")], hub._email(caller), work.now())
rev_n = repo.claims.counts("reverse", [entry_ref])[entry_ref] + 1
suffix = ":reverse" if rev_n == 1 else f":reverse:{rev_n}"
ref = entry_ref + suffix
repo.claims.take([("reverse", entry_ref, rev_n, ref)], hub._email(caller), work.now())
doc = f"{order.production_order_id}:activity:{op_no}:{no}{suffix}"
jid = derived_id(doc, "activity")
cost = orig["cost"]
wc = repo.masters.work_centre(orig["work_centre_id"])
repo.floor.add_confirmation({"production_order_id": order.production_order_id, "op_no": op_no, "confirmation_no": new_no,
"work_centre_id": orig["work_centre_id"], "machine_minutes": orig["machine_minutes"],
"labour_minutes": orig["labour_minutes"], "is_final": False, "cost": -cost, "journal_id": str(jid),
"reverses_confirmation_no": no, "recorded_by": hub._email(caller), "recorded_sub": hub._sub(caller),
"business_date": today, "status": "recorded", "outbox_ref": ref}, work.now())
if cost > 0:
body = _journal(order, today, repo.settings.all(today)["currency"], doc, f"Reversal of activity {op_no}.{no} of {order.production_order_id}",
("activity_absorption", cost, wc["cost_centre"]), ("wip", cost, ""))
repo.outbox.add([{"target": "gl.journal", "ref": ref, "production_order_id": order.production_order_id, "payload": hub.dump(body)}], work.now())
return cost > 0, ref
def _reverse_scrap(repo, caller: dict, order, scrap_no: int, entry_ref: str, today) -> None:
row = next((x for x in repo.floor.scraps(order.production_order_id) if x["scrap_no"] == scrap_no), None)
if row is None:
raise Refused(404, f"no scrap {entry_ref} on {order.production_order_id}")
_own_same_day(caller, row["recorded_by"], row["recorded_sub"], row["business_date"], today, "the scrap")
if row["status"] == "void":
raise RuleBroken("That scrap is already reversed", gate="already_reversed")
_next_reverse(repo, caller, entry_ref, lambda k: entry_ref + ":reverse")
repo.floor.void_scrap(order.production_order_id, scrap_no)