dodil/erp-brick-product

tests/test_variants.py

"""Templates and variants (PRD-24), barcodes (PRD-25), and a rule that covers a template (PRD-26)."""
from decimal import Decimal as D

import pytest
from conftest import CATALOGUE, PRICING, SALES, SELLING, api, categories, mk_list, must, price, row

from brick_product.domain import variants
from brick_product.domain.errors import RuleBroken

VOLUMES = [{"name": "volume", "values": [{"value": "0.33 L", "code": "033"}, {"value": "0.75 L", "code": "075"},
                                         {"value": "1.5 L", "code": "150"}]}]


def make_cola(**kw):
    categories(("drinks", None))
    return must(api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", 
        "name": "Cola", "attributes": VOLUMES, "base_unit_code": "LTR", "category_code": "drinks", "tax_category": "standard",
        "billing_kind": "one_off", "invoicing_policy": "ordered", **kw}))


def test_prd_24_a_template_gives_one_draft_product_per_combination_and_again_adds_only_the_missing():
    """PRD-24 — Given template COLA with attribute volume (0.33 L, 0.75 L, 1.5 L), category drinks, tax category standard:
    generating its variants makes three draft products COLA-033, COLA-075, COLA-150, each its own SKU carrying the template's
    category, tax category, billing and units; generating again adds only missing combinations."""
    first = make_cola()
    assert first["created"] == ["COLA-033", "COLA-075", "COLA-150"] and first["existing"] == []
    one = must(api("GET", "/products/COLA-075", CATALOGUE))
    assert (one["status"], one["name"], one["category_code"], one["tax_category"], one["base_unit_code"], one["sales_unit_code"]) == \
        ("draft", "Cola 0.75 L", "drinks", "standard", "LTR", "LTR")
    assert (one["product_template_id"], one["attribute_values"], one["billing_kind"]) == ("COLA", {"volume": "0.75 L"}, "one_off")
    again = make_cola()
    assert again["created"] == [] and again["existing"] == ["COLA-033", "COLA-075", "COLA-150"]
    assert len(must(api("GET", "/products", CATALOGUE))["products"]) == 3                       # three products, not six
    grown = must(api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "Cola", "attributes": [
        {"name": "volume", "values": [{"value": "2 L", "code": "200"}, {"value": "0.33 L", "code": "033"}]}]}))
    assert grown["created"] == ["COLA-200"] and len(grown["existing"]) == 3                         # a new value: only the new one
    status, body = api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "Cola", "attributes": [
        {"name": "volume", "values": [{"value": "0.33 L", "code": "999"}]}]})
    assert status == 409 and "already has the code 033" in body["detail"]
    status, body = api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "Cola", "attributes": [
        {"name": "volume", "values": [{"value": "1 L"}]}, {"name": "colour", "values": [{"value": "red"}]}]})
    assert status == 409 and "a new attribute is a new template" in body["detail"]
    assert api("POST", "/templates/COLA/generate-variants", SALES, {"product_type_code": "LEGACY", "name": "x", "attributes": VOLUMES})[0] == 403
    assert api("POST", "/templates/X/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "x", "attributes": []})[0] in (409, 422)


def test_a_variant_without_a_given_code_gets_its_letters_and_digits_and_combinations_multiply():
    got = must(api("POST", "/templates/TEE/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "T-shirt", "attributes": [
        {"name": "size", "values": [{"value": "S"}, {"value": "M"}]}, {"name": "colour", "values": [{"value": "red"}, {"value": "dark blue"}]}]}))
    assert got["created"] == ["TEE-S-RED", "TEE-S-DARKBLUE", "TEE-M-RED", "TEE-M-DARKBLUE"]
    assert must(api("GET", "/products/TEE-M-DARKBLUE", CATALOGUE))["name"] == "T-shirt M dark blue"
    with pytest.raises(RuleBroken):
        variants.combinations("T", [{"name": "n", "values": [{"value": str(i), "code": str(i)} for i in range(600)]}])
    with pytest.raises(RuleBroken):
        variants.check_attributes([{"name": "a", "values": [{"value": "x"}, {"value": "x"}]}])


def test_a_variants_own_unit_row_overrides_the_templates_and_the_templates_apply_to_every_variant():
    make_cola(units_of=[{"unit_code": "XBX", "numerator": 12, "denominator": 1, "packaging_level": "case"}])
    for sku in ("COLA-033", "COLA-150"):
        assert must(api("GET", f"/products/{sku}/convert", CATALOGUE, {"quantity": "1", "from_unit": "XBX", "to_unit": "LTR"}))["converted"] == "12.000"
    must(api("POST", "/products/COLA-075/set-units", CATALOGUE, {"units_of": [{"unit_code": "XBX", "numerator": 18, "denominator": 1}]}))
    assert must(api("GET", "/products/COLA-075/convert", CATALOGUE, {"quantity": "1", "from_unit": "XBX", "to_unit": "LTR"}))["converted"] == "18.000"
    assert must(api("GET", "/products/COLA-033/convert", CATALOGUE, {"quantity": "1", "from_unit": "XBX", "to_unit": "LTR"}))["converted"] == "12.000"
    status, body = api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "Cola", "attributes": VOLUMES, "base_unit_code": "LTR",
                                                                            "units_of": [{"unit_code": "XBX", "numerator": 6, "denominator": 1, "gtin": "4006381333931"}]})
    assert status == 422 and "give it on the product" in body["detail"]


def test_invariant_a_price_a_document_line_and_a_stock_row_name_a_product_never_a_template():
    make_cola()
    for sku in ("COLA-033", "COLA-075", "COLA-150"):
        must(api("POST", f"/products/{sku}/activate-product", CATALOGUE, {}))
    mk_list("LIST-GBP", rows=[row("COLA-075", "2026-11-01", "1.20", unit_code="LTR")], default_from="2026-10-02", publish=False)
    status, body = api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("COLA", "2026-11-02", "1.00", unit_code="LTR")]})
    assert status == 422 and "there is no product COLA" in body["detail"]                             # a template has no price
    assert api("GET", "/products/COLA", SALES)[0] == 404 and api("GET", "/products/COLA/price", SALES, {"currency": "GBP"})[0] == 404
    assert api("POST", "/products/COLA/units/LTR/used", SELLING, {})[0] == 404 and api("GET", "/products/COLA/convert", SALES, {"quantity": "1", "from_unit": "LTR", "to_unit": "LTR"})[0] == 404
    status, body = api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "COLA", "name": "Cola"})
    assert status == 409 and "is a product template" in body["detail"]                               # never a product with a template's code
    must(api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "SOLO", "name": "Solo"}))
    assert api("POST", "/templates/SOLO/generate-variants", CATALOGUE, {"product_type_code": "LEGACY", "name": "x", "attributes": VOLUMES})[0] == 409
    assert must(api("GET", "/products/COLA-075", CATALOGUE))["product_template_id"] == "COLA"
    assert must(api("GET", "/products/SOLO", CATALOGUE))["product_template_id"] == "SOLO"          # no variants: its own template


def test_prd_26_a_rule_on_a_template_covers_all_its_variants_and_the_answer_names_it():
    """PRD-26 — a rule 'all variants of template COLA, 5% off' on PARTNER-GBP: 3 bottles of COLA-150 is its bottle price less 5%."""
    make_cola()
    for sku in ("COLA-075", "COLA-150"):
        must(api("POST", f"/products/{sku}/set-units", CATALOGUE, {"sales_unit_code": "C62", "units_of": [
            {"unit_code": "C62", "numerator": 3, "denominator": 4 if sku == "COLA-075" else 2}]}))
        must(api("POST", f"/products/{sku}/activate-product", CATALOGUE, {}))
    mk_list("LIST-GBP", rows=[row("COLA-075", "2026-11-01", "1.20", unit_code="C62"), row("COLA-150", "2026-11-01", "2.00", unit_code="C62")],
            default_from="2026-10-02")
    mk_list("PARTNER-GBP", rules=[{"seq": 1, "applies_to": "template", "target": "COLA", "discount_pct": "5", "base_list": "LIST-GBP"}])
    must(api("POST", "/price-lists/PARTNER-GBP/assign-customer", PRICING, {"bp_id": "7311"}))
    got = must(price("COLA-150", bp_id="7311", quantity="3", priced_on="2026-12-01"))
    assert (got["unit_price"], got["unit_code"], got["rule"]["applies_to"], got["rule"]["target"]) == ("1.90", "C62", "template", "COLA")
    assert must(price("COLA-075", bp_id="7311", quantity="3", priced_on="2026-12-01"))["unit_price"] == "1.14"
    assert must(price("COLA-150", quantity="3", priced_on="2026-12-01"))["unit_price"] == "2.00"        # no list of their own: no rule
    assert api("POST", "/price-lists/PARTNER-GBP/change-prices", PRICING, {"rules": [
        {"seq": 2, "applies_to": "template", "target": "NOPE", "discount_pct": "5", "valid_from": "2027-01-01"}]})[0] == 422


def gtin(n):
    return variants.with_check_digit(n)


def test_prd_25_each_packaging_level_has_its_own_barcode_unique_across_everything():
    """PRD-25 — COLA-075 with the bottle, PK24 and a pallet PALLET80 of 80 packs, each with its GTIN: scanning the pallet answers
    COLA-075, unit PALLET80, 1 920 bottles; a GTIN is unique across all products and units."""
    make_cola()
    bottle, pack, pallet = gtin("540000000001"), gtin("540000000002"), gtin("540000000003")
    got = must(api("POST", "/products/COLA-075/set-units", CATALOGUE, {"sales_unit_code": "C62", "purchase_unit_code": "PK24", "units_of": [
        {"unit_code": "C62", "numerator": 3, "denominator": 4, "packaging_level": "each", "gtin": bottle},
        {"unit_code": "PK24", "contains_unit": "C62", "contains_quantity": "24", "packaging_level": "case", "gtin": pack, "einvoice_unit_code": "XPK"},
        {"unit_code": "PALLET80", "contains_unit": "PK24", "contains_quantity": "80", "packaging_level": "pallet", "gtin": pallet, "einvoice_unit_code": "XPX"}]}))
    assert {u["unit_code"]: (u["numerator"], u["packaging_level"]) for u in got["units"] if u["unit_code"] != "LTR"} == \
        {"C62": (3, "each"), "PK24": (18, "case"), "PALLET80": (1440, "pallet")}
    scan = must(api("GET", f"/gtin/{pallet}", CATALOGUE))
    assert (scan["product_id"], scan["unit_code"], scan["packaging_level"]) == ("COLA-075", "PALLET80", "pallet")
    assert (D(scan["quantity_in_sales_unit"]), D(scan["quantity_in_base_unit"]), scan["base_unit_code"]) == (D("1920"), D("1440"), "LTR")
    assert scan["exact"] == {"base": "1440/1", "sales": "1920/1"}
    assert D(must(api("GET", f"/gtin/{bottle}", CATALOGUE))["quantity_in_base_unit"]) == D("0.75")
    # unique across everything: another product, another unit, and a repeat of the same row
    status, body = api("POST", "/products/COLA-033/set-units", CATALOGUE, {"units_of": [{"unit_code": "C62", "numerator": 33, "denominator": 100, "gtin": bottle}]})
    assert status == 409 and f"{bottle.zfill(14)} is COLA-075 C62" in body["detail"]
    status, body = api("POST", "/products/COLA-075/set-units", CATALOGUE, {"units_of": [{"unit_code": "PK24", "numerator": 18, "denominator": 1, "gtin": bottle}]})
    assert status == 409 and "is COLA-075 C62" in body["detail"]
    assert must(api("POST", "/products/COLA-075/set-units", CATALOGUE, {"units_of": [{"unit_code": "PALLET80", "numerator": 1440, "denominator": 1, "gtin": pallet,
                                                                                    "packaging_level": "pallet"}]}))["units"]
    # a wrong check digit, a wrong length and a stranger are refused
    for bad in (bottle[:-1] + str((int(bottle[-1]) + 1) % 10), "12345", "ABCDEFGHIJKLM"):
        assert api("POST", "/products/COLA-033/set-units", CATALOGUE, {"units_of": [{"unit_code": "C62", "numerator": 1, "denominator": 3, "gtin": bad}]})[0] in (409, 422)
    assert api("GET", "/gtin/00000000", CATALOGUE)[0] == 404
    # a draft product's barcode is a 404 to sales (an unreleased product), and found by those who keep the catalogue
    assert api("GET", f"/gtin/{pallet}", SALES)[0] == 404
    must(api("POST", "/products/COLA-075/activate-product", CATALOGUE, {}))
    assert must(api("GET", f"/gtin/{pallet}", SALES))["unit_code"] == "PALLET80"
    # a used unit's barcode never changes
    must(api("POST", "/products/COLA-075/units/PALLET80/used", SELLING, {}))
    status, body = api("POST", "/products/COLA-075/add-unit", CATALOGUE, {"unit": {"unit_code": "PALLET80", "numerator": 1440, "denominator": 1, "gtin": gtin("540000000004")}})
    assert status == 409 and "already on documents" in body["detail"]


def test_a_barcode_check_digit_is_the_gs1_one():
    assert variants.valid_gtin("4006381333931") == "4006381333931" and variants.valid_gtin("96385074") == "96385074"
    assert variants.with_check_digit("400638133393") == "4006381333931"
    with pytest.raises(RuleBroken, match="check digit should be 1"):
        variants.valid_gtin("4006381333932")