# Sales orders: the module source.
#
# PROVENANCE. Written 2026-10-18 for milestone M1 (Lead to Cash), conveyor steps 1-2, from:
# - what exists upstream: brick-quote 0.2.0 records a customer's decision on a sent version
# (POST /quotes/{quote_id}/versions/{version}/decision); accepting supersedes every other open version, so a
# quote has at most one accepted version. That accepted version is where an order starts;
# - what exists downstream: the ar spec's invoicing (invoice from a source, schedules for repeating lines,
# deposits), and the payments spec's credit hold;
# - APQC PCF v8.0: 10185 (3.5.4 Manage sales orders), 10194 (3.5.4.1 Accept and validate sales orders),
# 10197 (3.5.4.4 Determine fulfillment process), 10199 (3.5.4.5 Process back orders and updates), 10200
# (3.5.4.6 Handle sales order inquiries);
# - the end-to-end process and customization level of a mainstream SME suite's Sales app (quotation to
# confirmed order, invoicing policy ordered vs delivered, down payments, order lock), as a parity reference
# only: the data model is our own.
# SIGNED OFF BY THE CUSTOMER: Seemo, DODIL (reference customer), 2026-10-18, after the M1 review page. The
# process is signed on its own (signoff/sales-order/<process>.json, erp-compose signoff verify); the decisions
# settled on the review are under `decisions:` below.
#
# Three decisions:
# - the order is a COMMITMENT, separate from the quote (an offer). It copies the accepted version's lines; it
# never points back at a mutable quote line
# - fulfilment status and invoice status are DERIVED from delivered and invoiced quantities; only the
# lifecycle a person decides (draft, confirmed, done, cancelled) is stored
# - the order never writes an invoice: it asks invoicing for one (SO-21), and reads back what was invoiced
#
# REVISED 2026-10-22 for sign-off (conveyor step 2), to the conventions of the tested product and projects specs:
# dated rows with no back-dating ("today" = the date in business_timezone), owner-held controls under
# so:settings:controls, exact-grant machine permissions, readiness gates {gate, met, reason, fix}, AI steps as
# suggestions a person accepts (recorded in so_suggestion), lines priced by product's find_price and carrying
# unit_code + priced_from, and the Odoo migration slice (milestone M8) under `migration:`.
#
# VERSION 3 (2026-10-22, wave 2, DRAFT for sign-off; v2 as signed is unchanged otherwise), for inventory: when
# inventory is installed, a STOCKED goods line is delivered only by inventory (record_delivery with source
# inventory; a hand delivery on it is refused), and confirm, amend and cancel reserve and release its stock
# through inventory's routes. Service lines, and every line without inventory, are delivered by hand as in v2.
# A delivered quantity must convert exactly to the base unit.
#
# VERSION 4 (2026-10-22, wave 2, DRAFT for sign-off; "up to SAP standard" for units of measure, shared.yaml
# units.uom): a line carries the PRICING QUANTITY and pricing unit find_price answered (2.4500 per 100 C62), and
# its net is computed exactly from them (SO-27); the product's delivery unit and multiple are a readiness gate on
# confirm and amend, warn or block per an owner control, default warn (SO-28); catch weight on deliveries is
# designed in inventory and reserved here (`deferred:`, M7); `sap_parity:` compares this module with SAP SD.
#
# VERSION 5 (2026-10-22, SAP full-scope pass, DRAFT for sign-off: the sales_order process v4 -> v5 and the new
# return_order and rebate_agreement processes need signing). "Fully up to SAP standard" for SD, SME to mid-market:
# - order types and item categories as configuration rows (standard, rush, cash, returns, credit / debit memo
# requests, free of charge, customer consignment fill / issue / pick-up / return), each with its number series,
# billing relevance, required references and default blocks (SO-29)
# - a pricing procedure with the condition technique: the base price is product's find_price (one seam, no second
# price book); discounts, surcharges and freight come from this module's condition records found through access
# sequences (customer + product, customer + category, price group + product, customer, all), at item and header
# level, with scales and validity (SO-30, SO-31)
# - rebate agreements with retroactive scales, accruals to the ledger and settlement as invoicing credit notes,
# approved by a second person (SO-32 to SO-34)
# - ATP with promised dates and schedule lines from inventory, and daily re-promising of what is not yet
# confirmed (SO-35, SO-36); partial and complete delivery rules and delivery tolerances (SO-37)
# - an incompletion log per order type as readiness gates, manual delivery and billing blocks (SO-38, SO-39)
# - partner functions sold-to, ship-to, bill-to and payer from business-partner (SO-40)
# - returns with an RMA, inspection in inventory and a credit only for what passed (SO-41, SO-49); credit and debit
# memo requests (SO-42); free-of-charge orders (SO-43); rush and cash orders (SO-44); customer consignment
# (SO-45); milestone billing plans beside invoicing's periodic schedules (SO-46); deposits deducted in part, per
# deposit, with the deposit's tax category (SO-47, the rule ar uses); third-party orders shipped by the supplier
# (SO-48); the quote -> order seam with its exact routes (SO-01)
# - tool names made unique across the method: accept_suggestion -> accept_order_suggestion, cancel_draft ->
# cancel_draft_order, and confirm_order / amend_order / cancel_order / finish_order -> *_sales_order (purchasing
# uses the short names for its own order)
# Version 11 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 10 stays the signed one until it is signed):
# - one gate name for over-delivery: within_tolerance, the signed workflow's, in SO's timesheets story and two
# route answers (they said within_ordered); no process change
#
# Version 10 (2026-10-25, DRAFT for sign-off; a seam, no process change):
# - lines are priced (draft, add and amend, re-price) and their delivery rounding asked (confirm, amend) with ONE call
# to product's POST /prices/batch (product v7 PRD-41) instead of find_price and rounding once per line: the same
# answers, so the same lines, gates and amounts; one round trip per order instead of one per line per route
#
# Version 9 (2026-10-25, DRAFT for sign-off, wave 4; a seam, no process change):
# - POST /sales-orders/{id}/deliveries takes source timesheets on a service line, with a negative correction
# naming corrects_ref (SO-51, timesheets TS-12)
#
# Version 8 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
# - ship_to_address is the one address shape (shared.yaml conventions.address) on the order and on POST
# /purchase-requests/from-sales-order; purchasing v8 reads the same object
# - each line copies category_code from product's price answer (product v6 line.category_code), which the category
# accesses of the condition technique (SO-30) match on
# - POST /invoices/raise lines send quantity_priced and the price basis exactly (ar v7): net = quantity_priced x
# unit_price / pricing_quantity, less the discount
# - the purchase-request cancel: an ordered request is refused until the buyer cancels its order (purchasing v8
# PUR-67); the order's cancel may be asked again
# - GET /demand?product_id=&warehouse_id=&from=&to=: open quantity across orders, no prices, for manufacturing's
# MRP-lite (MFG-23, wave 3)
# - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
id: sales-order
name: Sales orders
short: Orders
version: 11
maturity: tested
summary: >-
Turn an accepted quote into a confirmed order in one step, track what has been delivered, and invoice
exactly what is due (on order or on delivery, with deposits up front) without anyone re-typing a line.
decisions: # settled by the reference customer on the M1 review, 2026-10-18
- decided: "an accepted quote creates a DRAFT order that a person confirms (SO-01, SO-03); the setting confirm_on_acceptance confirms it at once"
by: "Seemo, DODIL (reference customer)"
date: "2026-10-18"
- decided: "the order's key is sales_order_id (shared.yaml keys.sales_order_id), never purchasing's order_id"
by: "Seemo, DODIL (reference customer)"
date: "2026-10-18"
- decided: "deliveries are recorded by hand (record_delivery) until the M4 delivery bricks call the same route (SO-23)"
by: "Seemo, DODIL (reference customer)"
date: "2026-10-18"
- decided: "where contracts are signed, an active contract BILLS DIRECTLY through invoicing's contract adapter (ar plan_billing / raise_draft, source_kind contract) by default, with no order; the setting contract_creates_order raises a draft order instead"
by: "Seemo, DODIL (reference customer)"
date: "2026-10-18"
- proposed: "a line is priced by product's find_price(product_id, quantity, unit_code, bp_id, currency, priced_on) with priced_on = today, and stores the answer's source as priced_from; an order from a quote copies the accepted prices and is never re-priced"
status: "for sign-off"
- proposed: "v3: with inventory installed, a stocked goods line is delivered only by inventory (source inventory); a hand delivery on it is refused, service lines stay by hand; confirm, amend and cancel reserve and release through inventory (SO-23, SO-25)"
status: "for sign-off (wave 2)"
- proposed: "Odoo quotations (draft, sent) migrate to brick-quote as open quotes, not as orders; confirmed Odoo orders migrate here, open ones live and the rest as read-only history"
status: "for sign-off"
- proposed: "v4: a line copies find_price's pricing_quantity and pricing unit with the price and nets from them exactly; a line from an accepted quote with no pricing quantity is per 1 of its unit"
status: "for sign-off (wave 2)"
- proposed: "v4: a line off the product's delivery multiple WARNS at confirm and amend by default; the owner may set block (delivery_multiple_mode, so:settings:controls)"
status: "for sign-off (wave 2)"
- proposed: "v5: the base price is ALWAYS product's find_price; every other condition (discounts, surcharges, freight, rebate accruals) is a condition record here, found through an access sequence; a confirmed line's conditions are frozen with it"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v5: a customer return is always a returns order (an RMA) referencing the original order line and invoice; the original order is never changed by a return; the credit is raised for what inspection accepted, against the original invoice, keyed on the returns order"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v5: a deposit may be deducted in part, tracked per deposit; each deduction line carries the deposit's tax category (the same rule in ar); an unused deposit is deducted in full by the final invoice or credited on cancellation"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v5: ATP gives every line a promised date (schedule lines) but never blocks a confirmation; unconfirmed quantities are re-promised daily and a later promise is shown for the rep to tell the customer"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v5: rebates accrue on every invoice in the agreement's period and settle as invoicing credit notes, approved by a second person (never the person who set the agreement up)"
status: "for sign-off (SAP full-scope pass)"
- decided: "v5: payment terms on a migrated order are written as ar's own key odoo:<payment_term id> (ar's migration creates the term under that key), so sales-order needs no ar map and the migration order is unchanged (resolved 2026-10-22: shared.yaml conventions.odoo_migration.payment_terms; no ar.terms slice)"
status: "for sign-off (SAP full-scope pass)"
facets:
domain: sales
industries: [any]
customer_words:
- "the customer said yes, now what"
- "sales orders"
- "order confirmation"
- "invoice when we deliver"
- "50% deposit up front"
- "what's still to deliver"
- "when can you deliver"
- "returns and RMA numbers"
- "year-end rebate"
- "ship to a different address"
- "free samples"
- "stock held at the customer"
- "invoice in stages"
replaces: [an order spreadsheet, re-typing the quote into the accounts, Odoo Sales orders]
pillars: [sql]
first_module_fit: high
depends_on:
- module: business-partner
why: the order's customer is the shared party
required: true
- module: product
why: lines name products, and their invoicing policy decides when a line is invoiceable
required: true
- module: tax
why: the order's tax is computed by the tax module
required: recommended
- module: crm
why: an order is created from an accepted quote (brick-quote)
required: recommended
- module: ar
why: invoicing raises the invoices the order asks for
required: recommended
- module: payments
why: the credit hold that blocks confirmation
required: optional
- module: inventory
why: reservations, promised dates (ATP), deliveries, returns inspection and consignment stock (v3, v5)
required: recommended
- module: purchasing
why: third-party orders the supplier ships straight to the customer (v5)
required: optional
- module: gl
why: rebate accruals are posted to the ledger (v5); without it they are reported, not posted
required: optional
feeds: [ar, inventory, purchasing]
owns_master_data: []
references_master_data: [business_partner, product, tax_code]
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]
stories:
# ── person ────────────────────────────────────────────────────────────────────────────────
- id: SO-02
kind: person
actor: sales_rep
story: As a sales rep, I want to raise an order by hand when there was no quote, so that a repeat order on the phone is still a real order.
given: a customer with bp_id 7311, active products, and COLA-075 sold by the bottle (C62) and the 24-pack (PK24)
when: I add 2 x PK24 of COLA-075 and 3 hours (HUR) of SETUP and save
then: 'a draft order dated today (in business_timezone; it cannot be dated earlier) exists; each line is priced by product''s find_price(product_id, quantity, unit_code, bp_id, currency, priced_on = today), stores the price, the unit and its conversion, and priced_from (the list, rule and row the answer named), and is taxed by the tax module; nothing is typed but products, units and quantities; a unit the product does not sell in is refused'
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: not built
- id: SO-03
kind: person
actor: sales_rep
story: As a sales rep, I want to confirm an order, so that the customer and the team know it is committed.
given: a draft order, priced and taxed, for a customer not on credit hold
when: I confirm it
then: 'it gets the next number from the series (SO-2026-0001), its prices and tax are frozen, a confirmation goes to the customer when I send it; before I press confirm the order shows its readiness, each gate as {gate, met, reason, fix} (e.g. {gate: credit, met: false, reason: "on hold: 2 invoices over 60 days", fix: "ask an order admin to override"}); confirming with a gate unmet is refused with that reason, and a credit hold only an order admin overrides, with a reason'
screen: Order
reference: not built
customer_varies: whether a PO from the customer is required before confirming
- id: SO-04
kind: person
actor: sales_rep
story: As a sales rep, I want to record the customer's purchase order number, so that their accounts payable team can match our invoice.
given: a customer whose order policy says "PO required" from 2026-11-01 (a dated row; a policy cannot start today or earlier, and a correction is a new row from a later date)
when: I confirm without one on 2026-11-02
then: refused (gate po_ref, "this customer requires a PO number"); with PO-4471 it confirms and every invoice for the order carries PO-4471; on 2026-10-30 the policy did not yet apply
screen: Order
reference: not built
- id: SO-05
kind: person
actor: sales_rep
story: As a sales rep, I want to amend a confirmed order before it is delivered, so that a change of mind is a revision, not a new order.
given: a confirmed order with 10 units, none delivered or invoiced
when: I change it to 12 units
then: revision 2 exists with the reason; only the changed line is re-priced, by find_price with priced_on = today and a new priced_from; the other lines keep their price and source; approval by an order admin where the control asks for it, never by the rep who amended; reducing below what is already delivered or invoiced is refused (gate not_below_done)
screen: Order
reference: not built
- id: SO-06
kind: person
actor: order_admin
story: As the order admin, I want to cancel an order with a reason, so that the pipeline and the books stop expecting it.
given: a confirmed order with nothing delivered or invoiced
when: I cancel it with the reason "customer withdrew"
then: it is cancelled, keeps its number, and asks invoicing to cancel any draft it requested; an order with a delivery or an issued invoice cannot be cancelled ("credit the invoice, then close the order")
screen: Order
reference: not built
- id: SO-07
kind: person
actor: fulfiller
story: As the person who delivers, I want to record what was delivered or done, line by line, so that invoicing on delivery bills exactly that.
given: a confirmed order with 12 units of a delivered-policy product
when: I record 5 delivered on 2026-11-20 with delivery note DN-118
then: the line shows 5 of 12 delivered, the order shows "partly delivered", 5 units become invoiceable; recording the same delivery note twice records it once; delivering more than ordered, or dating a delivery after today or before the confirmation, is refused; a mistake is corrected by a new correction row (DN-118-C, -2 against DN-118), never by editing DN-118, and never below what is already invoiced
screen: Deliveries
reference: not built
- id: SO-08
kind: person
actor: biller
story: As the biller, I want to invoice what an order has due, so that nothing is billed early, late or twice.
given: "an order: line 1 policy ordered, 1 unit; line 2 policy delivered, 12 ordered, 5 delivered; nothing invoiced"
when: I ask for the invoice
then: invoicing receives one draft request for line 1 x 1 and line 2 x 5, keyed on the order and the request number; asking again with nothing new due creates nothing; the order shows "partly invoiced"
screen: Order
reference: not built
- id: SO-09
kind: person
actor: biller
story: As the biller, I want to raise a deposit before the work starts, so that "50% up front" is a real invoice.
given: a confirmed order of 10,000.00 net and a setting for a 50% deposit
when: I raise the deposit
then: invoicing receives a deposit request of 5,000.00 net (taxed when the payment is received or the invoice issued, whichever is first, per the tax point rule); the next invoices for the order deduct it until it is used; a second deposit beyond the order total is refused
screen: Order
reference: not built
- id: SO-10
kind: person
actor: sales_rep
story: As a sales rep, I want one list of orders with what is still to deliver and to invoice, so that I can answer "where is my order" in a sentence.
given: orders in every state
when: I open the list
then: each row shows the number, the customer, the total, the fulfilment status and the invoice status (both derived); filters by state, customer and date
screen: Orders
reference: not built
- id: SO-11
kind: person
actor: sales_rep
story: As a sales rep, I want to send the customer an order confirmation, so that they have in writing what was agreed.
given: a confirmed order
when: I send the confirmation
then: the document (number, lines, prices, tax, delivery terms, their PO) is produced from the frozen order and the send is recorded; re-sending records a second send
screen: Order
reference: not built
- id: SO-15
kind: person
actor: owner
story: As the owner, I want to set the controls on how orders are taken, so that a rep cannot loosen the rules they work under.
given: amendment_needs_approval true and the PO-draft AI step off
when: on 2026-10-22 I set amendment approval off from 2026-11-01 and turn ai_order_from_po to suggest
then: 'each control is a dated row; one starting today or earlier is refused ("starts after today, in Europe/London"); a correction is a new row from a later date; a holder of so:settings:write alone is refused on a control; turning an AI step on is recorded as the owner''s consent (who, when, which step)'
screen: Settings
reference: not built
- id: SO-16
kind: person
actor: sales_rep
story: As a sales rep, I want to accept, change or reject what the AI suggested from a customer's PO, so that nothing the AI read reaches the order without me.
given: a draft order suggested from PO-4471 with 3 matched lines and one difference from the quote listed
when: I accept two lines, change the third line's unit from C62 to PK24, and reject the difference as noise
then: the draft holds the accepted and changed lines, each priced by find_price; the suggestion, the model, what I accepted, changed or rejected, and when are recorded on the order's suggestion log; nothing was on the order before I acted
screen: Order
reference: not built
# ── v5: SAP full scope (person) ────────────────────────────────────────────────────────────
- id: SO-29
kind: person
actor: order_admin
story: As sales operations, I want order types (standard, rush, cash, returns, credit and debit memo requests, free of charge, consignment), each behaving its own way, so that a return or a free sample is not an ordinary order someone has to remember to treat differently.
given: "seeded types STANDARD (SO-{yyyy}-{seq:4}), RUSH, CASH, RETURNS (RE-), CREDIT_REQ (CR-), DEBIT_REQ (DR-), FREE_OF_CHARGE (FC-), CONS_FILL, CONS_ISSUE, CONS_PICKUP, CONS_RETURN (CS-), each with direction, billing relevance, a required reference and default blocks; item category rules: STANDARD + product type TRADING -> STD_STOCK, STANDARD + SERVICE -> STD_SERVICE, STANDARD + TRADING with usage drop_ship -> THIRD_PARTY, FREE_OF_CHARGE + any -> FREE"
when: "a rep raises a STANDARD order of COLA-075 and HOST-M, a RETURNS order with no reference, and later tries to change the confirmed STANDARD order to RUSH"
then: "COLA-075 gets item category STD_STOCK (delivery-relevant, billed per its invoicing policy) and HOST-M STD_SERVICE (not delivered from stock), each line keeping the category it was determined with; the RETURNS order is refused (gate reference_required, \"a return names the order line it returns\"); the type change is refused (\"an order's type is fixed once confirmed: cancel it and raise a new one\"); each type numbers from its own series at confirmation, claimed with a plain INSERT, never reused and with no gap from a refused confirmation; a type or rule is a dated config row (from tomorrow), so a confirmed order keeps the behaviour it was confirmed under"
screen: Settings
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP sales document types (VOV8: OR, SO rush, BV cash, RE returns, G2 / L2 credit / debit memo requests, FD free of charge, KB / KE / KA / KR consignment) and item category determination (VOV4: TAN, TAD, TAS, TANN, REN, KBN, KEN); Odoo has one order kind"
customer_varies: "which types are used (most firms: standard, returns, credit requests); their number formats"
- id: SO-30
kind: person
actor: sales_rep
story: As a sales rep, I want each line priced by the base price from our price lists and then the customer's agreed discounts and surcharges found automatically, so that the price is the one agreed without me looking it up.
given: "pricing procedure STANDARD: 10 BASE (product find_price), 20 DISC_PCT (access sequence: customer + product, then customer + category, then price group + category), 30 SURCH_PCT (category); records: 7311 + COLA-075 5% from 2026-11-01; 7311 + category drinks 3%; price group TRADE + drinks 2% (4% from 100 C62); drinks surcharge 2%; base 1.20 a bottle for COLA-075 and 1.80 for COLA-150; DISC_PCT manual limit 10%"
when: "on 2026-11-02 I order 120 C62 of COLA-075 and 120 C62 of COLA-150 for 7311, then type a DISC_PCT of 12% on the second line, then confirm"
then: "COLA-075: BASE 144.00, DISC_PCT -7.20 (customer + product found first: the access stops at the first hit), SURCH_PCT +2.74 on 136.80, net 139.54; COLA-150: BASE 216.00, DISC_PCT -6.48 (customer + category), SURCH_PCT +4.19, net 213.71; each condition is a line_condition row naming its record (priced_from per condition) and rounded once, half up, to the minor unit; 12% is refused (\"DISC_PCT may be changed by hand up to 10%\") and 8% is accepted and recorded as manual with who; confirming freezes every condition; an amendment re-prices only the changed line; a record dated today or earlier cannot be added (records start after today); a NUMERIC overflow (a rate or amount above its bound) is a 422 with the reason"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP condition technique (V/06 condition types, V/07 access sequences, V/08 pricing procedure, VK11 records with scales, KONV / PRCD_ELEMENTS on the item); Odoo pricelist rules and a line discount"
customer_varies: "whether any discount or surcharge is agreed per customer, group or category (many small firms: none, the price list is enough)"
- id: SO-31
kind: person
actor: sales_rep
story: As a sales rep, I want an order-level discount and the freight charged once for the whole order and spread over its lines, so that the invoice shows the customer's deal and our carriage cost without arithmetic by hand.
given: "the order of SO-30 (net 139.54 and 213.71 = 353.25) for ship-to 7313 in GB; condition FREIGHT for country GB: 0.12 per KGM of gross weight, at least 15.00; the bottles weigh 0.79 KGM (COLA-075) and 1.60 KGM (COLA-150) gross (product measures in force today)"
when: I add a header discount HDR_DISC of 2% and confirm
then: "HDR_DISC 7.07 (2% of 353.25 rounded once) spread by net: 2.79 on line 1 and the remainder 4.28 on line 2; FREIGHT 34.42 (286.8 KGM x 0.12) spread by gross weight: 11.38 on line 1 and the remainder 23.04 on the heaviest line; nets 148.13 and 232.47, order net 380.60, exactly the sum; every share lands on a line so invoicing bills and taxes it with the goods; a line with an unknown weight leaves FREIGHT uncomputable and confirmation refused (gate freight_computable, fix \"record the unit's weight in Products\"); the weights used are copied on the line with their valid_from"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP header conditions (HA00 percentage, HB00 absolute) distributed by net value, freight KF00 by weight, group conditions; Odoo has delivery carriers adding a freight line"
customer_varies: whether freight is charged, and by weight, by order value or flat
- id: SO-32
kind: person
actor: order_admin
story: As sales operations, I want a rebate agreement with a customer (a year's purchases, tiers, paid back at the end), so that the year-end rebate is calculated from the invoices instead of a spreadsheet.
given: "customer 7311; drinks invoiced to them in 2027 will earn 1% from 0, 2% from 50,000.00 and 3% from 100,000.00, retroactive to the first pound; expected tier 2%"
when: "on 2026-12-10 I open agreement RB-2027-7311 for 2027-01-01 to 2027-12-31, basis invoiced net of category drinks, accrual rate 2%, and activate it; then try one starting 2026-12-01, and change the scale after the first accrual"
then: "RB-2027-7311 is active from 2027-01-01; the back-dated one is refused (gate starts_after_today); the scale change is refused once an accrual exists (\"end it and open a new agreement from a later date\"); an agreement names the customer (or a price group) and the products, categories or all; the basis is invoice net less credit notes, by the invoice's issue date in business_timezone"
screen: Rebates
path: { process: sales-order.rebate_agreement, choices: {} }
reference: "SAP rebate processing / settlement management (condition contracts, WCOCO; formerly VBO1 rebate agreements with BO01-BO03 conditions, retroactive scales, accruals, VBOF / partial settlement); Odoo has no rebates (loyalty programs only)"
customer_varies: whether rebates are agreed at all; basis (volume, value, category); retroactive or tier-by-tier
- id: SO-34
kind: person
actor: sales_controller
story: As the finance controller, I want a rebate settlement calculated and checked before the credit goes to the customer, so that we pay back exactly what the agreement earned, once.
given: "RB-2027-7311 ended 2027-12-31; invoiced drinks net 120,000.00 less credits; accrued 2,400.00; a partial settlement of 1,000.00 credited in July"
when: "on 2028-01-05 the order admin asks for the final settlement; the order admin approves it under a second account; then I approve it"
then: "the settlement is 3,600.00 (3% of 120,000.00, the tier reached, retroactive) less 1,000.00 already settled = 2,600.00; the order admin's second account is refused (same email or same sign-in subject); my approval posts the true-up of the accrual (1,200.00) to the ledger and asks invoicing for one credit note to 7311 keyed rebate:RB-2027-7311:2, with lines per tax category of the basis and the account purpose rebate (so invoicing debits the accrued rebates, not revenue again); approving twice credits once; the agreement closes, its accrual nets to exactly 0.00"
screen: Rebates
path: { process: sales-order.rebate_agreement, choices: {} }
reference: "SAP rebate settlement (VBOF, final settlement credit memo request B1 / B2, accrual reversal)"
- id: SO-35
kind: person
actor: sales_rep
story: As a sales rep, I want each line to show when it can be delivered from stock and what is coming in, so that I promise the customer a date we can keep.
given: "today 2026-11-02; COLA-075 at WH1 360 LTR unrestricted, 180 LTR reserved; PO-0101 brings 720 LTR on 2026-11-09 (goods-in 1 day, product site WH1); pick and pack 1 day; ship-to 7313 is 1 day from WH1"
when: I order 480 C62 (360 LTR) for delivery on 2026-11-04
then: "inventory's ATP answers 180 LTR now and 720 from 2026-11-10; the line gets two schedule lines: 240 C62 confirmed for 2026-11-04 and 240 C62 for 2026-11-12; the order shows \"partly confirmed: 240 on 4 Nov, 240 on 12 Nov\" and the confirmation document prints both; confirmation is never blocked by a shortfall; a lot expiring before the delivery date is not counted (the customer's minimum shelf life); with complete delivery (SO-37) the whole line is promised for 2026-11-12; without inventory installed the requested date is kept and the answer says nothing was checked"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP availability check with ATP quantity and delivery scheduling (backward and forward: pick-pack, loading, transit), schedule lines VBEP with confirmed quantities; Odoo shows forecasted quantity and a commitment date"
customer_varies: whether dates are promised from stock and receipts, or only from stock on hand
- id: SO-37
kind: person
actor: sales_rep
story: As a sales rep, I want each customer's delivery rules (part deliveries allowed or not, how many, what tolerance), so that the warehouse ships the way the customer wants without a phone call.
given: "7311: part deliveries allowed, at most 3 per line, under-delivery tolerance 2%, over-delivery 0%; 8420: complete order only"
when: "inventory asks how to deliver an order for each; 8420's order has one line short; 7311's line of 100 is delivered 98, then a fourth part delivery is tried on another line, and 101 on a line of 100"
then: "GET /sales-orders/{sales_order_id}/fulfilment answers each order's rule (copied from the customer's policy at confirmation, changeable on the order by the rep before the first delivery); inventory holds 8420's delivery until every line is reserved in full; 98 of 100 closes the line (within 2%): nothing is back-ordered and invoicing bills 98; the fourth part delivery is refused (gate max_partials) and 101 (gate within_tolerance); lines to two ship-tos or from two warehouses become two deliveries; orders of one ship-to may go in one delivery where the customer allows combination"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP complete delivery (KZAZU), partial delivery agreement per item (KZTLF: up to 9 partials, A / B / C / D), under / over-delivery tolerance (UNTTO / UEBTO), order combination, delivery split by ship-to and shipping point"
customer_varies: whether customers accept part deliveries; tolerances for bulk goods
- id: SO-38
kind: person
actor: order_admin
story: As sales operations, I want to say what an order of each type must contain before it is confirmed, delivered or billed, so that an incomplete order stops at the right step and says what is missing.
given: "incompletion rules: STANDARD: delivery_terms for a ship-to outside the company's country blocks delivery, customer_po_ref blocks billing where the customer's policy asks; RETURNS: return_reason blocks confirmation; CREDIT_REQ: reference_invoice blocks confirmation"
when: "a rep confirms an export order with no delivery terms, inventory asks to deliver it, and the biller asks to invoice it without a PO"
then: "the order's readiness lists {gate: incomplete:delivery_terms, met: false, reason: \"an export order needs its delivery terms (Incoterms)\", fix: \"Order: delivery terms\"} under delivery, and incomplete:customer_po_ref under billing; confirmation is allowed (the rule blocks delivery only); inventory's fulfilment answer says delivery blocked by incompletion; the invoice request is refused naming the field; the Orders screen filters incomplete orders by what they block; the rules are dated config rows per order type"
screen: Settings
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP incompletion procedure (OVA2 / VUA2: fields per procedure with status groups blocking delivery, billing or saving; incompletion log V.02)"
- id: SO-39
kind: person
actor: order_admin
story: As sales operations, I want to stop an order from being delivered or billed for a reason, and release it, so that a dispute or a check holds the right step and nothing else.
given: "block reasons CHECK_TERMS (delivery) and PRICE_DISPUTE (billing); confirmed order SO-2026-0020"
when: "I set the delivery block CHECK_TERMS with a note; inventory raises the delivery; I release it; I set PRICE_DISPUTE and the biller asks for the invoice; a rep tries to release a block"
then: "the block is an appended row with who and why; inventory still reserves but the delivery does not reach the pick list (fulfilment: delivery_blocked) until the release, also appended; the invoice request is refused (gate billing_block, \"on hold: PRICE_DISPUTE\"); the rep is refused (so:order:block); a CREDIT_REQ or FREE_OF_CHARGE order starts with its type's default block APPROVAL, released only by approval (SO-42, SO-43)"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP delivery block (LIFSK) and billing block (FAKSK) on the header and item, with reasons (OVLS, OVV3)"
- id: SO-40
kind: person
actor: sales_rep
story: As a sales rep, I want an order to know who ordered, where it goes, who gets the invoice and who pays, so that the depot gets the goods, head office gets the bill and the right account's credit is checked.
given: "sold-to 7311 Acme Ltd; partner rows from 2026-10-01: ship-to 7312 Acme Leeds depot (default) and 7313 Acme York, bill-to 7311, payer 7400 Acme Group Treasury; 7400 is on credit hold, mode block"
when: "I raise an order for 7311, pick ship-to 7313, try ship-to 9999, and confirm"
then: "the order defaults ship-to 7312, bill-to 7311 and payer 7400 from the rows in force on the order date and copies them; 7313 is accepted; 9999 is refused (\"9999 is not a ship-to of 7311\"); the credit gate reads the PAYER (payments GET /customers/7400/credit-ok) and refuses; tax is computed for the ship-to's country and the sold-to's VAT status; invoicing receives bp_id = 7400 (the payer, whose receivable it is), sold_to 7311, ship_to 7313 and bill_to 7311 (whose billing profile addresses the invoice); each partner must be an active business partner with the customer role (ship-to: the ship_to role); a partner change after the first delivery is refused; partner rows are dated, from tomorrow"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP partner functions SP sold-to, SH ship-to, BP bill-to, PY payer (KNVP, partner determination procedure); Odoo partner_invoice_id and partner_shipping_id"
customer_varies: whether any customer ships to depots or is paid for by a group company
- id: SO-41
kind: person
actor: sales_rep
story: As a sales rep, I want to authorise a customer's return with an RMA number before the goods come back, and credit only what the warehouse accepts, so that returns are expected, checked and refunded once.
given: "SO-2026-0012 line 1 delivered 24 C62 of COLA-075 (inv:WH1/OUT/00001), invoiced on INV-2026-0040 at 1.20; return reasons DAMAGED and WRONG_ITEM (inspect, full credit), CHANGED_MIND (inspect, 10% restocking fee); return_credit_on inspection"
when: "I raise a RETURNS order for 6 C62 referencing SO-2026-0012 line 1, reason DAMAGED, and confirm it; the goods arrive and inventory reports 5 accepted (4 back to stock, 1 scrapped) and 1 rejected; I also try a return of 30"
then: "RE-2026-0001 is the RMA number on the confirmation sent to the customer; the line copies the original's price, unit and tax category (never re-priced) and names the original order line, delivery and invoice; inventory is told to expect it (POST /expected-returns); 30 is refused (gate within_delivered, \"24 delivered, 0 returned: at most 24\"); on the inspection result the order asks invoicing for one credit note against INV-2026-0040 for 5 C62 (6.00 plus VAT), keyed so:RE-2026-0001:1 and carrying the returns order's line, so the original order still reads 24 delivered, 24 invoiced; the rejected 1 is shown with inventory's reason and credited nothing; return_credit_on receipt credits at receipt instead; a serial-tracked product returns only a serial delivered to this customer (inventory's gate)"
screen: Returns
path: { process: sales-order.return_order, choices: {} }
reference: "SAP returns order RE / advanced returns management (RMA, returns delivery, inspection, logistical follow-up: restock, scrap, return to supplier; refund determination, credit memo); Odoo stock.return.picking and the RMA apps"
customer_varies: whether returns are authorised first; restocking fees; credit on receipt or after inspection
- id: SO-42
kind: person
actor: sales_controller
story: As the finance controller, I want a credit or a debit to a customer raised as a request and approved by me before invoicing sees it, so that money only moves on an approved reason.
given: "INV-2026-0040 to 7311: 24 C62 at 1.20 (28.80 net); the price should have been 1.10"
when: "a rep raises CREDIT_REQ for 24 C62 at 0.10 (2.40) referencing INV-2026-0040 line 1, reason PRICE_ERROR, and DEBIT_REQ for freight forgotten (15.00); the rep approves the credit under a second account; then I approve both"
then: "CR-2026-0001 and DR-2026-0001 are confirmed with the billing block APPROVAL; the rep's second account is refused (same email or same sign-in subject); my approval releases the block: invoicing receives POST /credit-notes against INV-2026-0040 keyed so:CR-2026-0001:1, its lines carrying CR-2026-0001's line (the original order's billed quantity is untouched), and a debit draft keyed so:DR-2026-0001:1 naming INV-2026-0040 in its text; a credit above the invoice line's net less earlier credits is refused (gate within_invoiced, claim rows per invoice line so two requests cannot both take the last pound); neither is delivery-relevant"
screen: Returns
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP credit memo request G2 and debit memo request L2 with billing block 08 until released (V.23), credit memo G2 / debit memo L2 billing"
- id: SO-43
kind: person
actor: sales_rep
story: As a sales rep, I want to send free samples and replacements as orders that are delivered and costed but never billed, so that stock and cost are right and nobody invoices a gift by mistake.
given: "FREE_OF_CHARGE approval limit 250.00 of statistical value; samples of 12 C62 COLA-075 for prospect 7311; a replacement DRILL-500 (base price 320.00) referencing RE-2026-0002"
when: I raise both and confirm
then: "every line is priced by the procedure for statistics only (12 x 1.20 = 14.40 shown, net 0.00) and its item category FREE is not billing-relevant; the samples confirm; the replacement waits for a controller (gate free_of_charge_approval, 320.00 above 250.00; the requester never approves, by email and sub); inventory delivers both with purpose free_of_charge, so the cost goes to the samples account (inventory's account map), not to cost of sales; nothing is ever requested from invoicing; the order is done when delivered"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP free-of-charge delivery FD / subsequent free-of-charge delivery SDF, item category KLN / TANN (not billing relevant, statistical pricing)"
- id: SO-44
kind: person
actor: sales_rep
story: As someone at the trade counter, I want a rush order that ships today and a cash sale that is delivered, invoiced and paid on the spot, so that a walk-in customer leaves with the goods and a receipt.
given: "types RUSH (delivery created at confirmation, priority 1) and CASH (delivery and invoice at confirmation, paid on the spot); invoicing's owner control auto_issue_sources contains sales_order:cash"
when: "I confirm a RUSH order for 7311 at 10:00, and a CASH order of 6 C62 COLA-075 for a walk-in customer"
then: "the RUSH order's reservation asks inventory for a delivery today with priority rush, first on the pick list; the CASH order skips the credit gate (paid now), inventory's delivery is validated at the counter (a service line is recorded by hand), the invoice is requested and issued in the same step, and the cashier records the payment in payments against that invoice; a CASH order takes no deposit and no billing plan; both follow the normal readiness gates otherwise"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP rush order SO (immediate delivery) and cash sale BV (immediate delivery and billing, cash receipt); Odoo Point of Sale (outside these bricks)"
customer_varies: whether there is a trade counter at all
- id: SO-45
kind: person
actor: sales_rep
story: As a sales rep, I want to keep our stock at a customer's site and bill only what they use, so that the customer has goods at hand and we invoice consumption, not shipments.
given: "7311 keeps consignment stock of COLA-075 at its site (inventory location UK1:CONSIGNMENT:7311, ours and valued by us)"
when: "I raise CONS_FILL for 240 C62; 7311 reports using 60: CONS_ISSUE for 60; then CONS_PICKUP of 24; then CONS_RETURN of 6 of the used ones; then CONS_ISSUE for 200"
then: "CONS_FILL is delivered by inventory into 7311's consignment stock: not invoiced, not available to other orders; CONS_ISSUE takes 60 out of it to the customer, costed then, and is invoiced at the price on the issue date (find_price priced_on the issue date); CONS_PICKUP moves 24 back to WH1 and bills nothing; CONS_RETURN credits 6 against the issue's invoice (a return, SO-41); CONS_ISSUE for 200 is refused (gate consignment_on_hand, \"7311 holds 162 C62 on consignment\"); the consignment stock is read from inventory, never kept here"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP customer consignment KB fill-up, KE issue, KA pick-up, KR return, special stock W; Odoo has consignment only for supplier stock (owner on quants)"
customer_varies: whether any customer holds our stock
- id: SO-46
kind: person
actor: biller
story: As the biller, I want a big job invoiced in stages (on order, on installation, on acceptance), and repeating lines billed by period, so that cash comes in as the work progresses and no stage is billed twice.
given: "order SO-2026-0030: line 1 PROJ-INSTALL 20,000.00 with milestones M1 30% on confirmation, M2 40% on installed, M3 30% on accepted; line 2 12 x HOST-M monthly"
when: "the order is confirmed, the fulfiller releases M2 when installed, M3 is released, and the invoice is requested after each"
then: "M1 is due at once: a request of 6,000.00 (an invoice line naming the order line, \"Milestone 1 of 3: on order (30%)\"); line 2 goes to invoicing as a schedule (POST /schedules, SO-20); M2 holds a billing block until released, then 8,000.00; M3 takes the remainder (20,000.00 - 6,000.00 - 8,000.00 = 6,000.00) so the stages sum exactly even when percentages do not divide evenly; confirming with milestones that do not total 100% is refused (gate plan_complete); a milestone line is billed by value: its billed net never exceeds its released milestones; amending its price re-spreads only unreleased milestones; equal instalments of a fixed total go to invoicing as a schedule with billing_kind instalment"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP billing plans: milestone billing (billing plan type 01, milestones from the project or by date, billing block per date) and periodic billing (type 02); Odoo down payments by percentage, and subscriptions"
customer_varies: whether large jobs are billed in stages; on what events
- id: SO-47
kind: person
actor: biller
story: As the biller, I want each invoice to take off part of a deposit in proportion, or the whole of it, so that the deposit is used up exactly by the end and the tax on it is right.
given: "order of 10,000.00 net, standard rated; deposit invoice D1 5,000.00 (tax category standard) and D2 1,000.00 (standard), both issued; deduction mode proportional (the order's default)"
when: "a first part invoice of 4,000.00 of goods is requested, then the final invoice of 6,000.00"
then: "the first invoice deducts 2,400.00 (the deposits' share, 6,000 / 10,000 x 4,000 = 2,400.00, taken oldest first: 2,400.00 from D1) with one deduction line per deposit naming deducts_invoice_id and carrying the deposit's tax category; D1 has 2,600.00 left, D2 1,000.00; the final invoice deducts everything left (2,600.00 from D1 and 1,000.00 from D2); a deduction above a deposit's remaining, or from a deposit still a draft, is refused; two concurrent requests cannot deduct the same amount twice (a claim row per (deposit, seq), plain INSERT); modes full_first (each invoice deducts as much as it can) and manual (the biller types an amount per deposit) exist per order; cancelling an order with deposit left asks invoicing for a credit note against the deposit invoice"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP down payment requests (FAZ) and clearing on the final invoice (partial clearing allowed, by the down payment's tax code); ar AR-15 keeps the same rule"
- id: SO-48
kind: person
actor: sales_rep
story: As a sales rep, I want an item the supplier ships straight to the customer ordered from them when I confirm, and delivered when they ship, so that we sell what we never stock without anyone re-keying it.
given: "PRINTER-X (TRADING) sold to 7311 for ship-to 7313, usage drop_ship (item category THIRD_PARTY); purchasing installed"
when: "I confirm the order; purchasing later records the supplier's shipping notice SN-55 for 2; a fulfiller tries to record a delivery by hand; I try to cancel after purchasing ordered it"
then: "confirmation asks purchasing once for a purchase request for 2 PRINTER-X delivered to 7313 (POST /purchase-requests/from-sales-order, keyed so:<sales_order_id>:<line_no>@r<revision>), and nothing is reserved in inventory; purchasing records the shipment and calls record_delivery with source purchasing and delivery_ref pur:SN-55, so the line is delivered and invoiceable per its policy; the hand delivery is refused (\"a third-party line is delivered by the supplier\"); the cancellation is refused with purchasing's reason (\"PO-0144 is confirmed with the supplier\") until purchasing cancels it"
screen: Order
path: { process: sales-order.sales_order, choices: {} }
reference: "SAP third-party order processing (item category TAS, automatic purchase requisition, statistical goods receipt, billing on the supplier's invoice or shipment); Odoo dropship route"
customer_varies: whether any supplier drop-ships
# ── system ────────────────────────────────────────────────────────────────────────────────
- id: SO-12
kind: system
actor: system
trigger: on read (derived)
story: The system works out fulfilment and invoice status from quantities, so that "delivered" and "invoiced" are never stale.
given: a line of 12, 5 delivered, 5 invoiced
when: anyone opens the order
then: "fulfilment partly delivered, invoice status partly invoiced, to invoice 0 (delivered policy) or 7 (ordered policy); nothing stored but the quantities"
reference: not built
- id: SO-13
kind: system
actor: system
trigger: when the last line is fully delivered and invoiced
story: The system locks a finished order, so that a done order cannot be edited by accident.
given: an order whose every line is delivered and invoiced in full
when: the last invoice is issued
then: 'the order is done; an edit is refused ("done: raise a new order"); this runs once however often it is triggered'
reference: not built
- id: SO-14
kind: system
actor: system
trigger: when a customer's purchase order arrives by email
story: The system drafts an order from the customer's purchase order, so that the rep checks instead of types.
given: a PDF purchase order from a known customer quoting quote Q-2026-0042
when: it is read
then: a suggestion (lines matched to product_id and unit_code with a confidence, the PO number, differences from the quote) shown on an empty draft for the rep (SO-16); nothing is on the order until a person accepts it, and nothing is confirmed without a person
reference: not built
- id: SO-33
kind: system
actor: system
trigger: every night for the previous business day's issued invoices and approved credit notes (and on demand)
story: The system accrues each rebate agreement as invoices are issued, so that the books carry what we will owe the customer before the year ends.
given: "RB-2027-7311 active, accrual rate 2%; on 2027-02-03 invoicing issued INV-2027-0101 to 7311 with 10,000.00 of drinks; on 2027-02-10 a credit note of 500.00 against it"
when: the nightly accrual runs for 2027-02-03 and 2027-02-10, and again for 2027-02-03
then: "an accrual row of 200.00 keyed (agreement, invoice_id, line_no) and a journal to the ledger (rebate expense 200.00 debit / accrued rebates 200.00 credit, journal_id derived from (agreement, source document)); the credit note reverses 10.00 the same way; the re-run writes nothing; the basis is read from invoicing (GET /billed-lines), never from ar_ tables; the business date is today in business_timezone, never the UTC date, and a run never goes back past a signed migration or a closed period (the journal is refused by the ledger and kept pending with its reason)"
reference: "SAP rebate accruals posted with each billing document (BO01 accrual condition, ERB / ERU accounts)"
- id: SO-36
kind: system
actor: system
trigger: every business day at the start of the day (the composition's clock), and when an order is cancelled
story: The system re-promises every quantity not yet delivered against the stock and receipts it can see, so that a later supply date reaches the rep before it reaches the customer.
given: "SO-2026-0041 line 1 has 240 C62 confirmed for 2026-11-12 against PO-0101 arriving 2026-11-09; purchasing moves PO-0101 to 2026-11-16; another order's cancellation frees 90 LTR today"
when: the re-promise runs on 2026-11-05, and runs again the same day
then: "open schedule lines are re-checked in order of priority (rush first) then order date (first come, first served); the 240 C62 now confirm 120 for 2026-11-07 (from the freed 90 LTR) and 120 for 2026-11-19, appended as a new schedule version with the reason; inventory's reservation is re-sent for the same revision with schedule version 2; the order's owner gets a task \"tell 7311: 120 C62 now 19 Nov\"; an EARLIER date is applied only to quantities not yet promised to the customer and is otherwise offered, never pushed; the second run the same day writes nothing"
reference: "SAP backorder processing and rescheduling (V_V2, ATP re-check, BOP in aATP)"
# ── integration ───────────────────────────────────────────────────────────────────────────
- id: SO-27
kind: person
actor: sales_rep
story: As a sales rep, I want a product priced per 100 to stay priced per 100 on the order, so that a line of screws bills to the penny the customer was quoted.
given: "SCREW-M4, base C62, at 2.4500 per 100 C62 on the customer's list (PRD-31); the customer's discount 10% on the line"
when: I order 250 C62, then add a line of 3 BX500 (a box of 500 with no price row of its own), then amend the first line to 400
then: "line 1 holds unit_price 2.4500, pricing_quantity 100, pricing_unit_code C62 with its conversion, and net 5.51 (250 x 2.4500 / 100 = 6.125, less 10% = 5.5125, rounded once); line 2 is in BX500 but priced in C62 per 100 (the row's unit, as find_price answered), quantity_priced 1 500 and net 36.75 before discount; nothing is divided down to a piece price; the amendment re-prices line 1 only (9.80 before discount, 8.82 after); invoicing receives the pricing quantity on each line so the invoice nets the same"
screen: Order
reference: "SAP condition pricing unit carried on the sales item (KOMV-KPEIN / KMEIN, VBAP net value); Odoo has none"
- id: SO-28
kind: person
actor: sales_rep
story: As a sales rep, I want to be told when a quantity breaks the product's delivery unit, so that we do not promise 20 bottles that ship in sixes.
given: "COLA-075 delivery rule multiples of 6 C62 (PRD-30); delivery_multiple_mode warn (the default)"
when: I confirm an order of 20 C62; then the owner sets block from tomorrow and I confirm another order of 20 C62, and one of 2 PK24, and amend the first order to 22
then: "the first confirms with the gate met in warn mode and its reason shown and recorded on the order {gate: delivery_multiple, met: true, reason: \"COLA-075 is delivered in multiples of 6 C62: 20 is not one (18 or 24)\", fix: \"change to 18 or 24, or confirm as it is\"}; under block the second is refused with met false and the same reason; 2 PK24 (48 C62) passes; the amendment to 22 is checked the same way under the mode in force on its date; an order from an accepted quote meets the same gate; a line of a product with no delivery rule always passes"
screen: Order
reference: "SAP delivery unit and minimum delivery quantity in the sales item (MVKE-SCHME, MVKE-LFMNG), the rounding message in VA01"
customer_varies: whether a broken pack is a warning or not allowed at all
- id: SO-01
kind: integration
actor: system
trigger: when a quote version is accepted (brick-quote decision)
story: An accepted quote becomes a draft order in one step, so that nothing is re-typed.
given: quote Q-2026-0042 version 2 accepted, with three lines and a customer
when: the acceptance is handed over
then: 'one draft order keyed sales_order_id = "quote:Q-2026-0042@v2" with the version''s lines copied as accepted: product_id, description, quantity, unit_code (the quote line''s, or the product''s sales unit for a quote line written before units), list_price and unit_price, and priced_from = the quote line (quote:Q-2026-0042@v2#1) with the quote''s own source and priced_on; the order never re-prices an accepted line; tax is computed by the tax module for the order date; handing it over twice returns the same order; where the control says so it is confirmed at once, through the same readiness gates as a person. v5, the exact seam: the composition''s adapter is the ONE caller of brick-quote''s POST /quotes/{quote_id}/versions/{version}/decision for an acceptance; on its 2xx with outcome ACCEPTED it reads GET /quotes/{quote_id} (the accepted version) and calls POST /sales-orders/from-quote; a quote line not priced by product (its priced_from does not name a product price list row, PRD-20) is refused (409, gate priced_by_product, fix "re-price the quote from product before it is sent"), so a price from a second price book never reaches an order; the order''s partners (SO-40) and type STANDARD are defaulted as for a hand order'
owner: sales-order
reference: "the route exists (erp-brick-sales-order POST /sales-orders/from-quote, idempotent on the key); the adapter is not built, and brick-quote still prices from its own book (l2c leftovers): until quote reads find_price (product PRD-20), the composition does not install SO-01"
- id: SO-20
kind: integration
actor: system
trigger: when an order is confirmed and a line is recurring or metered
story: A repeating line becomes a billing schedule in invoicing, so that monthly lines bill themselves.
given: a confirmed order with a recurring line of 12 periods at 120.00
when: it is confirmed
then: invoicing receives plan_billing for that line, keyed "{sales_order_id}|{line_no}"; repeating the call creates nothing
owner: ar
reference: not built
- id: SO-21
kind: integration
actor: biller
trigger: when the biller asks for an invoice or a deposit (SO-08, SO-09)
story: The order asks invoicing for a draft; invoicing owns the invoice.
given: invoiceable quantities on an order
when: the request is made
then: invoicing raises one draft per request, keyed "so:{sales_order_id}:{request_no}", each line carrying sales_order_id and line_no; the order reads invoiced quantities back from invoicing and never writes ar_ tables
owner: ar
reference: not built
- id: SO-22
kind: integration
actor: system
trigger: before confirmation
story: The order reads whether the customer is on credit hold.
given: payments derives a hold for the customer
when: the rep confirms
then: the hold, its reason and the hold mode (warn | block) come from payments' read route (credit holds are payments', decided 2026-10-18); the order refuses or warns as that mode says
owner: payments
reference: not built
- id: SO-23
kind: integration
actor: system
trigger: when inventory validates a delivery or a customer return (milestone M4; v3)
story: With inventory installed, a stocked goods line is delivered only from inventory, so that the warehouse and the order agree and nobody keys a delivery twice.
given: inventory installed; an order with line 1 of 48 bottles of COLA-075 (stocked) and line 2 of 3 HUR installation (a service)
when: inventory validates WH1/OUT/00001 for 24 bottles of line 1, and a fulfiller records 5 delivered by hand on line 1 and 3 HUR on line 2
then: "inventory's call (POST /sales-orders/{sales_order_id}/deliveries, source inventory, delivery_ref inv:WH1/OUT/00001) records 24 on line 1 once, however often it is retried; the hand delivery on line 1 is refused (\"COLA-075 is stocked: its deliveries come from inventory\"); the 3 HUR on line 2 are recorded by hand as in v2; the order's rules (no over-delivery, idempotent per reference) apply unchanged; without inventory every line is delivered by hand"
owner: sales-order
reference: not built
- id: SO-24
kind: integration
actor: system
trigger: on the one-off Odoo migration (milestone M8), after business-partner, product and tax have migrated
story: The customer's Odoo sales orders arrive once, open ones live and the rest as history, and can be re-run safely.
given: an Odoo database with 40 confirmed orders, 6 of them open (part delivered or part invoiced), 3 cancelled
when: the migration runs twice
then: 'orders keyed "odoo:<company_id>:<sale.order name>" keep their Odoo number and prices; open orders are confirmed with an opening delivery per line of qty_delivered; the rest are done or cancelled, read-only, never re-priced or re-taxed, and ask invoicing for nothing; the second run changes nothing; the reconciliation (migration.odoo.reconcile) shows zero unexplained differences before the customer signs it'
owner: sales-order
reference: not built
- id: SO-25
kind: integration
actor: system
trigger: when an order is confirmed, amended or cancelled with inventory installed (v3)
story: The order reserves its stocked lines in inventory, so that what is promised to one customer is not sold to another.
given: inventory installed; an order of 48 bottles of COLA-075 and 3 HUR installation
when: it is confirmed (revision 1), amended to 60 bottles (revision 2), then cancelled
then: "confirm calls POST /reservations/sales-orders/{sales_order_id} with revision 1 and line 1 only as stocked (inventory ignores and names the service line, and the order keeps stocked false on it); the amendment calls it again with revision 2 and 60; cancel calls POST /reservations/sales-orders/{sales_order_id}/release with the reason; a retry of any call changes nothing; a short reservation is shown on the order, and never blocks confirmation"
owner: inventory
reference: not built
- id: SO-26
kind: system
actor: system
trigger: when a delivery or a correction is recorded (v3)
story: A delivered quantity converts exactly to the product's base unit, so that the order, the stock and the invoice agree to the last unit.
given: line 1 in bottles (unit_numerator 3 / unit_denominator 4 of a LTR, base LTR to 3 decimals)
when: 24 bottles are delivered, then 0.3333 of a bottle
then: "24 bottles are recorded as 18 LTR in the base unit beside the line's unit; 0.3333 is refused (\"0.3333 bottles is not an exact quantity of LTR to 3 decimals\") and nothing is recorded; never rounded"
reference: not built
- id: SO-49
kind: integration
actor: system
trigger: when inventory receives and inspects a returned line (inventory INV-14)
story: Inventory reports what came back and what passed inspection on the returns order, so that the credit follows the warehouse's decision.
given: "RE-2026-0001 line 1 expects 6 C62 of COLA-075"
when: "inventory records the receipt (record_delivery on the returns order: 6, source inventory, delivery_ref inv:WH1/RET/00001) and then POST /sales-orders/RE-2026-0001/return-inspections with 5 accepted (4 restock, 1 scrap) and 1 rejected, twice"
then: "the returns line reads received 6, accepted 5, rejected 1, recorded once however often it is repeated; accepted + rejected never exceed received (409 within_received); with return_credit_on inspection the credit request for 5 is made (SO-41); the returns order is done when every line is inspected and its credit issued"
owner: sales-order
reference: not built
- id: SO-50
kind: integration
actor: system
trigger: when manufacturing's MRP-lite reads demand (MFG-23)
story: Planning reads the open quantity of every order for a product, a warehouse and a date range in one call, so that production is planned from what customers ordered without reading orders one by one.
given: 'open lines for BAG-ETH-250 from ROASTERY: SO-2026-0100 line 1, 40 C62 requested 2026-11-20 with 10 delivered; SO-2026-0104 line 2, 24 C62 requested 2026-11-22, confirmed for 2026-11-23; a draft order for 50; a cancelled order for 12'
when: 'manufacturing calls GET /demand?product_id=BAG-ETH-250&warehouse_id=ROASTERY&from=2026-11-16&to=2026-11-29'
then: >-
two rows: (2026-11-20, open 30 C62 = 30 base) and (2026-11-23, open 24 C62), each with its order and line, dated
by the latest schedule line's confirmed date, else the requested date; open = ordered - delivered - closed short,
in the line's unit and in the base unit; only confirmed orders of goods lines delivered from that warehouse are
demand (drafts, quotes, cancelled and THIRD_PARTY lines are not); no price, customer name or amount is answered
owner: sales-order
reference: not built
# ── v9 (2026-10-25, wave 4): approved hours delivered on a service line ──
- id: SO-51
kind: integration
actor: system
trigger: when timesheets approves billable hours on a project planned from this order (timesheets TS-12)
story: Approved hours are delivered on the order's service line by timesheets, so that time and materials are invoiced by the order at the price it was sold at.
given: 'order quote:Q-2026-0042@v2 line 2: 120 HUR of CONSULT-SR at 95.0000, invoicing policy delivered, 40 HUR delivered'
when: 'timesheets calls POST /sales-orders/quote:Q-2026-0042@v2/deliveries {line_no 2, quantity "19.0000", source timesheets, delivery_ref "ts:4410:2026-W47:r1:1"} twice, then a correction {quantity "-2.0000", delivery_ref "ts:4410:2026-W47:r2:1", corrects_ref "ts:4410:2026-W47:r1:1"}'
then: >-
the line reads 59 HUR delivered once, then 57; the order invoices delivered quantities by its own policy
(SO-21); a delivery on a goods line is refused 409 {gate: service_line}; above the ordered quantity 409
{gate: within_tolerance} unless the line allows over-delivery (v11: one gate name, the signed workflow's, for every source; the story and two routes said within_ordered); a correction that would take delivered below what is
already invoiced is refused 409 {gate: not_below_invoiced, fix: "credit the invoice in invoicing"}; the order
never reads ts_ tables
owner: sales-order
reference: not built
processes:
- key: sales_order
pcf_id: 10185
pcf_name: Manage sales orders
reference_names: ["Quote to Order", "Order to Cash"]
brick: sales-order
- key: return_order
pcf_id: null # APQC PCF v8.0 element for customer returns to be verified in erp-bench/standards before sign-off
pcf_name: Manage customer returns (authorise, receive, inspect, credit)
reference_names: ["Returns", "RMA", "Advanced returns management"]
brick: sales-order
- key: rebate_agreement
pcf_id: null # APQC PCF v8.0 element for rebates to be verified before sign-off
pcf_name: Manage customer rebates (agree, accrue, settle)
reference_names: ["Rebates", "Settlement management"]
brick: sales-order
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, 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" }
entities:
- id: sales_order
meaning: what a customer has committed to buy, and on what terms
natural_key: 'sales_order_id: VARCHAR, derived from its source ("quote:<quote_id>@v<version>", "po:<bp_id>:<customer PO>") or a uuid for an order by hand (shared.yaml keys.sales_order_id). The order NUMBER is stamped at confirmation, never the key'
key_attributes: [order_no, "order_type_code (v5; fixed once confirmed)", "bp_id (the sold-to)", "ship_to_bp_id, bill_to_bp_id, payer_bp_id (v5: copied from customer_partner at creation)", "ship_to_address (v5: copied from business-partner when it serves addresses, else typed on the order; v8: the address shape {lines, city, postcode, region, country}, shared.yaml conventions.address)", owner, currency, price_list_id, "price_group (v5)", pricing_procedure_code, lifecycle, revision, customer_po_ref, ordered_on, requested_delivery_on, confirmed_at, delivery_terms, payment_terms_id, deposit_pct (percent), "deposit_deduction_mode (v5: proportional | full_first | manual)", "partial_delivery (v5: allowed | complete_line | complete_order), max_partials, under_tolerance_pct, over_tolerance_pct, combine_allowed", "priority (v5: normal | rush)", "ref_sales_order_id, ref_line_no, ref_invoice_id (v5: returns, memo requests, consignment returns, replacements)", "return_reason (v5)", "approved_by_email, approved_by_sub, approved_at (v5: memo requests, free of charge)", subtotal, tax_total, total, source_kind, source_id]
relationships: ["-> business_partner", "1..n order_line", "0..n delivery", "0..n invoice_request", "0..n send"]
master_data: module-owned
reference_table: so_order
note: "owner is the rep the order belongs to (the caller, or the quote's owner on hand-over): row visibility scopes on it. v5: the PAYER is whose receivable it is (invoicing's bp_id, the credit check); the sold-to is whose order and prices it is; the ship-to decides the tax place of supply for goods and the freight"
- id: order_line
meaning: one product, quantity and price on the order
natural_key: [sales_order_id, line_no]
key_attributes: [product_id (the SKU: one variant), description, "category_code (v8: copied from product's price answer, line.category_code; what the category accesses match)", unit_code (UN/ECE Rec 20), quantity (in unit_code, to the unit's quantity_decimals), unit_numerator, unit_denominator (the unit's conversion to the base unit, copied), list_price, "unit_price (NUMERIC 19,4, per pricing_quantity of pricing_unit_code)", "pricing_unit_code, pricing_numerator, pricing_denominator, pricing_quantity (v4: copied from find_price; default the line's unit and 1)", discount_pct (percent), priced_on, priced_from, tax_category, tax_code, net_amount, tax_amount, invoicing_policy (ordered | delivered), "stocked (v3: inventory's reservation answer took it)", "item_category_code (v5: determined at line time, kept)", "usage (v5: normal | drop_ship)", "warehouse_id (v5: the delivering site)", "gross_weight, weight_unit_code, measure_valid_from (v5: copied from product for freight)", "statistical_value (v5: free-of-charge lines)", "min_remaining_shelf_life_days (v5: from the customer's policy)", billing_kind, "billing_plan (single | periodic | arrears | milestone (v5) | instalment (v5))", "ref_sales_order_id, ref_line_no, ref_invoice_id, ref_invoice_line_no, ref_delivery_ref (v5: what a returns, memo or consignment-return line refers to)", quote_ref (quote_id, version, line_no)]
master_data: module-owned
reference_table: so_line
note: "description, unit and its conversion, tax category, invoicing policy and billing are COPIES taken when the line was added (product PRD-21). priced_from is the source find_price named (price list, rule, row), or the quote line for a line from an accepted quote, or odoo:<pricelist> for a migrated line; a line is re-priced only when an amendment changes it"
- id: line_condition
meaning: "one condition on a line (BASE, a discount, a surcharge, a share of a header condition, freight, a rebate accrual rate) as priced (v5)"
natural_key: [sales_order_id, line_no, step, revision]
key_attributes: [condition_type_code, "rate (NUMERIC(19,4): an amount per pricing quantity, or a percent)", "amount (NUMERIC(18,2), signed)", "basis (NUMERIC(18,2))", "source (the record found: condition_record key and the access step, or product's priced_from for BASE, or manual with who)", statistical (bool), header_share_of (the header condition it is a share of)]
master_data: "module-owned (append per revision; frozen at confirmation)"
reference_table: so_line_condition
note: "line 0 holds the header conditions as typed; each line holds its share, the remainder on the largest (by net, or by weight for freight), so the shares sum exactly"
- id: schedule_line
meaning: "a quantity of a line promised for a date, from inventory's ATP (v5)"
natural_key: [sales_order_id, line_no, schedule_version, seq]
key_attributes: ["requested_on, requested_quantity", "confirmed_on (the delivery date promised), material_available_on, confirmed_quantity (in the line's unit), confirmed_base", reason, at]
master_data: "module-owned (append-only: a re-promise is a new schedule_version; the latest version is in force)"
reference_table: so_schedule_line
- id: billing_milestone
meaning: "one stage of a line billed by milestone: a percent or an amount, on a date or an event (v5)"
natural_key: [sales_order_id, line_no, milestone_no]
key_attributes: ["percent or amount (NUMERIC(18,2); the last milestone takes the remainder)", "trigger (on_confirm | on_date | on_event)", due_on, event_name, released_by, released_at, request_no]
master_data: module-owned
reference_table: so_billing_milestone
- id: deposit_deduction
meaning: "how much of one deposit invoice an invoice request deducts (v5; the claim row two concurrent requests race for)"
natural_key: [sales_order_id, deposit_request_no, seq]
key_attributes: [request_no, deposit_invoice_id, "amount (NUMERIC(18,2))", tax_category (the deposit line's), remaining_after]
master_data: "module-owned (append-only; seq claimed with a plain INSERT: the loser re-reads what is left)"
reference_table: so_deposit_deduction
- id: return_inspection
meaning: "what inventory found when a returned line was inspected (v5)"
natural_key: [sales_order_id, line_no, inspection_ref]
key_attributes: [received_quantity, accepted_quantity, rejected_quantity, "decision (restock | scrap | to_supplier)", reason, inspected_on]
master_data: "module-owned (append-only; written only by inventory's service client)"
reference_table: so_return_inspection
- id: credit_claim
meaning: "how much of an invoice line a credit or return request takes (v5; stops two requests crediting the last pound twice)"
natural_key: [invoice_id, invoice_line_no, seq]
key_attributes: [sales_order_id, request_no, "amount (NUMERIC(18,2))", remaining_after]
master_data: "module-owned (append-only; plain INSERT on (invoice_id, invoice_line_no, seq))"
reference_table: so_credit_claim
- id: order_block
meaning: "a delivery or billing block set or released on an order, with why (v5)"
natural_key: [sales_order_id, seq]
key_attributes: ["kind (delivery | billing)", block_reason_code, action (set | release), note, by_email, by_sub, at]
master_data: "module-owned (append-only); a block is in force while its last row is set"
reference_table: so_order_block
- id: delivery
meaning: a record that some quantity of a line was delivered or done
natural_key: [sales_order_id, line_no, delivery_ref]
key_attributes: [quantity (negative on a correction), "quantity_base (v3: exact, in the product's base unit)", "catch_quantity, catch_unit_code (RESERVED until M7: the actual weight delivered, inventory deferred.catch_weight; NULL)", kind (delivery | correction | opening), corrects_ref, delivered_on, recorded_by, source (manual | inventory | migration)]
master_data: module-owned
reference_table: so_delivery
note: "append-only: a mistake is a correction row against the original, never an edit; delivered_on is never after today"
- id: invoice_request
meaning: what the order asked invoicing to bill, and when
natural_key: [sales_order_id, request_no]
key_attributes: [kind (regular | deposit | final), lines_json, invoice_id, requested_at, requested_by]
master_data: module-owned
reference_table: so_invoice_request
note: "invoiced quantities are read back from invoicing (its lines carry sales_order_id and line_no); this table is the request log, not the truth of what was billed"
- id: revision
meaning: a change to a confirmed order, with why and who approved it
natural_key: [sales_order_id, revision]
key_attributes: [reason, changed_lines_json, approved_by, approved_at]
master_data: module-owned
reference_table: so_revision
- id: send
meaning: a record that the confirmation went out
natural_key: [sales_order_id, sent_at]
key_attributes: [channel, address, revision]
master_data: module-owned
reference_table: so_send
- id: number_series
meaning: the counter behind order numbers (state, not a setting); v5 one series per order type
natural_key: series_id
key_attributes: [high_water_mark]
note: "v5: claimed by a plain INSERT of (series_id, number) in so_number_claim within the confirmation's write, so two confirmations never share a number, a refused one leaves no gap, and a number is never reused; a migrated series continues after Odoo's highest"
master_data: module-owned (state)
reference_table: so_number_series
- id: number_claim
meaning: "one order number claimed from a type's series (v5)"
natural_key: [series_id, number]
key_attributes: [sales_order_id, claimed_at]
master_data: "module-owned (a claim row per number, plain INSERT in the confirmation's own write)"
reference_table: so_number_claim
- id: suggestion
meaning: what an AI step suggested on an order, and what a person did with it (the audit of an AI step)
natural_key: [sales_order_id, step, suggestion_no]
key_attributes: [model, inputs_ref (the PO document's object key), output_json, decision (accepted | changed | rejected | open), decided_by, decided_at]
master_data: module-owned
reference_table: so_suggestion
- id: customer_order_policy
meaning: how one customer orders (PO required, default deposit), from a