dodil/erp-brick-sales-order

DODIL ERP brick: sales order

src/brick_sales_order/application/process.yaml

# The brick's process — module.yaml's workflows (v10), copied (pattern.md §15). SO.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 is frozen once signed: it is the BPMN id. Signed (sales-order v10, seemo 2026-10-25, erp-method v0.1.38). Regenerated from the signed
# module.yaml, never edited by hand: tests/test_process.py compares it with the spec.
id: sales-order
name: Sales orders
version: 10
actors:
- id: sales_rep
  name: Sales
  does: creates orders of any type (from a quote or by hand), confirms them, amends them before delivery, raises returns (RMAs) and credit or debit memo requests, tells the customer the promised dates
  sees: the orders they own; a holder of so:sales_order:read_all sees every order
  permissions:
  - so:order:read
  - so:order:write
  - so:order:confirm
- id: order_admin
  name: Sales operations / the owner
  does: approves amendments and cancellations of confirmed orders, overrides a credit hold, sets and releases delivery and billing blocks, keeps order types, condition records and rebate agreements, asks for a rebate settlement, sets order policy
  sees: every order
  permissions:
  - so:order:read
  - so:sales_order:read_all
  - so:order:approve
  - so:order:cancel
  - so:order:block
  - so:pricing:write
  - so:rebate:write
  - so:settings:write
- id: sales_controller
  name: A second person for money going back (the finance controller, the owner)
  does: approves credit and debit memo requests, free-of-charge orders above the limit and rebate settlements; never one they raised or set up (compared by email AND sign-in subject)
  sees: every order, condition and rebate agreement with its accruals
  permissions:
  - so:order:read
  - so:sales_order:read_all
  - so:memo:approve
  - so:rebate:approve
- id: owner
  name: The owner or a finance controller
  does: sets the controls on order takers (whether amendments need approval, whether an accepted quote confirms itself, the business time zone, which AI steps are on), each from a later date
  sees: every order
  permissions:
  - so:order:read
  - so:sales_order:read_all
  - so:settings:controls
- id: fulfiller
  name: Whoever delivers (operations, the project lead, the warehouse)
  does: records what has been delivered or done, line by line
  sees: confirmed orders to deliver; never prices
  permissions:
  - so:order:read
  - so:order:deliver
- id: biller
  name: Whoever invoices
  does: asks for the invoice of what is due, raises the deposit
  sees: every confirmed order
  permissions:
  - so:order:read
  - so:sales_order:read_all
  - so:order:invoice
- id: customer
  name: The customer
  external: true
  does: sends their purchase order, receives the confirmation
  sees: their own order confirmation, by email or the portal
  permissions: []
- id: system
  name: The system
  does: creates the order on acceptance, suggests a draft from a PO and compares a PO with the order, derives fulfilment and invoice status, numbers confirmed orders, locks finished orders, re-promises unconfirmed quantities, accrues rebates, records return inspections and supplier shipments reported by inventory and purchasing, imports the Odoo orders once
  sees: everything, through its own service account
  permissions:
  - so:order:read
  - so:order:intake
  - so:order:suggest
  - so:order:finish
  - so:number_series:claim
  - so:order:import
  - so:order:reschedule
  - so:rebate:accrue
  - so:return:inspect
  - so:demand:read
  - so:order:deliver
workflows:
- entity: sales_order
  states:
  - draft
  - confirmed
  - done
  - cancelled
  terminal:
  - done
  - cancelled
  transitions:
  - key: order_from_quote
    name: Create the order from the accepted quote
    from: none
    to: draft
    by: system
    gate: 'a quote version was accepted; readiness: priced_by_product (every quote line priced by product''s find_price, v5)'
    effect: type STANDARD; partners defaulted (v5); lines copied, keyed quote:{quote_id}@v{version}; a second hand-over returns the same order
  - key: draft_by_hand
    name: Raise an order by hand
    from: none
    to: draft
    by: sales_rep
    gate: 'ordered_on is today in business_timezone; every unit is one the product sells in; an order type (v5); readiness: partners_valid (sold-to, ship-to, bill-to, payer, v5), reference_required (returns, memo requests, consignment issue: the order line or invoice referenced, v5)'
    effect: each line's item category determined from its type and the product's type (v5), priced by the pricing procedure, the BASE condition from find_price (priced_on today); taxed by the tax module for the ship-to
  - key: ai_order_from_po
    name: AI suggests the order from the customer's PO (suggest)
    from: none
    to: draft
    by: system
    gate: a purchase order arrived and the owner turned the step on
    effect: 'an empty draft with a suggestion: lines matched to product and unit, the PO number, differences from the quote; nothing on the order yet'
  - key: accept_order_suggestion
    name: Accept, change or reject the AI suggestion
    from: draft
    to: draft
    by: sales_rep
    effect: accepted lines priced by the pricing procedure; what was accepted, changed or rejected, by whom, logged in so_suggestion
  - key: customer_sends_po
    name: The customer sends their purchase order
    from: draft
    to: draft
    by: customer
    gate: the customer requires a PO
  - key: ai_check_po
    name: AI compares the PO with the order (suggest)
    from: draft
    to: draft
    by: system
    gate: a PO document is attached
    effect: price, quantity and term differences listed for the rep
  - key: price_order
    name: Price the lines and the header (pricing procedure)
    from: draft
    to: draft
    by: sales_rep
    gate: 'readiness: base_price_found (find_price answered every line), manual_within_limit (a typed condition within its type''s limit), freight_computable (every line''s weight known where FREIGHT applies)'
    effect: line_condition rows per line and the header's shares, each naming its record; nets rounded once and summed exactly (v5)
  - key: promise_dates
    name: Check availability and promise dates (ATP)
    from: draft
    to: draft
    by: system
    gate: inventory installed and the line is stocked (else the requested date is kept and said so)
    effect: 'schedule lines per line from inventory''s GET /atp: confirmed quantities and dates (v5); a shortfall never blocks'
  - key: review_order
    name: Review lines
    prices and tax: null
    from: draft
    to: draft
    by: sales_rep
  - key: confirm_sales_order
    name: Confirm the order
    from: draft
    to: confirmed
    by: sales_rep
    gate: 'readiness all met: customer_active, lines_priced (every line has priced_from), units_valid, lines_taxed, credit (the PAYER not on hold, or an admin overrode it with a reason; mode warn is met with its reason; skipped for CASH, v5), po_ref (where the customer''s policy on that date requires one), no_open_suggestion, delivery_multiple (v4: each line a multiple of its product''s delivery rule; mode warn is met with its reason, block is not), incomplete:* (v5: every field the type''s incompletion rules require for confirmation), site_allows_sale (v5: product site status at the delivering warehouse), plan_complete (v5: milestones total 100%), reference_required (v5)'
    effect: numbered from the order type's series (v5); prices, conditions and tax frozen; repeating lines handed to invoicing as schedules; with inventory installed, its goods lines reserved with their schedule lines (POST /reservations/sales-orders/{sales_order_id}, revision 1) and each line's stocked flag kept from the answer; third-party lines sent to purchasing as purchase requests (v5); RUSH asks for a delivery today; CASH has its delivery and invoice made at once; CREDIT_REQ, DEBIT_REQ and FREE_OF_CHARGE above the limit take the type's APPROVAL block; RETURNS follow the return_order workflow
  - key: cancel_draft_order
    name: Cancel the draft
    from: draft
    to: cancelled
    by: sales_rep
  - key: send_confirmation
    name: Send the order confirmation
    from: confirmed
    to: confirmed
    by: sales_rep
    effect: the document from the frozen order, with the promised dates per schedule line (v5); the send recorded
  - key: receive_confirmation
    name: The customer receives the confirmation
    from: confirmed
    to: confirmed
    by: customer
  - key: approve_memo_request
    name: Approve a credit or debit memo request, or a free-of-charge order
    from: confirmed
    to: confirmed
    by: sales_controller
    approval: true
    gate: 'readiness: approver_not_requester (neither the rep who raised it nor anyone with the same email or sign-in subject), within_invoiced (a credit never above the invoice line''s net less earlier credits)'
    effect: the APPROVAL block released; a credit request asks invoicing for a credit note against the referenced invoice, a debit request for a draft; a free-of-charge order may be delivered (v5)
  - key: set_order_block
    name: Set a delivery or billing block
    from: confirmed
    to: confirmed
    by: order_admin
    requires: reason
    gate: a block reason of the right kind (delivery | billing)
    effect: a block row appended; the fulfilment answer or the invoice request stops at it (v5)
  - key: release_order_block
    name: Release a block
    from: confirmed
    to: confirmed
    by: order_admin
    requires: reason
    gate: the block is set; an APPROVAL block only by approve_memo_request
    effect: a release row appended
  - key: reschedule_order
    name: Re-promise open quantities (daily)
    from: confirmed
    to: confirmed
    by: system
    gate: an open schedule line not yet delivered; once per business date
    effect: a new schedule version with the reason; a later date raises a task for the owner; the reservation re-sent with the same revision and the new schedule version (v5)
  - key: raise_deposit
    name: Raise the deposit invoice
    from: confirmed
    to: confirmed
    by: biller
    gate: 'readiness: deposit_set (the order''s deposit percentage is above zero), within_total (deposits never exceed the order total); not CASH'
    effect: invoicing raises a deposit draft; later invoices deduct it in part or in full (v5)
  - key: record_delivery
    name: Record what was delivered or done
    from: confirmed
    to: confirmed
    by: fulfiller
    gate: delivered_on not after today nor before the confirmation; no line beyond its ordered quantity plus its over-delivery tolerance (v5); a correction never below what is invoiced; the quantity converts exactly to the base unit (v3); with inventory installed, a stocked line only with source inventory (v3); a third-party line only with source purchasing (v5); max_partials (v5); no delivery block (v5)
    effect: delivered quantities per line, with the base quantity beside them, a correction as a new row; a line within its under-delivery tolerance is closed (v5); fulfilment status derived
  - key: inventory_delivers
    name: Inventory delivers a stocked line
    from: confirmed
    to: confirmed
    by: system
    gate: inventory installed; the line is stocked; delivery_ref inv:<delivery number>
    effect: record_delivery with source inventory, called inside inventory's validation; a repeat with the same delivery_ref records once
  - key: supplier_delivers
    name: The supplier ships a third-party line
    from: confirmed
    to: confirmed
    by: system
    gate: the line's item category is THIRD_PARTY; delivery_ref pur:<shipping notice>
    effect: record_delivery with source purchasing, called by purchasing; a repeat records once (v5)
  - key: release_milestone
    name: Release a billing milestone
    from: confirmed
    to: confirmed
    by: fulfiller
    gate: the milestone's event happened (or its date is today or past); not released before
    effect: the milestone's billing block released; its value becomes due (v5)
  - key: request_invoice
    name: Ask invoicing for what is due
    from: confirmed
    to: confirmed
    by: biller
    gate: 'readiness: something_due (a line has quantity or milestone value to invoice, per its policy), billing_block (none set, v5), incomplete:* (the fields the type requires for billing, v5), billing_relevant (not a FREE_OF_CHARGE or CONS_FILL order, v5), deduction_within_remaining (no deposit deducted beyond what is left, v5)'
    effect: one draft request with its deposit deductions per deposit, each carrying the deposit's tax category (v5); invoice status derived
  - key: amend_sales_order
    name: Amend the order
    from: confirmed
    to: confirmed
    by: sales_rep
    approval: true
    requires: reason
    gate: 'readiness: not_done, not_below_done (no line below what is delivered or invoiced), approver_not_amender (where amendment_needs_approval), delivery_multiple (v4, on changed lines), partners_unchanged_after_delivery (v5)'
    effect: revision n+1; only changed lines re-priced by the pricing procedure (priced_on today), the header shares re-spread; changed lines re-promised; a new confirmation can be sent; with inventory installed, the reservation re-sent with revision n+1
  - key: finish_sales_order
    name: Lock the finished order
    from: confirmed
    to: done
    by: system
    gate: every line delivered (or closed within tolerance) and invoiced in full, or not billing-relevant
    effect: edits refused
  - key: cancel_sales_order
    name: Cancel the confirmed order
    from: confirmed
    to: cancelled
    by: order_admin
    requires: reason
    approval: true
    gate: 'readiness: nothing_delivered, nothing_invoiced, third_party_cancellable (purchasing agreed, v5)'
    effect: drafts it requested are cancelled in invoicing; an unused deposit is credited (v5); with inventory installed, the reservation released (POST /reservations/sales-orders/{sales_order_id}/release)
  note: 'Fulfilment status (not started, partly delivered, delivered) and invoice status (nothing to invoice, to invoice, partly invoiced, invoiced) are DERIVED from the quantities on read; nothing stores them. For the screens and agents, the brick answers each order''s readiness for its next transitions as a list of {gate, met, reason, fix}: the gate''s key (as named in each transition''s gate), met true or false, the reason in words ("this customer requires a PO number"), and a fix (the field or the person who can clear it), so no screen or agent re-derives a rule. "Today" is the date in business_timezone. v5: the order TYPE decides which transitions apply (a FREE_OF_CHARGE order is never invoiced, a CREDIT_REQ never delivered, a RETURNS order follows the return_order workflow), and the incompletion rules add their incomplete:<field> gates to confirm, delivery (the fulfilment answer) and billing.'
  readiness:
  - transition: order_from_quote
    gate: priced_by_product
    reason: a quote line was priced from the quote's own price book, not by product
    fix: re-price the quote from product (PRD-20) before it is sent
  - transition: draft_by_hand
    gate: partners_valid
    reason: a ship-to, bill-to or payer is not one of the sold-to's partners in force today, or is not an active customer
    fix: 'Settings: customer partners, or pick another'
  - transition: draft_by_hand
    gate: reference_required
    reason: this order type names the order line or invoice it refers to
    fix: pick the original order line or invoice
  - transition: price_order
    gate: base_price_found
    reason: product has no price for this product, unit, customer and currency today
    fix: ask the pricing manager to add one (Price lists)
  - transition: price_order
    gate: manual_within_limit
    reason: a condition typed by hand is beyond its type's manual limit
    fix: stay within the limit, or ask an order admin to change the condition record
  - transition: price_order
    gate: freight_computable
    reason: a line's unit has no gross weight and freight is charged by weight
    fix: record the unit's weight in Products
  - transition: confirm_sales_order
    gate: site_allows_sale
    reason: the product is blocked for sale at the delivering warehouse
    fix: 'deliver from another warehouse, or ask the planner (Products: sites)'
  - transition: confirm_sales_order
    gate: plan_complete
    reason: the milestones of a line do not add up to 100%
    fix: 'Order: billing plan'
  - transition: approve_memo_request
    gate: approver_not_requester
    reason: 'you raised this request (same email or sign-in subject): a second person approves'
    fix: ask a sales controller
  - transition: approve_memo_request
    gate: within_invoiced
    reason: the credit is above what is left of the invoice line after earlier credits
    fix: reduce it to the amount left
  - transition: record_delivery
    gate: max_partials
    reason: the customer accepts at most this many part deliveries per line
    fix: deliver the rest in one, or change the order's rule before delivery
  - transition: record_delivery
    gate: within_tolerance
    reason: more than ordered plus the over-delivery tolerance
    fix: deliver no more than the tolerance allows
  - transition: request_invoice
    gate: billing_block
    reason: the order has a billing block
    fix: an order admin releases it
  - transition: request_invoice
    gate: deduction_within_remaining
    reason: a deposit has less left than the deduction asked
    fix: deduct what is left
  - transition: cancel_sales_order
    gate: third_party_cancellable
    reason: purchasing refused to cancel the purchase for a third-party line (named)
    fix: ask the buyer to cancel it with the supplier first
- entity: return_order
  states:
  - draft
  - authorised
  - received
  - closed
  - cancelled
  terminal:
  - closed
  - cancelled
  transitions:
  - key: raise_return
    name: Raise the return
    from: none
    to: draft
    by: sales_rep
    gate: 'readiness: reference_required (the original order line, its delivery and invoice), within_delivered (no more than delivered less already returned on that line), return_reason'
    effect: a sales_order of type RETURNS; each line copies the original line's price, unit and tax category, never re-priced
  - key: authorise_return
    name: Authorise the return (RMA)
    from: draft
    to: authorised
    by: sales_rep
    gate: 'readiness: incomplete:* (the RETURNS type''s rules)'
    effect: 'numbered RE-{yyyy}-{seq:4}: the RMA the customer quotes; the confirmation sent; inventory told to expect it (POST /expected-returns)'
  - key: receive_returned_goods
    name: The goods come back
    from: authorised
    to: received
    by: system
    gate: inventory received them against the RMA; a serial is one delivered to this customer
    effect: record_delivery on the return line with source inventory (positive, received)
  - key: record_return_inspection
    name: Inventory reports the inspection
    from: received
    to: received
    by: system
    gate: accepted + rejected never above received
    effect: accepted and rejected quantities per line with the decision (restock, scrap, to supplier) and reason (POST /sales-orders/{sales_order_id}/return-inspections)
  - key: request_return_credit
    name: Ask invoicing for the credit
    from: received
    to: received
    by: system
    gate: 'readiness: credit_point_reached (return_credit_on receipt, or the line inspected), within_invoiced (never above the original invoice line''s net less earlier credits)'
    effect: one credit note against the original invoice for what is accepted, less a restocking fee where the reason has one, keyed so:{sales_order_id}:{request_no}, its lines carrying the returns order's line
  - key: close_return
    name: Close the return
    from: received
    to: closed
    by: system
    gate: every line inspected, and credited or rejected
    effect: edits refused
  - key: cancel_return
    name: Cancel the return
    from:
    - draft
    - authorised
    to: cancelled
    by: sales_rep
    requires: reason
    gate: nothing received
    effect: inventory's expected return withdrawn
  note: 'a return never changes the original order: it is its own order (type RETURNS) whose lines reference the original line, delivery and invoice; returned and credited quantities per original line are derived from the returns orders that name it'
  readiness:
  - transition: raise_return
    gate: within_delivered
    reason: more than was delivered on that line less what was already returned
    fix: return at most the quantity named
  - transition: raise_return
    gate: return_reason
    reason: a return needs its reason
    fix: pick a return reason
  - transition: request_return_credit
    gate: credit_point_reached
    reason: the goods are not yet inspected (credit on inspection)
    fix: wait for inventory's inspection
- entity: rebate_agreement
  states:
  - draft
  - active
  - ended
  - settled
  - cancelled
  terminal:
  - settled
  - cancelled
  transitions:
  - key: open_rebate_agreement
    name: Open a rebate agreement
    from: none
    to: draft
    by: order_admin
    gate: a customer or price group; a basis (products, categories or all); a validity period
    effect: the agreement and its scale, editable
  - key: activate_rebate_agreement
    name: Activate the agreement
    from: draft
    to: active
    by: order_admin
    gate: 'readiness: starts_after_today, scale_valid (tiers ascending, rates from 0 to 100 percent)'
    effect: invoices from its start accrue against it
  - key: accrue_rebate
    name: Accrue on issued invoices (nightly)
    from: active
    to: active
    by: system
    gate: an invoice or credit note in the basis issued on the business date
    effect: accrual rows keyed (agreement, document, line) and the accrual journal posted to the ledger
  - key: request_partial_settlement
    name: Ask for a partial settlement
    from: active
    to: active
    by: order_admin
    requires: reason
    effect: 'a settlement draft: the rate reached so far on the basis so far, less settled'
  - key: approve_partial_settlement
    name: Approve the partial settlement
    from: active
    to: active
    by: sales_controller
    approval: true
    gate: 'readiness: approver_not_requester (by email and sign-in subject)'
    effect: a credit note in invoicing keyed rebate:{agreement_id}:{settlement_no}; the accrual released by its amount
  - key: end_rebate_agreement
    name: The agreement period ends
    from: active
    to: ended
    by: system
    gate: valid_to is before today
    effect: no further accruals; the final basis fixed once the last day's invoices are accrued
  - key: request_final_settlement
    name: Ask for the final settlement
    from: ended
    to: ended
    by: order_admin
    effect: the tier reached on the whole basis, retroactive, less what partial settlements paid
  - key: approve_final_settlement
    name: Approve the final settlement
    from: ended
    to: settled
    by: sales_controller
    approval: true
    gate: 'readiness: approver_not_requester'
    effect: the accrual trued up in the ledger, one credit note in invoicing, the accrual account for the agreement nets to 0.00
  - key: cancel_rebate_agreement
    name: Cancel a draft agreement
    from: draft
    to: cancelled
    by: order_admin
  readiness:
  - transition: activate_rebate_agreement
    gate: starts_after_today
    reason: an agreement starts tomorrow at the earliest (business time zone)
    fix: pick a later start
  - transition: activate_rebate_agreement
    gate: scale_valid
    reason: the tiers are not ascending, or a rate is outside 0 to 100 percent
    fix: correct the scale
  - transition: approve_final_settlement
    gate: approver_not_requester
    reason: you asked for this settlement or set the agreement up (same email or sign-in subject)
    fix: ask a sales controller