dodil/erp-brick-purchasing

DODIL ERP brick: purchasing

src/brick_purchasing/application/process.yaml

# The brick's process — module.yaml's workflows, copied (pattern.md §15). BRICK.declare_process serves
# it as BPMN 2.0 at GET /process/bpmn; it is identical to `erp-compose bpmn --from module.yaml`.
# A transition's key (or its derived <entity>_<from>_to_<to>) is frozen once signed: it is the BPMN id.
id: purchasing
name: Purchasing & suppliers
version: 8
actors:
- id: requester
  name: whoever needs something
  does: asks for what they need and sees where the request got to
  sees: their own requests
  permissions:
  - purchasing:request:write
  - purchasing:request:read_own
- id: spend_approver
  name: budget holder
  does: approves or refuses spend before it is committed, within their limit, and asks for things themselves like anyone else
  sees: requests and orders routed to them, and the orders that came from them
  permissions:
  - purchasing:request:approve
  - purchasing:order:approve
  - purchasing:agreement:approve
  - purchasing:request:write
  - purchasing:request:read
  - purchasing:order:read
- id: buyer
  name: whoever places orders
  does: turns approved requests into orders, keeps supplier prices, confirms and sends orders, closes them short
  sees: every request and order, every supplier price
  permissions:
  - purchasing:order:write
  - purchasing:order:confirm
  - purchasing:order:send
  - purchasing:request:read
  - purchasing:order:read
  - purchasing:supplier:write
  - purchasing:supplier:read
  - purchasing:supplier_price:write
  - purchasing:receipt:read
  - purchasing:rfq:write
  - purchasing:agreement:write
  - purchasing:source:write
  - purchasing:evaluation:write
  - purchasing:down_payment:request
- id: receiver
  name: whoever takes delivery (stores, site, reception)
  does: records what actually arrived and what goes back to the supplier
  sees: orders still to arrive, and receipts
  permissions:
  - purchasing:receipt:write
  - purchasing:receipt:read
  - purchasing:order:read_expected
  - purchasing:subcontract:provide
- id: payables_clerk
  name: accounts payable
  does: records supplier invoices and credit notes, disputes them, proposes payment runs, confirms what the bank paid, keeps supplier bank details
  sees: every bill, credit, run and payment
  permissions:
  - purchasing:bill:write
  - purchasing:bill:read
  - purchasing:payment:write
  - purchasing:payment:read
  - purchasing:order:read
  - purchasing:receipt:read
  - purchasing:supplier:read
  - purchasing:supplier_bank:write
  - purchasing:down_payment:clear
  - purchasing:self_billing:write
  - purchasing:ers:run
  - purchasing:consignment:settle
- id: payables_approver
  name: whoever releases money (finance manager)
  does: approves bills, accepts match variances with a reason, approves payment runs and supplier bank-detail changes, keeps day-to-day settings
  sees: everything payables sees
  permissions:
  - purchasing:bill:approve
  - purchasing:bill:read
  - purchasing:payment:approve
  - purchasing:payment:read
  - purchasing:supplier_bank:approve
  - purchasing:supplier:read
  - purchasing:settings:write
  - purchasing:migration:read
  - purchasing:down_payment:approve
  - purchasing:self_billing:approve
- id: owner
  name: The owner (or whoever the owner names)
  does: holds the controls on the people who spend and pay (approval limits and the release strategy, tolerance keys, the order approval threshold, number formats, the payment-file format and the BACS service user number, vendor-evaluation weights, the self-billing maximum) and signs the migration reconciliation
  sees: everything
  permissions:
  - purchasing:bill:read
  - purchasing:order:read
  - purchasing:settings:controls
  - purchasing:migration:read
  - purchasing:migration:sign
  note: 'the controls are never held by whoever they control: a spend_approver, buyer, clerk or payables_approver holding purchasing:settings:controls is a configuration error the access test refuses'
- id: supplier
  name: The supplier
  external: true
  does: receives the order, delivers, sends an invoice, is paid
  sees: the order sent to them; nothing in the system
  permissions: []
- id: system
  name: The system
  does: stamps numbers, hands receipts to inventory, computes the match, asks tax, posts to the ledger, writes the bank file, reads a supplier's PDF into a draft (suggest), loads and reconciles a migration
  sees: everything, through its own service account
  permissions:
  - purchasing:number_series:claim
  - purchasing:receipt:hand_over
  - purchasing:bill:post
  - purchasing:payment:post
  - purchasing:payment:export
  - purchasing:bill:ai_read
  - purchasing:bill:ai_check
  - purchasing:migration:load
  - purchasing:migration:reconcile
  - purchasing:consignment:intake
  - purchasing:source:propose
  - purchasing:asset:hand_over
  - purchasing:cis:record
  - purchasing:request:intake
  - purchasing:payment:confirm
  note: each is an exact grant to one service client, never reached by a wildcard (permissions.machine); purchasing:consignment:intake and purchasing:source:propose go to inventory's client
workflows:
- entity: request
  process: procure
  states:
  - draft
  - awaiting_approval
  - approved
  - refused
  - ordered
  - closed
  - cancelled
  terminal:
  - refused
  - closed
  - cancelled
  transitions:
  - key: draft_request
    name: Ask for what you need
    from: none
    to: draft
    by: requester
    effect: request_id derived from the requester and the moment they asked
  - key: intake_request_from_sales_order
    name: Take a request from a confirmed third-party order line (PUR-65)
    from: none
    to: awaiting_approval
    by: system
    gate: request_ref so:{sales_order_id}:{line_no}@r{revision} unique (a repeat answers the same); the product can be bought
    effect: a request carrying the ship-to party and address and the sales order line; the order raised from it is a drop-ship order
  - key: intake_request_from_replenishment
    name: Take a request from inventory's replenishment (PUR-65)
    from: none
    to: awaiting_approval
    by: system
    gate: request_ref inv:repl:{warehouse_id}:{product_id}:{business_date} unique (a repeat answers the same); the product can be bought
    effect: a request for the warehouse's goods-in location, needed by the date inventory named
  - key: submit_request
    name: Submit the request
    from: draft
    to: awaiting_approval
    by: requester
    gate: a line, an amount, a cost centre and a reason
    effect: routed to the approvers whose limit in force today covers it
  - key: approve_request_level
    name: Approve one release level
    from: awaiting_approval
    to: awaiting_approval
    by: spend_approver
    approval: true
    gate: the release strategy in force on the submit date needs more levels than are approved; the approver's limit meets this level; the approver is not the requester and approved no earlier level (email AND sub)
    effect: a release row (request, level, who, email and sub, when) (v4, PUR-38)
  - key: approve_request
    name: Approve the spend
    from: awaiting_approval
    to: approved
    by: spend_approver
    approval: true
    gate: 'the final level: the amount is within this approver''s limit in force today; the approver is not the requester nor any earlier level''s approver (email AND sub)'
    effect: who, when (today in the business time zone) and any note recorded
  - key: refuse_request
    name: Refuse with a reason
    from: awaiting_approval
    to: refused
    by: spend_approver
    requires: reason
  - key: raise_order
    name: Raise the order
    from: approved
    to: ordered
    by: buyer
    gate: a supplier is chosen
    effect: an order exists, keyed req:<request_id>; the approval travels with it
  - key: close_request
    name: Close without ordering
    from: approved
    to: closed
    by: buyer
    requires: reason
  - key: cancel_request
    name: Cancel the request (v8; PUR-67)
    from:
    - draft
    - awaiting_approval
    - approved
    to: cancelled
    by:
    - requester
    - buyer
    requires: reason
    gate: not ordered; a requester cancels only their own request (purchasing:request:write), a buyer any request (purchasing:order:write)
    effect: who (email and sub), when and why recorded; the request leaves the approval queue and inventory's expected stock
  - key: cancel_request_from_sales_order
    name: Cancel a request sales-order raised (v8; PUR-67)
    from:
    - awaiting_approval
    - approved
    to: cancelled
    by: system
    requires: reason
    gate: request_ref so:... raised by the caller; not ordered (an ordered request answers not_cancellable naming its order and supplier)
    effect: as cancel_request; a repeat answers the same
  - key: cancel_with_order
    name: Cancel the request with its order (v8; PUR-67)
    from: ordered
    to: cancelled
    by: system
    gate: the purchase order raised from it (req:<request_id>) is cancelled
    effect: the request ends with the order; the order's reason copied
  note: 'v8: ordered is no longer an end state: a request ordered stays ordered unless its order is cancelled, which cancels the request with it. An ordered request is never cancelled on its own, whether or not the supplier has confirmed the order: the commitment is the order''s, and cancel_purchase_order (nothing received, nothing billed) is the one control over it.'
  readiness:
  - transition: cancel_request
    gate: not_ordered
    reason: the request is ordered as {order_no} at {supplier}
    fix: cancel the purchase order (PUR-08); the request follows it
  - transition: cancel_request_from_sales_order
    gate: not_cancellable
    reason: the request is ordered as {order_no} at {supplier}
    fix: the buyer cancels the purchase order; then cancel again
  - transition: submit_request
    gate: complete
    reason: the request has no line, amount, cost centre or reason
    fix: fill in what is missing
  - transition: approve_request
    gate: within_limit
    reason: the amount is above your limit in force today for this category
    fix: 'an approver with a higher limit (Settings: approval limits)'
  - transition: approve_request
    gate: second_person
    reason: you asked for this (same email or same sign-in)
    fix: another approver
  - transition: approve_request_level
    gate: second_person
    reason: you asked for this, or approved an earlier level (same email or same sign-in)
    fix: another approver
  - transition: approve_request_level
    gate: level_limit
    reason: your limit is below this level's threshold
    fix: 'an approver of this level (Settings: release strategy)'
  - transition: raise_order
    gate: supplier_known
    reason: the supplier is not in the partner register
    fix: add it (promote, role supplier)
- entity: order
  process: procure
  states:
  - draft
  - to_approve
  - approved
  - confirmed
  - closed
  - cancelled
  terminal:
  - closed
  - cancelled
  transitions:
  - key: raise_from_request
    name: Draft the order from the request
    from: none
    to: draft
    by: buyer
    gate: an approved request
    effect: keyed req:<request_id>; a second raise returns the same order
  - key: draft_order
    name: Draft an order directly
    from: none
    to: draft
    by: buyer
    effect: keyed po:<buyer>:<moment>
  - key: raise_from_quote
    name: Draft the order from the awarded quote
    from: none
    to: draft
    by: buyer
    gate: an awarded quote (PUR-40)
    effect: keyed rfq:<rfq_id>:<bp_id>; the quoted prices fixed on the lines; the request's approval carried (v4)
  - key: raise_release_order
    name: Draft a release order against a contract
    from: none
    to: draft
    by: buyer
    gate: an active quantity or value contract in its validity
    effect: keyed po:<buyer>:<moment> with agreement_id; prices from the agreement line; the agreement's approval is the spend approval within its target (v4, PUR-42)
  - key: open_call_off
    name: Open a call-off from a JIT release
    from: none
    to: confirmed
    by: system
    gate: a JIT release of an active scheduling agreement
    effect: keyed sa:<agreement_id>:<release_no>, confirmed at once with order_no from the series; the agreement claims written (v4, PUR-43)
  - key: price_lines
    name: Price the lines in the supplier's unit
    from: draft
    to: draft
    by: buyer
    effect: unit_code and its exact conversion copied from the product; the order price unit, its conversion (the supplier price row's own, else the product's), pricing_quantity and unit_price from the supplier price in force today, fixed on the line; net = quantity_priced x unit_price / pricing_quantity, rounded once; the purchase rounding gate shown (v3); the source list checked, the quota's proposal shown, the account assignment (stock | cost_centre | asset | project), item category (standard | consignment | subcontract), subcontract components and planned delivery costs set (v4)
  - key: accept_rounding
    name: Accept an order off the pack rule
    from: draft
    to: draft
    by: spend_approver
    approval: true
    requires: reason
    gate: a line fails purchase_rounding; the acceptor is not the buyer (email AND sub)
    effect: an override row (order, revision, line, gate purchase_rounding, who, why), before the order is sent for approval or confirmed; the buyer confirms (v3)
  - key: approve_order_level
    name: Approve one release level
    from: to_approve
    to: to_approve
    by: spend_approver
    approval: true
    gate: the release strategy in force on the submit date needs more levels than are approved; the approver's limit meets this level; not the buyer nor an earlier level's approver (email AND sub)
    effect: a release row (order, revision, level, who) (v4, PUR-38)
  - key: submit_order
    name: Send for approval
    from: draft
    to: to_approve
    by: buyer
    gate: no approved request covers the total, and the total is above the owner's order threshold
  - key: approve_order
    name: Approve the order
    from: to_approve
    to: approved
    by: spend_approver
    approval: true
    gate: 'the final level: the total is within the approver''s limit in force today; the approver is not the buyer nor an earlier level''s approver (email AND sub)'
  - key: reject_order
    name: Send back with a reason
    from: to_approve
    to: draft
    by: spend_approver
    requires: reason
  - key: confirm_purchase_order
    name: Confirm the order
    from:
    - draft
    - approved
    to: confirmed
    by: buyer
    gate: within authority (an approved request or approval covers the total, or below the threshold); every line has a unit and a pricing unit that convert; every line meets its purchase rounding or carries an override for this revision (v3); every line's supplier is allowed by the source list; a release order fits what is left on its agreement; an asset-assigned line is assignable; a subcontract line has its components (v4)
    effect: order_no claimed from the series; contents, each line's pricing conversion and account assignment frozen; product told each unit and pricing unit was used; agreement claims written (v4)
  - key: send_order
    name: Send it to the supplier
    from: confirmed
    to: confirmed
    by: buyer
    effect: each send recorded with when and to whom
  - key: provide_components
    name: Send the subcontractor our components
    from: confirmed
    to: confirmed
    by: receiver
    gate: a subcontract line; not more than the line needs, unless a reason is given
    effect: 'inventory''s POST /subcontract-provisions per component, keyed pur:<purchase_order_id>:<line_no>:prov:<seq>: the components move to stock held at the supplier, still ours (v4, PUR-57)'
  - key: receive_order
    name: The supplier delivers
    from: confirmed
    to: confirmed
    by: supplier
  - key: amend_purchase_order
    name: Amend the confirmed order
    from: confirmed
    to: confirmed
    by: buyer
    gate: the new total is still within what was approved; a line is never reduced below what was received or billed
    effect: a new revision row; the previous revision kept
  - key: amend_beyond_approval
    name: Amend beyond what was approved
    from: confirmed
    to: to_approve
    by: buyer
    gate: the new total exceeds what was approved
  - key: close_short
    name: Close short with a reason
    from: confirmed
    to: closed
    by: buyer
    requires: reason
    effect: what was not received stops being outstanding and committed
  - key: finish_purchase_order
    name: Finish the order
    from: confirmed
    to: closed
    by: system
    gate: 'every line received and billed in full (or two-way: billed)'
  - key: cancel_purchase_order
    name: Cancel the order
    from:
    - draft
    - to_approve
    - approved
    - confirmed
    to: cancelled
    by: buyer
    requires: reason
    gate: nothing received and nothing billed
    effect: 'a confirmed order keeps its number; v8: the request it was raised from is cancelled with it (cancel_with_order)'
  note: received, billed and outstanding are DERIVED from receipt and bill rows; part_received is not a state. A stored status and a receipt table disagree the first time someone returns goods.
  readiness:
  - transition: confirm_purchase_order
    gate: authority
    reason: the total is above the order threshold and no approval covers it
    fix: send it for approval (PUR-09)
  - transition: confirm_purchase_order
    gate: units_convert
    reason: a line's unit has no exact conversion to the product's base unit
    fix: 'Product: add the unit with its conversion'
  - transition: confirm_purchase_order
    gate: purchase_rounding
    reason: 'a line is not a multiple (or is below the minimum) of its purchase unit''s rule: COLA-075 is bought in multiples of 80 PK24'
    fix: order the next valid quantity (shown), or ask a second person to accept it with a reason (PUR-37)
  - transition: confirm_purchase_order
    gate: pricing_unit_converts
    reason: a line's order price unit has no exact conversion to the product's base unit
    fix: 'Product: add the unit, or price the line in its order unit'
  - transition: accept_rounding
    gate: second_person
    reason: you raised this order (same email or same sign-in)
    fix: another budget holder
  - transition: confirm_purchase_order
    gate: number_series
    reason: no order number series for this year
    fix: 'Settings: number format (owner)'
  - transition: approve_order
    gate: within_limit
    reason: the total is above your limit in force today
    fix: an approver with a higher limit
  - transition: approve_order
    gate: second_person
    reason: you raised this order (same email or same sign-in)
    fix: another approver
  - transition: approve_order_level
    gate: second_person
    reason: you raised this order, or approved an earlier level (same email or same sign-in)
    fix: another approver
  - transition: confirm_purchase_order
    gate: source_allowed
    reason: the supplier is not on the product's source list, or is blocked on the order date
    fix: pick an allowed supplier, or ask the buyer who keeps the list (PUR-44)
  - transition: confirm_purchase_order
    gate: within_agreement
    reason: the release is more than what is left on the contract (or the contract has expired)
    fix: release up to what is left, or revise the contract (a new revision approved)
  - transition: confirm_purchase_order
    gate: asset_assignable
    reason: the asset does not exist, is of another company, is retired, or the line is below the capitalisation threshold
    fix: pick another asset or class, or expense the line (cost_centre)
  - transition: confirm_purchase_order
    gate: components_listed
    reason: a subcontract line has no components
    fix: list what we provide to the supplier
  - transition: provide_components
    gate: within_need
    reason: more than the line needs of this component
    fix: lower the quantity, or give the reason (scrap allowance)
  - transition: amend_purchase_order
    gate: not_below_received
    reason: a line would fall below what was received or billed
    fix: return the goods first (PUR-26) or ask the supplier for a credit
  - transition: cancel_purchase_order
    gate: nothing_received
    reason: goods were received or billed against it
    fix: close it short instead (PUR-08)
- entity: receipt
  process: procure
  states:
  - recorded
  - handed_over
  terminal:
  - handed_over
  transitions:
  - key: record_goods_receipt
    name: Record what arrived
    from: none
    to: recorded
    by: receiver
    gate: a confirmed order; the delivery date is today or earlier; each quantity is above zero
    effect: keyed rcpt:<purchase_order_id>:<delivery_note>; quantity also kept in the base unit
  - key: record_return
    name: Return goods to the supplier
    from: none
    to: recorded
    by: receiver
    requires: reason
    gate: no more than was received on the receipt it names
    effect: a reversing receipt row (kind return) dated today; the original receipt untouched
  - key: hand_to_inventory
    name: Tell inventory what arrived and where
    from: recorded
    to: handed_over
    by: system
    gate: inventory is installed
    effect: 'inventory''s POST /receipts (a return: POST /returns-to-supplier) per line, keyed move_ref pur:<receipt_id>:<line_no>; {move_id, quantity_base} answered and the move id kept on the line; a consignment line names stock_owner_bp_id, a subcontract line consume_provided, a line with planned delivery costs a unit cost including them (v4)'
  - key: no_stock_kept
    name: Keep the receipt without stock
    from: recorded
    to: handed_over
    by: system
    gate: inventory is not installed, the line is a service, or the line is assigned to an asset, a cost centre or a project (non-valuated; v4)
  note: a receipt is a fact and is never edited; a wrong receipt is corrected by a return row, and a missing one by another receipt
  readiness:
  - transition: record_goods_receipt
    gate: not_future
    reason: a delivery cannot be dated after today
    fix: record it on the day it arrives
  - transition: record_goods_receipt
    gate: location
    reason: inventory is installed and the line has no location
    fix: pick the receiving location
  - transition: record_return
    gate: within_received
    reason: the return is more than was received on that delivery
    fix: lower the quantity
- entity: bill
  process: payables
  states:
  - draft
  - recorded
  - approved
  - disputed
  - cancelled
  terminal:
  - approved
  - cancelled
  transitions:
  - key: ai_read_bill
    name: AI reads the supplier's PDF into a draft (suggest)
    from: none
    to: draft
    by: system
    gate: a PDF was received and the step is on
    effect: each field a suggestion with its evidence, in pur_ai_finding
  - key: accept_ai_fields
    name: Accept or correct each read field
    from: draft
    to: draft
    by: payables_clerk
    effect: each decision recorded; nothing is recorded without the clerk
  - key: record_bill
    name: Record the supplier's invoice or credit
    from:
    - none
    - draft
    to: recorded
    by: payables_clerk
    gate: supplier, their number and date, lines against order lines, consignment withdrawals or delivery-cost conditions (or none for a two-way service bill); each line's cis_part for a CIS subcontractor (v4)
    effect: keyed (bp_id, supplier_invoice_no, invoice_date); tax asked of the tax module; the match computed
  - key: record_intercompany_bill
    name: Record the bill another of our companies sent (PUR-64)
    from: none
    to: recorded
    by: system
    gate: the sending company and the receiving company are both companies of these books; bill_id ic:<invoice_id> unique (a repeat answers the same bill)
    effect: a bill of the receiving company with ar's lines and frozen tax mapped to purchase codes, ic_reference on every line; matched two-way to those lines
  - key: ai_check_bill
    name: AI checks the bill for duplicates and anomalies (suggest)
    from: recorded
    to: recorded
    by: system
    gate: the step is on
    effect: a likely duplicate (same amount, near date, other number), an unusual amount or a changed bank listed for the clerk
  - key: match_bill
    name: Match against the order and the receipts
    from: recorded
    to: recorded
    by: system
    effect: 'per line: price (per pricing quantity of the line''s pricing unit), quantity (in the pricing unit, through the line''s fixed conversion, v3) and tax, each pass or fail with the difference as an exact fraction and the tolerance in force on the invoice date, named by its key (PP, DQ, AN, AP, ST, KW, LA, LD) for the supplier''s tolerance group; a total difference within BD accepted by the system as a small difference (v4); derived, never stored'
  - key: accept_variance
    name: Accept a match difference with a reason
    from: recorded
    to: recorded
    by: payables_approver
    requires: reason
    approval: true
    gate: the line fails; the approver is not the clerk who recorded it (email AND sub)
    effect: an acceptance row (bill, line, kind, difference, who, why)
  - key: dispute_bill
    name: Dispute the bill
    from:
    - recorded
    - approved
    to: disputed
    by: payables_clerk
    requires: reason
  - key: resolve_bill_dispute
    name: Resolve the dispute
    from: disputed
    to: recorded
    by: payables_clerk
    requires: outcome
    effect: back to recorded; an approved bill disputed and resolved returns to approved through approve_bill again (a no-op re-check)
  - key: approve_bill
    name: Approve the bill
    from: recorded
    to: approved
    by: payables_approver
    approval: true
    gate: 'every line matches or its difference is accepted; tax contents pass; the period is open; the billed quantity still fits under what was received; v8: every cost centre on the bill''s lines is open in gl on the invoice date (GET /cost-centres/{cost_centre})'
    effect: bill_no claimed; tax frozen; billed quantity claimed per order line; posted to the ledger
  - key: post_bill
    name: Post to the ledger
    from: approved
    to: approved
    by: system
    gate: the ledger is installed
    effect: a balanced journal keyed on the bill and event approve; an asset-assigned line debits asset_clearing, a consignment line consignment_clearing, a delivery-cost line delivery_cost_clearing (v4)
  - key: hand_asset_to_register
    name: Hand asset lines to the asset register
    from: approved
    to: approved
    by: system
    gate: fixed-assets is installed and a line is assigned to an asset
    effect: fixed-assets' POST /acquisitions per line keyed pur:<bill_id>:<line_no>; the transaction id kept on the line; a refusal listed and retried (v4, PUR-56)
  - key: hand_cost_difference
    name: Hand a delivery-cost difference to inventory
    from: approved
    to: approved
    by: system
    gate: a delivery-cost line differs from its planned cost and inventory is installed
    effect: inventory's POST /valuation-adjustments keyed pur:<bill_id>:<line_no> (v4, PUR-63)
  - key: settle_ers
    name: Settle receipts by self-billing
    from: none
    to: approved
    by: payables_clerk
    gate: an active self-billing agreement on each receipt's date; order lines flagged ers; receipts not yet billed
    effect: a self-billed invoice per supplier, currency and tax point, numbered from our SB series; tax computed (self_billed true) and frozen; recorded and approved by the run; posted; sent to the supplier (v4, PUR-51)
  - key: cancel_bill
    name: Cancel the bill
    from:
    - recorded
    - disputed
    to: cancelled
    by: payables_approver
    requires: reason
    gate: never approved, nothing allocated
  note: 'PAID is derived, like invoicing''s settlement: outstanding = total - SUM(allocations of payments and credits). The match is derived from order, receipt and bill rows plus acceptance rows. An approved bill is never edited; a correction is a supplier credit (kind credit, PUR-27). A migrated bill (source_kind odoo) is approved history: post_bill and the match never run on it; allocations do.'
  readiness:
  - transition: record_bill
    gate: not_duplicate
    reason: this supplier already has that invoice number on that date
    fix: open the existing bill
  - transition: record_bill
    gate: order_open
    reason: the order it names is cancelled
    fix: ask the supplier, or record it without an order and dispute it
  - transition: approve_bill
    gate: match
    reason: a line fails on price, quantity or tax beyond the tolerance, and no acceptance is recorded
    fix: accept the difference with a reason (PUR-12), or dispute it
  - transition: approve_bill
    gate: not_over_billed
    reason: the order line has already been billed for what was received (another bill claimed it)
    fix: wait for the receipt, or dispute
  - transition: approve_bill
    gate: tax_content
    reason: 'tax''s content check failed: the supplier''s VAT number or a required particular is missing'
    fix: ask the supplier for a valid VAT invoice, or record the VAT as not reclaimable
  - transition: approve_bill
    gate: period_open
    reason: the invoice date falls in a closed ledger period
    fix: ask the accountant to reopen, or post in the next open period
  - transition: approve_bill
    gate: cost_centre_open
    reason: line {line_no}'s cost centre {cost_centre} is unknown to the ledger, or closed on {invoice_date}
    fix: pick an open cost centre (gl), or ask the controller to open it
  - transition: approve_bill
    gate: account_map
    reason: no account in force on the invoice date for a category or tax code on the bill
    fix: 'Settings: account map'
  - transition: accept_variance
    gate: second_person
    reason: you recorded this bill (same email or same sign-in)
    fix: another approver
  - transition: settle_ers
    gate: self_billing_active
    reason: no self-billing agreement in force for this supplier on the receipt's date
    fix: 'Suppliers: self-billing agreement (PUR-50), or wait for the supplier''s invoice'
  - transition: record_bill
    gate: cis_parts
    reason: a CIS subcontractor's bill line has no labour or materials split
    fix: split each line (materials only as the supplier's own cost of materials)
- entity: payment_run
  process: payables
  states:
  - draft
  - approved
  - exported
  - done
  - cancelled
  terminal:
  - done
  - cancelled
  transitions:
  - key: propose_run
    name: Propose a run of what falls due
    from: none
    to: draft
    by: payables_clerk
    gate: a pay date today or later and a bank account
    effect: approved, undisputed bills due by the pay date and approved down payments due by it, one payment per supplier and currency; a CIS subcontractor's payment net of the deduction on the labour share paid (tax's rate on the pay date); a bill whose order has an uncleared down payment proposed for its balance after clearing, else left out; left-out bills named (v4)
  - key: approve_run
    name: Approve the run
    from: draft
    to: approved
    by: payables_approver
    approval: true
    gate: 'the approver is not the proposer nor the clerk who recorded any bill in it (email AND sub); every payee''s bank details are approved and in force on the pay date; for a BACS file: GBP, UK sort code and account per payee, the service user number set and the pay date at least the BACS cycle away (v4)'
  - key: cancel_run
    name: Cancel the run
    from:
    - draft
    - approved
    to: cancelled
    by: payables_approver
    requires: reason
    gate: no file written
  - key: export_bank_file
    name: Write the bank file
    from: approved
    to: exported
    by: system
    effect: pain.001, BACS Standard 18 (v4, PUR-59) or CSV kept once as raw/purchasing/runs/<run_id>.<ext>; a second export returns the same file
  - key: upload_to_bank
    name: Upload the file to the bank
    from: exported
    to: exported
    by: payables_clerk
    effect: 'outside DODIL: a person signs it in the bank''s portal'
  - key: confirm_paid
    name: Confirm what the bank paid
    from: exported
    to: exported
    by: payables_clerk
    gate: the bank's reference; the bank's date today or earlier
    effect: allocations written per bill; posted payables / bank (and CIS liability); a down payment posts supplier down payments / bank; each CIS deduction recorded with tax (v4)
  - key: confirm_paid_from_bank
    name: Confirm what the bank paid from the statement (PUR-66)
    from: exported
    to: exported
    by: system
    gate: 'payments matched a statement debit to this payment: the bank''s reference; the bank''s date today or earlier; the payment not already confirmed (a repeat answers the same)'
    effect: 'as confirm_paid: allocations written per bill; the payment posts (post_payment)'
  - key: post_payment
    name: Post the payment to the ledger
    from: exported
    to: exported
    by: system
    gate: the ledger is installed
    effect: journal keyed on the payment and event pay
  - key: reject_payment
    name: Record a payment the bank rejected
    from: exported
    to: exported
    by: payables_clerk
    requires: reason
    effect: reversing allocations and a reversing journal, dated today; the bills are open again; a CIS deduction reversed with tax (v4)
  - key: finish_run
    name: Finish the run
    from: exported
    to: done
    by: system
    gate: every payment confirmed or rejected
  readiness:
  - transition: propose_run
    gate: not_past
    reason: a pay date cannot be before today
    fix: pick today or later
  - transition: approve_run
    gate: second_person
    reason: you proposed this run, or recorded a bill in it (same email or same sign-in)
    fix: another approver
  - transition: approve_run
    gate: bank_details
    reason: a payee has no approved bank details in force on the pay date
    fix: 'Suppliers: bank details (PUR-30), or leave the payee out'
  - transition: propose_run
    gate: cis_verified
    reason: a CIS subcontractor has no verification in force with HMRC
    fix: verify them in Tax (CIS), or pay at the unmatched rate tax answers
  - transition: approve_run
    gate: bacs_ready
    reason: 'the BACS file cannot be written: a payee without a UK sort code and account, a currency other than GBP, no service user number, or the pay date is inside the BACS cycle'
    fix: fix the payee, pick pain.001, or move the pay date to the earliest BACS date shown
  - transition: confirm_paid
    gate: not_future
    reason: a payment cannot be dated after today
    fix: confirm it on the day the bank shows it
- entity: supplier_bank
  process: payables
  states:
  - proposed
  - approved
  - rejected
  terminal:
  - approved
  - rejected
  transitions:
  - key: propose_bank
    name: Enter new bank details
    from: none
    to: proposed
    by: payables_clerk
    gate: starts after today
    effect: a dated row, masked on every screen but this one
  - key: ai_flag_bank_change
    name: AI flags a risky bank change (suggest)
    from: proposed
    to: proposed
    by: system
    gate: the step is on
    effect: a change requested by email, a new country, or just before a large run listed for the approver
  - key: approve_bank
    name: Approve the change
    from: proposed
    to: approved
    by: payables_approver
    approval: true
    gate: the approver is not the person who entered it (email AND sub); confirmed with the supplier by a known phone number (recorded)
  - key: reject_bank
    name: Reject the change
    from: proposed
    to: rejected
    by: payables_approver
    requires: reason
  readiness:
  - transition: propose_bank
    gate: starts_after_today
    reason: new bank details start tomorrow at the earliest
    fix: pick a later date
  - transition: approve_bank
    gate: second_person
    reason: you entered these details (same email or same sign-in)
    fix: another approver
  - transition: approve_bank
    gate: call_back
    reason: the change has not been confirmed with the supplier on a number we already held
    fix: call them and record who confirmed
- entity: rfq
  process: procure
  states:
  - draft
  - sent
  - awarded
  - closed
  terminal:
  - awarded
  - closed
  transitions:
  - key: draft_rfq
    name: Draft a request for quotation
    from: none
    to: draft
    by: buyer
    gate: an approved request, or lines with units
    effect: keyed rfq:<request_id> (or rfq:<buyer>:<moment>); a second draft from the same request returns it
  - key: send_rfq
    name: Send it to the suppliers
    from: draft
    to: sent
    by: buyer
    gate: at least one supplier; a deadline after today; each supplier allowed by the source list
    effect: rfq_no claimed from the series; one invitation per supplier with when and to whom
  - key: record_quote
    name: Record a supplier's quote
    from: sent
    to: sent
    by: buyer
    gate: an invited supplier
    effect: a quote (rfq_id, bp_id, revision) with a price, unit and delivery days per line; after the deadline flagged late
  - key: award_quote
    name: Award the quote
    from: sent
    to: awarded
    by: buyer
    gate: a quote for every line awarded; a reason when it is not the cheapest
    effect: the order drafted keyed rfq:<rfq_id>:<bp_id> (raise_from_quote); the other suppliers listed to be told
  - key: close_rfq
    name: Close without an award
    from:
    - draft
    - sent
    to: closed
    by: buyer
    requires: reason
  readiness:
  - transition: send_rfq
    gate: deadline_after_today
    reason: the deadline is today or earlier
    fix: pick a later deadline
  - transition: send_rfq
    gate: source_allowed
    reason: a supplier is blocked, or not on the source list, for a product that needs one
    fix: remove the supplier, or ask for the source list to be changed
  - transition: award_quote
    gate: reason_if_not_cheapest
    reason: the awarded quote is not the cheapest and no reason is given
    fix: give the reason (delivery, quality, terms)
- entity: purchase_agreement
  process: procure
  states:
  - draft
  - to_approve
  - active
  - closed
  terminal:
  - closed
  transitions:
  - key: draft_agreement
    name: Draft a contract or scheduling agreement
    from: none
    to: draft
    by: buyer
    effect: keyed ag:<bp_id>:<kind>:<valid_from>:<supplier_ref>; kind quantity_contract | value_contract | scheduling_agreement
  - key: submit_agreement
    name: Send for approval
    from: draft
    to: to_approve
    by: buyer
    gate: lines with prices, a target (quantity or value), a validity starting today or later
  - key: approve_agreement
    name: Approve the agreement
    from: to_approve
    to: active
    by: spend_approver
    approval: true
    gate: the target value is within the approver's limit in force today and the release strategy's levels are met; the approver is not the buyer (email AND sub)
    effect: agreement_no claimed; the spend approval for every release within its target and validity
  - key: reject_agreement
    name: Send back with a reason
    from: to_approve
    to: draft
    by: spend_approver
    requires: reason
  - key: release_against_agreement
    name: Release against the contract
    from: active
    to: active
    by: buyer
    gate: within validity and what is left (claims)
    effect: a release order's lines claim the agreement lines (pur_agreement_claim) at confirm_purchase_order
  - key: maintain_schedule
    name: Update the delivery schedule
    from: active
    to: active
    by: buyer
    gate: a scheduling agreement; lines dated today or later; a line released JIT inside the firm zone changes only with the supplier's agreement (reason)
    effect: a new schedule revision; the previous kept
  - key: send_schedule_release
    name: Send a forecast or JIT release
    from: active
    to: active
    by: buyer
    gate: a scheduling agreement with schedule lines
    effect: release_no claimed per agreement, the lines frozen in it; jit opens a call-off order (open_call_off); forecast commits nothing
  - key: revise_agreement
    name: Revise the target or prices
    from: active
    to: to_approve
    by: buyer
    gate: the target or a price is raised
    effect: a new revision; releases continue under the approved one until the new one is approved
  - key: close_agreement
    name: Close the agreement
    from: active
    to: closed
    by: buyer
    requires: reason
  - key: expire_agreement
    name: The agreement ends
    from: active
    to: closed
    by: system
    gate: valid_to is before today (the business date)
  note: released, remaining and the call-offs are DERIVED from claims and releases, never counters
  readiness:
  - transition: approve_agreement
    gate: second_person
    reason: you drafted this agreement (same email or same sign-in)
    fix: another approver
  - transition: approve_agreement
    gate: within_limit
    reason: the target is above your limit in force today
    fix: an approver with a higher limit
  - transition: release_against_agreement
    gate: within_agreement
    reason: the release is more than is left, or the agreement is outside its validity
    fix: release what is left, or revise the agreement
  - transition: maintain_schedule
    gate: firm_zone
    reason: a JIT-released line inside the firm zone would change
    fix: record the supplier's agreement as the reason
- entity: down_payment
  process: payables
  states:
  - requested
  - approved
  - paid
  - cleared
  - refunded
  - cancelled
  terminal:
  - cleared
  - refunded
  - cancelled
  transitions:
  - key: request_down_payment
    name: Request a down payment on the order
    from: none
    to: requested
    by: buyer
    gate: a confirmed order; requests together not above the order total
    effect: keyed dp:<purchase_order_id>:<seq>; dp_no claimed; net, tax code and gross; a noted item, not posted
  - key: approve_down_payment
    name: Approve the down payment
    from: requested
    to: approved
    by: payables_approver
    approval: true
    gate: the approver is not the requester (email AND sub)
    effect: proposed in the run of its due date
  - key: pay_down_payment
    name: The down payment is paid
    from: approved
    to: paid
    by: system
    gate: its payment confirmed (in a run or outside one)
    effect: posted debit supplier down payments, credit bank
  - key: record_down_payment_invoice
    name: Record the supplier's VAT invoice for it
    from: paid
    to: paid
    by: payables_clerk
    gate: the supplier's VAT invoice for the down payment
    effect: a bill of kind down_payment_invoice; at approval its VAT moved from supplier down payments to input VAT
  - key: clear_down_payment_part
    name: Clear part against a bill
    from: paid
    to: paid
    by: payables_clerk
    gate: a bill of the same order; not above what remains nor the bill's outstanding
    effect: a clearing row claiming the down payment; an allocation of source_kind down_payment on the bill; the clearing line carries the down payment's tax code
  - key: clear_down_payment_rest
    name: Clear the rest against a bill
    from: paid
    to: cleared
    by: payables_clerk
    gate: what remains fits the bill's outstanding
    effect: as clear_down_payment_part, leaving nothing
  - key: refund_down_payment
    name: Record the supplier's refund
    from: paid
    to: refunded
    by: payables_clerk
    requires: bank_reference
    gate: the order is closed or cancelled
    effect: debit bank, credit supplier down payments for what remains; dated by the bank, today or earlier
  - key: cancel_down_payment
    name: Cancel the request
    from:
    - requested
    - approved
    to: cancelled
    by: buyer
    requires: reason
    gate: not paid and not in an approved run
  readiness:
  - transition: request_down_payment
    gate: within_order_total
    reason: down payments on the order would exceed its total
    fix: lower it to what remains (shown)
  - transition: approve_down_payment
    gate: second_person
    reason: you requested this down payment (same email or same sign-in)
    fix: another approver
  - transition: clear_down_payment_part
    gate: not_over_cleared
    reason: more than remains on the down payment, or more than the bill's outstanding (another clearing claimed it)
    fix: lower the amount
  - transition: refund_down_payment
    gate: order_finished
    reason: 'the order is still open: clear the down payment against its bills'
    fix: close the order first, or clear it
- entity: self_billing_agreement
  process: payables
  states:
  - proposed
  - active
  - ended
  - rejected
  terminal:
  - ended
  - rejected
  transitions:
  - key: propose_self_billing
    name: Record the self-billing agreement
    from: none
    to: proposed
    by: payables_clerk
    gate: the signed agreement; an end date within the owner's maximum; the supplier's VAT number where they are registered
    effect: keyed (bp_id, valid_from); the PDF kept as an object
  - key: approve_self_billing
    name: Approve the agreement
    from: proposed
    to: active
    by: payables_approver
    approval: true
    gate: the approver is not the person who recorded it (email AND sub)
  - key: reject_self_billing
    name: Reject it
    from: proposed
    to: rejected
    by: payables_approver
    requires: reason
  - key: end_self_billing
    name: End it early
    from: active
    to: ended
    by: payables_clerk
    requires: reason
    effect: ERS stops from tomorrow
  - key: expire_self_billing
    name: The agreement runs out
    from: active
    to: ended
    by: system
    gate: valid_to is before today (the business date)
    effect: listed for renewal 30 days before
  readiness:
  - transition: propose_self_billing
    gate: within_max_months
    reason: the agreement has no end date or runs longer than the owner's maximum
    fix: set an end date within the maximum (renew it then)
  - transition: propose_self_billing
    gate: supplier_vat_number
    reason: the supplier is VAT-registered and their VAT number is not on file
    fix: 'Tax: record the supplier''s VAT number'
  - transition: approve_self_billing
    gate: second_person
    reason: you recorded this agreement (same email or same sign-in)
    fix: another approver
- entity: consignment_withdrawal
  process: procure
  states:
  - recorded
  - settled
  - reversed
  terminal:
  - settled
  - reversed
  transitions:
  - key: take_consignment_withdrawal
    name: Take a withdrawal from inventory
    from: none
    to: recorded
    by: system
    gate: a consignment price in force on the withdrawal date; the date today or earlier
    effect: keyed move_ref; priced; {withdrawal_id, unit_price, amount} answered to inventory
  - key: reverse_consignment_withdrawal
    name: Reverse a withdrawal
    from: recorded
    to: reversed
    by: system
    gate: not settled (after settlement a credit in the next one)
  - key: settle_consignment
    name: Settle the withdrawals
    from: recorded
    to: settled
    by: payables_clerk
    gate: the supplier's invoice for the period matched two-way against the withdrawals, or ERS under an agreement
    effect: bill lines against withdrawals; debit consignment clearing, credit payables
  readiness:
  - transition: take_consignment_withdrawal
    gate: consignment_price
    reason: no consignment price in force for this supplier and product on the withdrawal date
    fix: 'Suppliers: record the consignment price; the stock stays the supplier''s'
- entity: migration_run
  process: payables
  states:
  - loaded
  - reconciled
  - signed
  - discarded
  terminal:
  - signed
  - discarded
  transitions:
  - key: load_purchasing_from_odoo
    name: Load the Odoo history
    from: none
    to: loaded
    by: system
    gate: the doors are decided; business-partner, product and tax have mapped every partner, product, unit and purchase tax used
    effect: documents written read-only, keyed odoo:<db>:<model>:<id>; a re-run changes nothing (PUR-34)
  - key: reconcile_purchasing_migration
    name: Reconcile against Odoo
    from: loaded
    to: reconciled
    by: system
    effect: every migration.odoo.reconcile check computed on both sides, with the difference
  - key: explain_purchasing_difference
    name: Explain each difference
    from: reconciled
    to: reconciled
    by: payables_approver
    requires: reason
    gate: a check is not equal and may be explained
  - key: reload_purchasing_migration
    name: Load again after a fix
    from:
    - loaded
    - reconciled
    to: loaded
    by: system
    gate: not signed
  - key: sign_purchasing_migration
    name: Sign the reconciliation
    from: reconciled
    to: signed
    by: owner
    approval: true
    gate: every check equal, or explained where explained is allowed
    effect: who (email and sub), when and the run's checksums recorded; purchasing takes writes for these suppliers from now
  - key: discard_purchasing_migration
    name: Discard the run
    from:
    - loaded
    - reconciled
    to: discarded
    by: owner
    requires: reason
  readiness:
  - transition: load_purchasing_from_odoo
    gate: doors_decided
    reason: a migration door is undecided
    fix: 'Migration: answer it'
  - transition: load_purchasing_from_odoo
    gate: partners_mapped
    reason: Odoo suppliers used have no bp_id
    fix: run the business-partner migration first
  - transition: load_purchasing_from_odoo
    gate: products_mapped
    reason: Odoo products or units used have no product_id or unit_code
    fix: run the product migration first
  - transition: load_purchasing_from_odoo
    gate: purchase_taxes_mapped
    reason: Odoo purchase taxes used have no tax_code
    fix: run the tax migration with purchase taxes included
  - transition: load_purchasing_from_odoo
    gate: not_signed
    reason: 'this run is signed: signed history is never rewritten'
    fix: start a new run for what changed after cutover
  - transition: sign_purchasing_migration
    gate: zero_unexplained
    reason: 'checks neither equal nor explained: named'
    fix: explain each, or fix and reload
  - transition: sign_purchasing_migration
    gate: must_equal
    reason: a check that must be equal differs
    fix: fix the mapping and reload