dodil/erp-brick-sales-order
DODIL ERP brick: sales order
tests/test_scenarios_so.py
"""The spec's scenarios (module.yaml tests.scenarios), end to end through the API door with the neighbours as fakes."""
from decimal import Decimal
from conftest import (
ADMIN,
BILLER,
FULFILLER,
OWNER,
REP,
SYSTEM,
api,
confirm,
confirmed,
deliver,
get,
hand,
must,
on_day,
refused,
)
D = Decimal
def ln(o, n):
return next(x for x in o["lines"] if x["line_no"] == n)
def test_SC_SO_01_quote_to_order_to_invoice_ordered_policy(neighbours):
quote = {"quote_id": "Q-2026-0042", "quote_version": 2, "bp_id": 7311, "currency": "GBP", "owner": "sales_rep@shop.example",
"lines": [{"line_no": i, "product_id": "FEE", "quantity": str(i), "list_price": "1000.00", "unit_price": "900.00", "priced_from": {"price_list_id": "LIST-GBP", "rule_seq": 1}} for i in (1, 2, 3)]}
a = must(api("POST", "/sales-orders/from-quote", SYSTEM, quote))
b = must(api("POST", "/sales-orders/from-quote", SYSTEM, quote))
assert a["sales_order_id"] == b["sales_order_id"] and a["order_no"] == "" # handed over twice: one order, not numbered yet
c = confirm(a)
assert c["order_no"] == "SO-2026-0001"
must(api("POST", f"/sales-orders/{c['sales_order_id']}/request-invoice", BILLER, {}))
assert refused(api("POST", f"/sales-orders/{c['sales_order_id']}/request-invoice", BILLER, {}))
drafts = neighbours.invoicing.drafts
assert len(drafts) == 1 and len(drafts[f"so:{c['sales_order_id']}:1"]["lines"]) == 3
assert sum(D(x["net_amount"]) for x in drafts[f"so:{c['sales_order_id']}:1"]["lines"]) == D("5400.00") # the quote's 900.00, not the list's 1000.00
def test_SC_SO_02_part_delivery_delivered_policy(neighbours):
o = confirmed(lines=[{"product_id": "WIDGET", "quantity": "12", "unit_code": "C62"}])
sid = o["sales_order_id"]
must(deliver(o, 1, 5, "DN-118"))
must(deliver(o, 1, 5, "DN-118")) # recording DN-118 twice counts once
must(api("POST", f"/sales-orders/{sid}/request-invoice", BILLER, {}))
neighbours.invoicing.issue(f"so:{sid}:1")
must(deliver(o, 1, 7, "DN-119"))
must(api("POST", f"/sales-orders/{sid}/request-invoice", BILLER, {}))
neighbours.invoicing.issue(f"so:{sid}:2")
assert [x["quantity"] for r in (1, 2) for x in neighbours.invoicing.drafts[f"so:{sid}:{r}"]["lines"]] == ["5", "7"]
assert get(sid)["lifecycle"] == "confirmed"
must(api("POST", f"/sales-orders/{sid}/finish", SYSTEM, {}))
assert get(sid)["lifecycle"] == "done" and get(sid)["fulfilment_status"] == "delivered" and get(sid)["invoice_status"] == "invoiced"
def test_SC_SO_03_deposit_then_final(neighbours):
with on_day("2026-11-19T09:00:00+00:00"):
must(api("POST", "/customer-order-policies", ADMIN, {"bp_id": 7311, "valid_from": "2026-11-20", "deposit_pct": "50"}))
o = confirmed(lines=[{"product_id": "FEE", "quantity": "10", "unit_code": "C62"}])
sid = o["sales_order_id"]
must(api("POST", f"/sales-orders/{sid}/raise-deposit", BILLER, {}))
neighbours.invoicing.issue(f"so:{sid}:1")
must(api("POST", f"/sales-orders/{sid}/request-invoice", BILLER, {}))
dep, final = neighbours.invoicing.drafts[f"so:{sid}:1"], neighbours.invoicing.drafts[f"so:{sid}:2"]
assert (dep["kind"], D(dep["lines"][0]["net_amount"])) == ("deposit", D("5000.00"))
assert (final["kind"], [D(x["net_amount"]) for x in final["lines"]], [D(x["net_amount"]) for x in final["deductions"]]) == ("final", [D("10000.00")], [D("5000.00")])
def test_SC_SO_04_credit_hold(neighbours):
neighbours.payments.status[7311] = {"hold": True, "reason": "an invoice 75 days overdue", "mode": "block"}
o = hand()
assert "75 days overdue" in refused(api("POST", f"/sales-orders/{o['sales_order_id']}/confirm", REP, {}))
must(api("POST", f"/sales-orders/{o['sales_order_id']}/override-credit-hold", ADMIN, {"reason": "customer paid today, remittance seen"}))
c = confirm(get(o["sales_order_id"], REP), REP)
assert c["lifecycle"] == "confirmed" and c["credit_override_reason"] == "customer paid today, remittance seen"
def test_SC_SO_05_amend_before_delivery():
o = confirmed(lines=[{"product_id": "WIDGET", "quantity": "10", "unit_code": "C62"}])
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"}]}))
r2 = must(api("POST", f"/sales-orders/{sid}/revisions/2/approve", ADMIN, {}))
assert r2["revision"] == 2 and D(ln(r2, 1)["quantity"]) == 12
assert "not_below_done" in refused(api("POST", f"/sales-orders/{sid}/amend", REP, {"reason": "less", "changes": [{"line_no": 1, "quantity": "3"}]}))
assert get(sid)["revision"] == 2
def test_SC_SO_06_units_and_prices_on_a_hand_order(neighbours):
with on_day("2026-11-02T09:00:00+00:00"):
assert "dated today (2026-11-02)" in refused(api("POST", "/sales-orders", REP, {"bp_id": 7311, "currency": "GBP", "ordered_on": "2026-11-01",
"lines": [{"product_id": "COLA-075", "quantity": "1", "unit_code": "C62"}]}))
o = confirm(hand(lines=[{"product_id": "COLA-075", "quantity": "2", "unit_code": "PK24"}, {"product_id": "COLA-075", "quantity": "6", "unit_code": "C62"}]))
assert D(ln(o, 1)["net_amount"]) == D("48.00") and "PK24@2026-10-01" in ln(o, 1)["priced_from"]
assert D(ln(o, 2)["net_amount"]) == D("7.20") and "C62@2026-10-01" in ln(o, 2)["priced_from"]
sid = o["sales_order_id"]
with on_day("2026-12-03T09:00:00+00:00"):
must(api("POST", f"/sales-orders/{sid}/amend", REP, {"reason": "they want 12 bottles", "changes": [{"line_no": 2, "quantity": "12"}]}))
a = must(api("POST", f"/sales-orders/{sid}/revisions/2/approve", ADMIN, {}))
assert D(ln(a, 2)["net_amount"]) == D("15.60") and "C62@2026-12-01" in ln(a, 2)["priced_from"] and ln(a, 2)["priced_on"] == "2026-12-03"
assert ln(a, 1)["priced_from"] == ln(o, 1)["priced_from"] and D(ln(a, 1)["net_amount"]) == D("48.00") # the pack line is untouched
assert D(a["subtotal"]) == D("63.60") and a["revision"] == 2
def test_SC_SO_07_covered_by_the_story_tests():
"""SC-SO-07 (AI suggestion is only a suggestion) is test_SO_14_and_16_* and test_SC_SO_07_* in test_stories.py."""
import test_stories
assert test_stories.test_SC_SO_07_a_po_difference_blocks_confirmation_until_marked and test_stories.test_SO_14_and_16_the_draft_is_empty_until_the_rep_acts
def test_the_fulfiller_story_end_to_end(neighbours):
o = confirmed(lines=[{"product_id": "WIDGET", "quantity": "3", "unit_code": "C62"}])
d = must(deliver(o, 1, 3, "DN-9", who=FULFILLER))
assert d["fulfilment_status"] == "delivered" and "unit_price" not in ln(d, 1)
assert OWNER
def test_a_discounted_line_is_billed_at_its_list_price_with_the_discount_once(neighbours):
"""invoicing computes net = quantity x unit_price x (100 - discount_pct)/100: the order sends the LIST price and the
discount, so list 100.00, 10% off, quantity 10 is 900.00 on the order and 900.00 on the invoice (never 810.00)."""
quote = {"quote_id": "Q-2026-0090", "quote_version": 1, "bp_id": 7311, "currency": "GBP", "owner": "sales_rep@shop.example",
"lines": [{"line_no": 1, "product_id": "FEE", "quantity": "10", "list_price": "100.00", "unit_price": "90.00", "discount_pct": "10", "priced_from": {"price_list_id": "LIST-GBP", "rule_seq": 1}}]}
o = confirm(must(api("POST", "/sales-orders/from-quote", SYSTEM, quote)))
assert D(o["total"]) - D(o["tax_total"]) == D("900.00")
must(api("POST", f"/sales-orders/{o['sales_order_id']}/request-invoice", BILLER, {}))
sent = neighbours.invoicing.drafts[f"so:{o['sales_order_id']}:1"]["lines"][0]
assert (sent["unit_price"], sent["discount_pct"], sent["net_amount"]) == ("100.00", "10", "900.00")
assert D(sent["quantity"]) * D(sent["unit_price"]) * (100 - D(sent["discount_pct"])) / 100 == D("900.00")