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, {}))