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