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")