dodil/erp-brick-sales-order
DODIL ERP brick: sales order
tests/test_stories.py
"""One acceptance test per person/system/integration story of module.yaml, named for its id, through the API door."""
from datetime import date
from decimal import Decimal
from conftest import (
ADMIN,
ALICE,
BILLER,
FULFILLER,
OWNER,
REP,
REP2,
SYSTEM,
api,
confirm,
confirmed,
deliver,
get,
hand,
must,
on_day,
refused,
)
D = Decimal
TODAY = "2026-11-20"
WIDGETS = lambda n=12: [{"product_id": "WIDGET", "quantity": str(n), "unit_code": "C62"}] # noqa: E731 delivered policy, 10.00
FEES = lambda n=1: [{"product_id": "FEE", "quantity": str(n), "unit_code": "C62"}] # noqa: E731 ordered policy, 1000.00
def line(order, n=1):
return next(x for x in order["lines"] if x["line_no"] == n)
def set_policy(bp, valid_from, day_before, **kw):
with on_day(f"{day_before}T09:00:00+00:00"):
return must(api("POST", "/customer-order-policies", ADMIN, {"bp_id": bp, "valid_from": valid_from, **kw}))
def invoice(order, who=BILLER):
return api("POST", f"/sales-orders/{order['sales_order_id']}/request-invoice", who, {})
# ── person stories ────────────────────────────────────────────────────────────────────────────
def test_SO_02_a_hand_order_is_priced_by_find_price_and_taxed(neighbours):
o = hand()
assert o["lifecycle"] == "draft" and o["ordered_on"] == TODAY and o["owner"] == "sales_rep@shop.example"
pack, hours = line(o, 1), line(o, 2)
assert (pack["unit_code"], D(pack["quantity"]), D(pack["unit_price"]), D(pack["net_amount"])) == ("PK24", 2, D("24.00"), D("48.00"))
assert pack["unit_numerator"] == 24 and pack["unit_denominator"] == 1
assert "LIST-GBP" in pack["priced_from"] and "PK24@2026-10-01" in pack["priced_from"]
assert pack["priced_on"] == TODAY and D(pack["tax_amount"]) == D("9.60") and pack["tax_code"] == "S"
assert (hours["unit_code"], D(hours["net_amount"]), hours["invoicing_policy"]) == ("HUR", D("240.00"), "delivered")
assert (D(o["subtotal"]), D(o["tax_total"]), D(o["total"])) == (D("288.00"), D("57.60"), D("345.60"))
assert all(c[5] == date(2026, 11, 20) for c in neighbours.product.calls) # priced_on = today
def test_SO_02_a_unit_the_product_does_not_sell_in_is_refused_and_nothing_is_written():
reason = refused(api("POST", "/sales-orders", REP, {"bp_id": 7311, "currency": "GBP", "sales_order_id": "X1",
"lines": [{"product_id": "SETUP", "quantity": "1", "unit_code": "C62"}]}), 422)
assert "not counted in C62" in reason
assert api("GET", "/sales-orders/X1", ALICE)[0] == 404
def test_SO_02_an_order_cannot_be_back_dated():
assert "dated today (2026-11-20)" in refused(api("POST", "/sales-orders", REP, {"bp_id": 7311, "currency": "GBP", "lines": WIDGETS(1), "ordered_on": "2026-11-19"}))
assert hand(ordered_on=TODAY)["ordered_on"] == TODAY
def test_SO_02_the_same_order_id_returns_the_same_order():
a = hand(sales_order_id="SO-HAND-1")
b = hand(sales_order_id="SO-HAND-1")
assert a["sales_order_id"] == b["sales_order_id"] and a["version"] == b["version"]
assert len(must(api("GET", "/sales-orders", ALICE))["orders"]) == 1
def test_SO_03_confirming_numbers_the_order_and_freezes_it():
o = hand()
r = must(api("GET", f"/sales-orders/{o['sales_order_id']}/readiness", REP))["readiness"]["confirm"]
assert {g["gate"] for g in r} >= {"customer_active", "lines_priced", "units_valid", "lines_taxed", "credit", "po_ref", "no_open_suggestion"}
assert all(g["met"] for g in r)
c = confirm(o)
assert c["lifecycle"] == "confirmed" and c["order_no"] == "SO-2026-0001" and c["revision"] == 1 and c["confirmed_on"] == TODAY
assert confirm(hand())["order_no"] == "SO-2026-0002"
again = must(api("POST", f"/sales-orders/{o['sales_order_id']}/confirm", REP, {}))
assert again["order_no"] == "SO-2026-0001" and again["version"] == c["version"] # a repeat changes nothing
def test_SO_03_a_gate_unmet_refuses_with_the_readiness_words(neighbours):
neighbours.payments.status[7311] = {"hold": True, "reason": "2 invoices over 60 days", "mode": "block"}
o = hand()
gate = next(g for g in must(api("GET", f"/sales-orders/{o['sales_order_id']}/readiness", REP))["readiness"]["confirm"] if g["gate"] == "credit")
assert gate == {"gate": "credit", "met": False, "reason": "on hold: 2 invoices over 60 days", "fix": "ask an order admin to override"}
said = refused(api("POST", f"/sales-orders/{o['sales_order_id']}/confirm", REP, {}))
assert f"credit: {gate['reason']} (fix: {gate['fix']})" in said
assert get(o["sales_order_id"])["lifecycle"] == "draft"
def test_SO_03_the_customer_must_exist_and_be_active(neighbours):
assert "customer_active" in refused(api("POST", f"/sales-orders/{hand(bp_id=7312)['sales_order_id']}/confirm", REP, {}))
assert "there is no business partner 9999" in refused(api("POST", f"/sales-orders/{hand(bp_id=9999)['sales_order_id']}/confirm", REP, {}))
def test_SO_04_a_dated_po_policy_applies_from_its_date_and_the_po_reaches_the_invoice(neighbours):
set_policy(7311, "2026-11-01", "2026-10-30", po_required=True)
with on_day("2026-10-30T09:00:00+00:00"):
assert confirm(hand(lines=FEES(1)))["lifecycle"] == "confirmed" # the policy did not yet apply
with on_day("2026-11-02T09:00:00+00:00"):
o = hand(lines=FEES(1))
assert "po_ref: this customer requires a PO number" in refused(api("POST", f"/sales-orders/{o['sales_order_id']}/confirm", REP, {}))
must(api("POST", f"/sales-orders/{o['sales_order_id']}/customer-po", REP, {"customer_po_ref": "PO-4471"}))
c = confirm(o)
assert c["lifecycle"] == "confirmed" and c["customer_po_ref"] == "PO-4471"
must(invoice(c))
assert neighbours.invoicing.drafts[f"so:{c['sales_order_id']}:1"]["customer_po_ref"] == "PO-4471"
def test_SO_04_a_policy_cannot_start_today_or_earlier_and_a_correction_is_a_new_row():
assert "starts after today, in Europe/London" in refused(api("POST", "/customer-order-policies", ADMIN, {"bp_id": 7311, "valid_from": TODAY, "po_required": True}), 422)
must(api("POST", "/customer-order-policies", ADMIN, {"bp_id": 7311, "valid_from": "2026-11-25", "po_required": True}))
assert "new row from a later date" in refused(api("POST", "/customer-order-policies", ADMIN, {"bp_id": 7311, "valid_from": "2026-11-25", "po_required": False}))
must(api("POST", "/customer-order-policies", ADMIN, {"bp_id": 7311, "valid_from": "2026-11-26", "po_required": False}))
assert len(must(api("GET", "/customer-order-policies", REP, {"bp_id": 7311}))["customer_order_policies"]) == 2
def test_SO_05_an_amendment_is_a_revision_only_the_changed_line_is_repriced(neighbours):
o = confirm(hand(lines=[{"product_id": "COLA-075", "quantity": "6", "unit_code": "C62"}, {"product_id": "COLA-075", "quantity": "2", "unit_code": "PK24"}]))
sid = o["sales_order_id"]
a = must(api("POST", f"/sales-orders/{sid}/amend", REP, {"reason": "they want more", "changes": [{"line_no": 1, "quantity": "12"}]}))
assert a["revision"] == 1 and a["revisions"][0]["status"] == "pending" and D(line(a, 1)["quantity"]) == 6 # waits for approval
done = must(api("POST", f"/sales-orders/{sid}/revisions/2/approve", ADMIN, {}))
assert done["revision"] == 2 and done["revisions"][0]["status"] == "applied" and done["revisions"][0]["approved_by"] == "order_admin@shop.example"
assert D(line(done, 1)["quantity"]) == 12 and D(line(done, 1)["net_amount"]) == D("14.40")
assert line(done, 2)["priced_from"] == o["lines"][1]["priced_from"] and D(line(done, 2)["net_amount"]) == D("48.00") # untouched
def test_SO_05_the_amender_never_approves_their_own_amendment():
o = confirmed(who=ALICE, lines=WIDGETS(10))
sid = o["sales_order_id"]
must(api("POST", f"/sales-orders/{sid}/amend", ALICE, {"reason": "more", "changes": [{"line_no": 1, "quantity": "12"}]}))
said = refused(api("POST", f"/sales-orders/{sid}/revisions/2/approve", ALICE, {}))
assert "approver_not_amender" in said and "never approved by the person who made it" in said
assert get(sid)["revision"] == 1
assert must(api("POST", f"/sales-orders/{sid}/revisions/2/approve", ADMIN, {}))["revision"] == 2
def test_SO_05_reducing_below_what_is_delivered_is_refused():
o = confirmed(lines=WIDGETS(10))
sid = o["sales_order_id"]
must(deliver(o, 1, 4, "DN-1"))
must(api("POST", f"/sales-orders/{sid}/amend", REP, {"reason": "more", "changes": [{"line_no": 1, "quantity": "12"}]}))
must(api("POST", f"/sales-orders/{sid}/revisions/2/approve", ADMIN, {}))
said = refused(api("POST", f"/sales-orders/{sid}/amend", REP, {"reason": "less", "changes": [{"line_no": 1, "quantity": "3"}]}))
assert "not_below_done" in said and "below the 4 already delivered or invoiced" in said
assert get(sid)["revision"] == 2
def test_SO_05_without_the_approval_control_an_amendment_applies_at_once():
with on_day("2026-10-22T09:00:00+00:00"):
must(api("POST", "/controls/amendment_needs_approval", OWNER, {"value": False, "valid_from": "2026-11-01"}))
o = confirmed(lines=WIDGETS(10))
a = must(api("POST", f"/sales-orders/{o['sales_order_id']}/amend", REP, {"reason": "more", "changes": [{"line_no": 1, "quantity": "12"}]}))
assert a["revision"] == 2 and D(line(a, 1)["quantity"]) == 12 and a["revisions"][0]["status"] == "applied"
def test_SO_06_cancelling_a_confirmed_order_asks_invoicing_to_cancel_its_drafts(neighbours):
o = confirmed(lines=FEES(1))
sid = o["sales_order_id"]
must(invoice(o))
assert "reason" in str(api("POST", f"/sales-orders/{sid}/cancel", ADMIN, {})[1]).lower()
c = must(api("POST", f"/sales-orders/{sid}/cancel", ADMIN, {"reason": "customer withdrew"}))
assert c["lifecycle"] == "cancelled" and c["order_no"] == o["order_no"] and c["cancel_reason"] == "customer withdrew"
assert neighbours.invoicing.cancelled == [f"so:{sid}:1"] and not neighbours.invoicing.drafts
assert must(api("POST", f"/sales-orders/{sid}/cancel", ADMIN, {"reason": "again"}))["version"] == c["version"]
def test_SO_06_an_order_with_a_delivery_or_an_issued_invoice_cannot_be_cancelled(neighbours):
o = confirmed(lines=WIDGETS(12))
must(deliver(o, 1, 1, "DN-1"))
assert "nothing_delivered" in refused(api("POST", f"/sales-orders/{o['sales_order_id']}/cancel", ADMIN, {"reason": "x"}))
p = confirmed(lines=FEES(1))
must(invoice(p))
neighbours.invoicing.issue(f"so:{p['sales_order_id']}:1")
said = refused(api("POST", f"/sales-orders/{p['sales_order_id']}/cancel", ADMIN, {"reason": "x"}))
assert "nothing_invoiced" in said and "credit the invoice" in said
def test_SO_07_deliveries_are_recorded_line_by_line_once_and_corrected_by_new_rows():
o = confirmed(lines=WIDGETS(12))
sid = o["sales_order_id"]
d = must(deliver(o, 1, 5, "DN-118", delivered_on=TODAY))
assert D(line(d, 1)["delivered"]) == 5 and d["fulfilment_status"] == "partly delivered" and D(line(d, 1)["to_invoice"]) == 5
again = must(deliver(o, 1, 5, "DN-118"))
assert len(again["deliveries"]) == 1 and again["version"] == d["version"] # DN-118 twice records it once
assert "would make 13 of 12" in refused(deliver(o, 1, 8, "DN-119"))
assert "never dated in the future" in refused(deliver(o, 1, 1, "DN-120", delivered_on="2026-11-21"))
assert "before the confirmation" in refused(deliver(o, 1, 1, "DN-121", delivered_on="2026-11-19"))
c = must(deliver(o, 1, -2, "DN-118-C", corrects_ref="DN-118"))
assert D(line(c, 1)["delivered"]) == 3 and [x["delivery_ref"] for x in c["deliveries"]] == ["DN-118", "DN-118-C"]
assert "cannot be corrected by -4" in refused(deliver(o, 1, -4, "DN-118-D", corrects_ref="DN-118"))
assert "names the delivery it corrects" in refused(deliver(o, 1, -1, "DN-X", corrects_ref="NOPE"))
must(invoice(c)) # 3 invoiceable
assert "below what is already invoiced (3" in refused(deliver(o, 1, -1, "DN-118-E", corrects_ref="DN-118"))
assert get(sid)["deliveries"][0]["quantity"] in ("5", "5.0000") # DN-118 itself is never edited
def test_SO_07_a_fulfiller_never_sees_a_price():
o = confirmed(lines=WIDGETS(12))
seen = must(api("GET", f"/sales-orders/{o['sales_order_id']}", FULFILLER))
assert "total" not in seen and "unit_price" not in line(seen) and "net_amount" not in line(seen) and "invoice_requests" not in seen
assert D(line(seen, 1)["quantity"]) == 12
rows = must(api("GET", "/sales-orders", FULFILLER))["orders"]
assert [r["sales_order_id"] for r in rows] == [o["sales_order_id"]] and "total" not in rows[0]
draft = hand()
assert api("GET", f"/sales-orders/{draft['sales_order_id']}", FULFILLER)[0] == 404 # a draft is not theirs to see
def test_SO_08_the_invoice_request_asks_for_exactly_what_is_due_once(neighbours):
o = confirmed(lines=FEES(1) + WIDGETS(12))
sid = o["sales_order_id"]
must(deliver(o, 2, 5, "DN-1"))
r = must(invoice(o))
draft = neighbours.invoicing.drafts[f"so:{sid}:1"]
assert [(x["line_no"], x["quantity"], x["sales_order_id"]) for x in draft["lines"]] == [(1, "1", sid), (2, "5", sid)]
assert r["invoice_status"] == "partly invoiced" and r["invoice_requests"][0]["kind"] == "regular"
assert "something_due" in refused(invoice(o))
assert list(neighbours.invoicing.drafts) == [f"so:{sid}:1"] # asking again created nothing
must(deliver(o, 2, 7, "DN-2"))
final = must(invoice(o))
assert final["invoice_requests"][1]["kind"] == "final" and [x["quantity"] for x in neighbours.invoicing.drafts[f"so:{sid}:2"]["lines"]] == ["7"]
def test_SO_09_a_deposit_is_a_real_request_and_later_invoices_deduct_it(neighbours):
set_policy(7311, TODAY, "2026-11-19", deposit_pct="50")
o = confirmed(lines=FEES(10))
sid = o["sales_order_id"]
assert D(o["deposit_pct"]) == 50 and D(o["subtotal"]) == D("10000.00")
d = must(api("POST", f"/sales-orders/{sid}/raise-deposit", BILLER, {}))
assert d["invoice_requests"][0]["kind"] == "deposit" and D(d["invoice_requests"][0]["amount"]) == D("5000.00")
assert D(neighbours.invoicing.drafts[f"so:{sid}:1"]["lines"][0]["net_amount"]) == D("5000.00")
assert "within_total" in refused(api("POST", f"/sales-orders/{sid}/raise-deposit", BILLER, {"amount": "6000.00"}))
neighbours.invoicing.issue(f"so:{sid}:1") # a deposit is deducted once invoicing has issued it
f = must(invoice(o))
req = f["invoice_requests"][1]
assert req["kind"] == "final" and D(req["deducted"]) == D("5000.00") and D(req["amount"]) == D("5000.00")
d = neighbours.invoicing.drafts[f"so:{sid}:2"]
assert [D(x["net_amount"]) for x in d["lines"]] == [D("10000.00")]
assert d["deductions"] == [{"deposit_invoice_id": "AR-0001", "net_amount": "5000.00", "tax_category": "standard"}]
def test_SO_09_no_deposit_set_is_refused():
o = confirmed(lines=FEES(1))
assert "deposit_set" in refused(api("POST", f"/sales-orders/{o['sales_order_id']}/raise-deposit", BILLER, {}))
def test_SO_10_one_list_with_derived_statuses_and_filters():
a, b = confirmed(lines=WIDGETS(12)), confirmed(who=REP2, bp_id=7313, lines=FEES(1))
hand()
must(deliver(a, 1, 12, "DN-1"))
cancelled = hand()
must(api("POST", f"/sales-orders/{cancelled['sales_order_id']}/cancel-draft", REP, {}))
rows = {r["sales_order_id"]: r for r in must(api("GET", "/sales-orders", ADMIN))["orders"]}
assert len(rows) == 4 and rows[a["sales_order_id"]]["fulfilment_status"] == "delivered" and rows[a["sales_order_id"]]["invoice_status"] == "to invoice"
assert rows[b["sales_order_id"]]["invoice_status"] == "to invoice" and D(rows[a["sales_order_id"]]["total"]) == D("144.00")
only = lambda **q: {r["sales_order_id"] for r in must(api("GET", "/sales-orders", ADMIN, q))["orders"]} # noqa: E731
assert only(state="confirmed") == {a["sales_order_id"], b["sales_order_id"]} and only(bp_id=7313) == {b["sales_order_id"]}
assert only(ordered_from="2026-11-21") == set() and len(only(ordered_from=TODAY, ordered_to=TODAY)) == 4
page = must(api("GET", "/sales-orders", ADMIN, {"limit": 2, "offset": 2}))
assert len(page["orders"]) == 2 and page["total"] == 4
assert b["sales_order_id"] not in {r["sales_order_id"] for r in must(api("GET", "/sales-orders", REP))["orders"]} # another rep's order
def test_SO_11_the_confirmation_is_rendered_from_the_frozen_order_and_resending_records_again():
o = confirmed(lines=FEES(2))
sid = o["sales_order_id"]
s1 = must(api("POST", f"/sales-orders/{sid}/send-confirmation", REP, {"address": "ap@customer.example"}))
assert s1["document"]["order_no"] == o["order_no"] and D(s1["document"]["total"]) == D("2400.00") and len(s1["sends"]) == 1
s2 = must(api("POST", f"/sales-orders/{sid}/send-confirmation", REP, {"address": "ap@customer.example"}))
assert len(s2["sends"]) == 2 and s2["sends"][0]["revision"] == 1
assert "confirmed order" in refused(api("POST", f"/sales-orders/{hand()['sales_order_id']}/send-confirmation", REP, {"address": "x@y.z"}))
def test_SO_15_the_owner_sets_dated_controls_and_the_rep_cannot_loosen_them():
with on_day("2026-10-22T09:00:00+00:00"):
c = must(api("POST", "/controls/amendment_needs_approval", OWNER, {"value": False, "valid_from": "2026-11-01"}))
assert c["valid_from"] == "2026-11-01"
assert "starts after today, in Europe/London" in refused(api("POST", "/controls/amendment_needs_approval", OWNER, {"value": False, "valid_from": "2026-10-22"}), 422)
on = must(api("POST", "/controls/ai.ai_order_from_po.mode", OWNER, {"value": "suggest", "valid_from": "2026-10-23"}))
assert on["consent"] == {"step": "ai_order_from_po", "mode": "suggest", "by": "owner@shop.example", "from": "2026-10-23", "recorded_at": on["consent"]["recorded_at"]}
assert "new row from a later date" in refused(api("POST", "/controls/amendment_needs_approval", OWNER, {"value": True, "valid_from": "2026-11-01"}))
# so:settings:write alone is refused on a control; the controls permission is not an ordinary setting's
assert "needs so:settings:controls" in refused(api("POST", "/settings/amendment_needs_approval", ADMIN, {"value": False, "valid_from": "2026-11-01"}), 403)
assert api("POST", "/controls/amendment_needs_approval", ADMIN, {"value": False, "valid_from": "2026-11-02"})[0] == 403
assert api("POST", "/settings/order_number_format", OWNER, {"value": "X-{seq:3}", "valid_from": "2026-11-01"})[0] == 403
ok = must(api("POST", "/settings/order_number_format", ADMIN, {"value": "SALE/{yyyy}/{seq:3}", "valid_from": "2026-11-01"}))
assert ok["value"] == "SALE/{yyyy}/{seq:3}"
shown = {s["setting_key"] for s in must(api("GET", "/settings", ADMIN))["settings"]}
assert "amendment_needs_approval" not in shown and "order_number_format" in shown # the controls are the owner's to see
assert "amendment_needs_approval" in {s["setting_key"] for s in must(api("GET", "/settings", OWNER))["settings"]}
with on_day("2026-11-02T09:00:00+00:00"):
assert confirm(hand(lines=FEES(1)))["order_no"] == "SALE/2026/001" # the new format, from its date
def test_SO_15_a_timezone_is_validated_and_decides_today():
with on_day("2026-11-20T09:00:00+00:00"):
assert "not an IANA time zone" in refused(api("POST", "/controls/business_timezone", OWNER, {"value": "Mars/Base", "valid_from": "2026-11-21"}), 422)
must(api("POST", "/controls/business_timezone", OWNER, {"value": "Pacific/Auckland", "valid_from": "2026-11-21"}))
with on_day("2026-11-21T20:00:00+00:00"): # 09:00 on the 22nd in Auckland
assert hand()["ordered_on"] == "2026-11-22"
# ── system and integration stories ────────────────────────────────────────────────────────────
QUOTE = {"quote_id": "Q-2026-0042", "quote_version": 2, "bp_id": 7311, "currency": "GBP", "owner": "sales_rep@shop.example",
"lines": [{"line_no": 1, "product_id": "COLA-075", "description": "Cola 75cl", "quantity": "10", "list_price": "24.00", "unit_price": "20.00",
"discount_pct": "16.6667", "unit_code": "PK24", "priced_from": {"price_list_id": "BOOK-1", "rule_seq": 2}, "priced_on": "2026-11-10", "line_total": "200.00", "schedule": {}},
{"product_id": "SETUP", "line_no": 2, "description": "Set-up", "quantity": "3", "list_price": "90.00", "unit_price": "90.00", "priced_from": {"price_list_id": "BOOK-1", "rule_seq": 2},
"priced_on": "2026-11-10"},
{"product_id": "FEE", "line_no": 3, "quantity": "1", "list_price": "1500.00", "unit_price": "1000.00", "priced_from": {"price_list_id": "LIST-GBP", "rule_seq": 1}}]}
def test_SO_01_an_accepted_quote_becomes_one_draft_order_never_repriced(neighbours):
o = must(api("POST", "/sales-orders/from-quote", SYSTEM, QUOTE))
assert o["sales_order_id"] == "quote:Q-2026-0042@v2" and o["lifecycle"] == "draft" and o["owner"] == "sales_rep@shop.example"
one, two, three = line(o, 1), line(o, 2), line(o, 3)
assert (D(one["unit_price"]), D(one["list_price"]), one["unit_code"], one["unit_numerator"]) == (D("20.00"), D("24.00"), "PK24", 24) # the quote's 20.00, not the list's 24.00
assert one["priced_from"] == "quote:Q-2026-0042@v2#1 (BOOK-1 rule 2 on 2026-11-10)" and one["priced_on"] == "2026-11-10" and one["quote_ref"] == "Q-2026-0042|2|1"
assert two["unit_code"] == "HUR" and three["unit_code"] == "C62" # a quote line before units: the product's sales unit
assert one["description"] == "Cola 75cl" and two["tax_code"] == "S" and D(one["tax_amount"]) == D("40.00")
again = must(api("POST", "/sales-orders/from-quote", SYSTEM, QUOTE))
assert again["version"] == o["version"] and len(must(api("GET", "/sales-orders", ADMIN))["orders"]) == 1 # twice returns the same order
assert api("GET", f"/sales-orders/{o['sales_order_id']}", REP2)[0] == 404 # it belongs to the quote's owner
assert must(api("GET", f"/sales-orders/{o['sales_order_id']}", REP))["lifecycle"] == "draft"
def test_SO_01_confirm_on_acceptance_confirms_through_the_same_gates(neighbours):
with on_day("2026-11-19T09:00:00+00:00"):
must(api("POST", "/controls/confirm_on_acceptance", OWNER, {"value": True, "valid_from": TODAY}))
o = must(api("POST", "/sales-orders/from-quote", SYSTEM, QUOTE))
assert o["lifecycle"] == "confirmed" and o["order_no"] == "SO-2026-0001"
neighbours.payments.status[7311] = {"hold": True, "reason": "75 days overdue", "mode": "block"}
q2 = {**QUOTE, "quote_version": 3}
held = must(api("POST", "/sales-orders/from-quote", SYSTEM, q2))
assert held["lifecycle"] == "draft" # a credit hold keeps it a draft for a person
def test_SO_14_an_ai_step_is_off_until_the_owner_turns_it_on():
out = {"bp_id": 7311, "currency": "GBP", "customer_po_ref": "PO-4471", "owner": "sales_rep@shop.example", "model": "m1", "inputs_ref": "inbox/po-4471.pdf",
"suggestion": {"lines": [{"product_id": "COLA-075", "quantity": "10", "unit_code": "C62"}], "po_number": "PO-4471"}}
assert "ai_order_from_po is off" in refused(api("POST", "/sales-orders/ai-order-from-po", SYSTEM, out))
def _po_suggestion(neighbours):
with on_day("2026-11-19T09:00:00+00:00"):
must(api("POST", "/controls/ai.ai_order_from_po.mode", OWNER, {"value": "suggest", "valid_from": TODAY}))
body = {"bp_id": 7311, "currency": "GBP", "customer_po_ref": "PO-4471", "owner": "sales_rep@shop.example", "model": "m1", "inputs_ref": "inbox/po-4471.pdf",
"suggestion": {"lines": [{"product_id": "COLA-075", "quantity": "10", "unit_code": "C62", "confidence": "93"},
{"product_id": "SETUP", "quantity": "3", "unit_code": "HUR", "confidence": "88"},
{"product_id": "COLA-075", "quantity": "2", "unit_code": "C62", "confidence": "71"}],
"po_number": "PO-4471",
"differences": [{"kind": "quantity", "detail": "PO asks 12, quote says 10", "po_text": "12 x cola"}]}}
return must(api("POST", "/sales-orders/ai-order-from-po", SYSTEM, body)), body
def test_SO_14_and_16_the_draft_is_empty_until_the_rep_acts(neighbours):
o, body = _po_suggestion(neighbours)
sid = o["sales_order_id"]
assert sid == "po:7311:PO-4471" and o["lines"] == [] and o["customer_po_ref"] == "" and o["suggestions"][0]["decision"] == "open"
again = must(api("POST", "/sales-orders/ai-order-from-po", SYSTEM, body))
assert len(again["suggestions"]) == 1 # re-running replaces the open one
assert "no_open_suggestion" in refused(api("POST", f"/sales-orders/{sid}/confirm", REP, {}))
decisions = [{"item": "line:0", "action": "accept"}, {"item": "line:1", "action": "accept"},
{"item": "line:2", "action": "change", "unit_code": "PK24"}, {"item": "po_number", "action": "accept"}, {"item": "diff:0", "action": "reject"}]
done = must(api("POST", f"/sales-orders/{sid}/suggestions/ai_order_from_po/1/decide", REP, {"decisions": decisions}))
assert [(x["product_id"], x["unit_code"]) for x in done["lines"]] == [("COLA-075", "C62"), ("SETUP", "HUR"), ("COLA-075", "PK24")]
assert D(line(done, 3)["unit_price"]) == D("24.00") and done["customer_po_ref"] == "PO-4471" # priced by find_price, PO number accepted
s = done["suggestions"][0]
assert (s["decision"], s["decided_by"], s["model"], s["inputs_ref"]) == ("changed", "sales_rep@shop.example", "m1", "inbox/po-4471.pdf")
assert s["decisions"]["diff:0"]["action"] == "rejected" and s["decisions"]["line:2"]["action"] == "changed" and s["output"]["po_number"] == "PO-4471"
assert confirm(done)["lifecycle"] == "confirmed"
assert "already" in refused(api("POST", f"/sales-orders/{sid}/suggestions/ai_order_from_po/1/decide", REP, {"decisions": decisions[:1]}))
def test_SO_16_a_partial_decision_leaves_the_suggestion_open(neighbours):
o, _ = _po_suggestion(neighbours)
sid = o["sales_order_id"]
half = must(api("POST", f"/sales-orders/{sid}/suggestions/ai_order_from_po/1/decide", REP, {"decisions": [{"item": "line:0", "action": "accept"}]}))
assert half["suggestions"][0]["decision"] == "open" and len(half["lines"]) == 1
assert "no_open_suggestion" in refused(api("POST", f"/sales-orders/{sid}/confirm", REP, {}))
def test_SC_SO_07_a_po_difference_blocks_confirmation_until_marked(neighbours):
with on_day("2026-11-19T09:00:00+00:00"):
must(api("POST", "/controls/ai.ai_check_po.mode", OWNER, {"value": "suggest", "valid_from": TODAY}))
d = hand(lines=FEES(10))
sid = d["sales_order_id"]
must(api("POST", f"/sales-orders/{sid}/ai-check-po", SYSTEM, {"model": "m1", "inputs_ref": "inbox/po.pdf",
"differences": [{"kind": "price", "detail": "PO says 950", "po_text": "950.00 each"}]}))
assert "no_open_suggestion" in refused(api("POST", f"/sales-orders/{sid}/confirm", REP, {}))
must(api("POST", f"/sales-orders/{sid}/suggestions/ai_check_po/1/decide", REP, {"decisions": [{"item": "diff:0", "action": "accept"}]}))
assert confirm(get(sid, REP))["lifecycle"] == "confirmed"
def test_SO_20_a_repeating_line_becomes_a_billing_schedule_once(neighbours):
o = confirmed(lines=[{"product_id": "HOST", "quantity": "12", "unit_code": "MON"}] + FEES(1))
sid = o["sales_order_id"]
sch = neighbours.invoicing.schedules[f"{sid}|1"]
assert (sch["line_no"], sch["billing_kind"], sch["unit_price"], sch["quantity"]) == (1, "recurring", "120.00", "12")
assert list(neighbours.invoicing.schedules) == [f"{sid}|1"] # the one-time fee is not a schedule
must(api("POST", f"/sales-orders/{sid}/confirm", REP, {}))
assert len(neighbours.invoicing.schedules) == 1
must(invoice(o))
assert [x["line_no"] for x in neighbours.invoicing.drafts[f"so:{sid}:1"]["lines"]] == [2] # the repeating line bills from its schedule
def test_SO_21_the_order_asks_and_never_writes_an_invoice(neighbours):
o = confirmed(lines=FEES(1))
must(invoice(o))
d = neighbours.invoicing.drafts[f"so:{o['sales_order_id']}:1"]
assert d["kind"] == "final" and d["lines"][0]["sales_order_id"] == o["sales_order_id"] and d["lines"][0]["line_no"] == 1
neighbours.invoicing.down = True
assert "invoicing did not answer" in refused(invoice(o), 502)
def test_SO_22_a_warning_hold_confirms_and_a_block_needs_an_admin_override(neighbours):
neighbours.payments.status[7311] = {"hold": True, "reason": "2 invoices over 60 days", "mode": "warn"}
w = hand()
g = next(x for x in must(api("GET", f"/sales-orders/{w['sales_order_id']}/readiness", REP))["readiness"]["confirm"] if x["gate"] == "credit")
assert g["met"] and "warning: 2 invoices over 60 days" in g["reason"]
assert confirm(w)["lifecycle"] == "confirmed"
neighbours.payments.status[7311]["mode"] = "block"
b = hand()
sid = b["sales_order_id"]
assert "credit" in refused(api("POST", f"/sales-orders/{sid}/confirm", REP, {}))
assert api("POST", f"/sales-orders/{sid}/override-credit-hold", REP, {"reason": "paid by phone"})[0] == 403 # only an order admin
o = must(api("POST", f"/sales-orders/{sid}/override-credit-hold", ADMIN, {"reason": "paid by phone"}))
assert o["credit_override_by"] == "order_admin@shop.example"
assert confirm(o)["lifecycle"] == "confirmed"
def test_SO_23_a_delivery_brick_calls_the_same_route_and_the_same_rules_apply():
o = confirmed(lines=WIDGETS(12))
for _ in range(2):
d = must(api("POST", f"/sales-orders/{o['sales_order_id']}/deliveries", FULFILLER, {"line_no": 1, "quantity": "4", "delivery_ref": "WH-OUT-77"}))
assert D(line(d, 1)["delivered"]) == 4 and len(d["deliveries"]) == 1
assert "would make 16 of 12" in refused(api("POST", f"/sales-orders/{o['sales_order_id']}/deliveries", FULFILLER, {"line_no": 1, "quantity": "12", "delivery_ref": "WH-OUT-78"}))
def test_SO_13_a_finished_order_locks_once(neighbours):
o = confirmed(lines=WIDGETS(12))
sid = o["sales_order_id"]
must(deliver(o, 1, 12, "DN-1"))
assert get(sid)["lifecycle"] == "confirmed" # delivered, not yet invoiced
must(invoice(o))
neighbours.invoicing.issue(f"so:{sid}:1")
assert "not every line" not in str(must(api("POST", f"/sales-orders/{sid}/finish", SYSTEM, {}))["lifecycle"])
done = get(sid)
assert done["lifecycle"] == "done"
assert must(api("POST", f"/sales-orders/{sid}/finish", SYSTEM, {}))["version"] == done["version"] # once however often
assert "done: raise a new order" in refused(api("POST", f"/sales-orders/{sid}/lines", ALICE, {"line": {"product_id": "FEE", "quantity": "1"}}))
assert "done" in refused(api("POST", f"/sales-orders/{sid}/amend", REP, {"reason": "x", "changes": [{"line_no": 1, "quantity": "13"}]}))
def test_SO_13_the_last_delivery_locks_an_order_already_invoiced(neighbours):
o = confirmed(lines=FEES(2))
sid = o["sales_order_id"]
must(invoice(o))
neighbours.invoicing.issue(f"so:{sid}:1")
must(deliver(o, 1, 2, "DN-1"))
assert get(sid)["lifecycle"] == "done"
def test_SO_13_a_switched_off_lock_leaves_it_confirmed(neighbours):
with on_day("2026-11-19T09:00:00+00:00"):
must(api("POST", "/controls/lock_done_orders", OWNER, {"value": False, "valid_from": TODAY}))
o = confirmed(lines=FEES(2))
must(invoice(o))
neighbours.invoicing.issue(f"so:{o['sales_order_id']}:1")
must(deliver(o, 1, 2, "DN-1"))
assert get(o["sales_order_id"])["lifecycle"] == "confirmed"
assert "switched off" in refused(api("POST", f"/sales-orders/{o['sales_order_id']}/finish", SYSTEM, {}))