dodil/erp-brick-product
tests/test_v4_slice_a.py
"""Product v4, slice A1: GTINs per unit (PRD-27), dated measures (PRD-28), dated unit roles (PRD-29), rounding rules (PRD-30)."""
from decimal import Decimal as D
from conftest import CATALOGUE, SALES, SELLING, api, categories, mk_product, must, on_day
from brick_product.domain import variants
MAIN, CASE = "05012345000015", "04006381333931"
LATER = "2026-11-25T09:00:00+00:00"
def cola(pid="COLA-075", activate=True):
"""COLA-075, base LTR: a bottle (C62) is 3/4 LTR, PK24 is 24 bottles (18 LTR), PALLET80 is 80 packs; PK24's main GTIN is MAIN."""
mk_product(pid, name="Cola 0.75 L", base_unit_code="LTR", activate=False)
must(api("POST", f"/products/{pid}/set-units", CATALOGUE, {"sales_unit_code": "C62", "units_of": [
{"unit_code": "C62", "numerator": 3, "denominator": 4, "packaging_level": "each"},
{"unit_code": "PK24", "contains_unit": "C62", "contains_quantity": "24", "packaging_level": "case", "gtin": MAIN, "einvoice_unit_code": "XPK"},
{"unit_code": "PALLET80", "contains_unit": "PK24", "contains_quantity": "80", "packaging_level": "pallet", "einvoice_unit_code": "XPX"}]}))
if activate:
must(api("POST", f"/products/{pid}/activate-product", CATALOGUE, {}))
def gtins(pid, unit):
out = must(api("GET", f"/products/{pid}", CATALOGUE))
return {g["gtin"]: g["kind"] for u in out["units"] if u["unit_code"] == unit for g in u["gtins"]}
def test_prd_27_two_gtins_on_one_unit_one_main_a_retired_one_still_names_its_unit():
"""PRD-27 — COLA-075 PK24 with main GTIN MAIN; the supplier's case carries CASE: add it, try COLA-150, make it main, retire it."""
cola()
mk_product("COLA-150", name="Cola 1.5 L", base_unit_code="LTR", activate=False)
must(api("POST", "/products/COLA-150/set-units", CATALOGUE, {"units_of": [{"unit_code": "PK6", "numerator": 9, "denominator": 1}]}))
added = must(api("POST", "/products/COLA-075/add-gtin", CATALOGUE, {"unit_code": "PK24", "gtin": CASE}))
assert {g["gtin"]: g["kind"] for g in added["gtins"]} == {MAIN: "main", CASE: "additional"}
for g, kind in ((MAIN, "main"), (CASE, "additional")): # scanning either answers COLA-075, PK24, 24 bottles (18 LTR)
scan = must(api("GET", f"/gtin/{g}", SALES))
assert (scan["product_id"], scan["unit_code"], scan["kind"], scan["status"], scan["base_unit_code"]) == ("COLA-075", "PK24", kind, "active", "LTR")
assert D(scan["quantity_base"]) == 18
status, body = api("POST", "/products/COLA-150/add-gtin", CATALOGUE, {"unit_code": "PK6", "gtin": CASE})
assert status == 409 and "04006381333931 is COLA-075 PK24" in body["detail"]
must(api("POST", "/products/COLA-075/make-gtin-main", CATALOGUE, {"gtin": CASE}))
assert gtins("COLA-075", "PK24") == {CASE: "main", MAIN: "additional"} # exactly one main
# a wrong check digit is refused, naming the digit expected
status, body = api("POST", "/products/COLA-075/add-gtin", CATALOGUE, {"unit_code": "PALLET80", "gtin": "04006381333932"})
assert status == 409 and "check digit should be 1" in body["detail"]
# retired: no longer answers a new document's scan as active, still names its unit, never given to another product
retired = must(api("POST", "/products/COLA-075/retire-gtin", CATALOGUE, {"gtin": CASE}))
assert {g["gtin"]: (g["kind"], g["status"]) for g in retired["gtins"]} == {MAIN: ("main", "active"), CASE: ("additional", "retired")}
old = must(api("GET", f"/gtin/{CASE}", SALES))
assert (old["status"], old["product_id"], old["unit_code"]) == ("retired", "COLA-075", "PK24")
assert gtins("COLA-075", "PK24") == {MAIN: "main"}
assert api("POST", "/products/COLA-150/add-gtin", CATALOGUE, {"unit_code": "PK6", "gtin": CASE})[0] == 409
assert api("POST", "/products/COLA-075/add-gtin", CATALOGUE, {"unit_code": "PK24", "gtin": CASE})[0] == 409
assert api("GET", "/gtin/04006381333900", SALES)[0] == 404 # never issued
assert api("POST", "/products/COLA-075/add-gtin", SALES, {"unit_code": "PK24", "gtin": CASE})[0] == 403
def test_a_gtin_is_claimed_on_the_base_unit_too_and_8_digits_pad_to_14():
cola()
ean8 = variants.with_check_digit("9638507")
must(api("POST", "/products/COLA-075/add-gtin", CATALOGUE, {"unit_code": "C62", "gtin": ean8}))
assert must(api("GET", f"/gtin/{ean8}", SALES))["gtin"] == ean8.zfill(14)
assert must(api("GET", f"/gtin/{ean8.zfill(14)}", SALES))["unit_code"] == "C62"
assert D(must(api("GET", f"/gtin/{ean8}", SALES))["quantity_base"]) == D("0.75")
def test_prd_28_measures_are_dated_rows_never_frozen_by_use():
"""PRD-28 — PK24 19.2 KGM gross / 18.0 net, 0.027 MTQ, 40 x 27 x 25 CMT; the bottle 0.79 / 0.75 KGM; a correction to 19.6 from a later
day leaves the delivery's copy 19.2 and a later delivery copies 19.6; GET ?on= answers the row in force then."""
cola()
pk = {"unit_code": "PK24", "gross_weight": "19.2", "net_weight": "18.0", "weight_unit_code": "KGM", "volume": "0.027",
"volume_unit_code": "MTQ", "length": "40", "width": "27", "height": "25", "dimension_unit_code": "CMT"}
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, pk))
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "C62", "gross_weight": "0.79", "net_weight": "0.75", "weight_unit_code": "KGM"}))
units = {u["unit_code"]: u for u in must(api("GET", "/products/COLA-075", SALES))["units"]}
m = units["PK24"]["measures"]
assert (m["gross_weight"], m["net_weight"], m["weight_unit_code"], m["volume"], m["volume_unit_code"], m["length"], m["width"], m["height"],
m["dimension_unit_code"], m["valid_from"]) == ("19.2", "18", "KGM", "0.027", "MTQ", "40", "27", "25", "CMT", "2026-11-20")
assert units["C62"]["measures"]["gross_weight"] == "0.79" and units["PALLET80"]["measures"] is None and units["LTR"]["measures"] is None
# refused: net above gross, a weight in a volume, a volume in a length, an unset measure never 0
status, body = api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "net_weight": "20"})
assert status == 409 and "above the gross" in body["detail"]
status, body = api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "5", "weight_unit_code": "LTR"})
assert status == 409 and "volume" in body["detail"] and "mass" in body["detail"], body
assert api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "volume": "1", "volume_unit_code": "CMT"})[0] == 409
assert api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "1e13", "weight_unit_code": "KGM"})[0] == 422
assert api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "XBX", "gross_weight": "1", "weight_unit_code": "KGM"})[0] == 409
# today's row is replaced while nothing copied it; a delivery copies it; later days correct by a new row
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.1"}))
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.2"}))
must(api("POST", "/products/COLA-075/units/PK24/used", SELLING, {"measures_from": "2026-11-20"}))
status, body = api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.9"})
assert status == 409 and "a document copied today's row: correct from tomorrow" in body["detail"]
with on_day(LATER):
assert api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.6", "valid_from": "2026-11-24"})[0] == 409
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.6"}))
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.65"})) # same day, not copied
pk24 = lambda on: {u["unit_code"]: u for u in must(api("GET", "/products/COLA-075", SALES, {"on": on}))["units"]}["PK24"] # noqa: E731
assert pk24("2026-11-22")["measures"]["gross_weight"] == "19.2" and pk24("2026-11-25")["measures"]["gross_weight"] == "19.65"
assert pk24("2026-11-25")["measures"]["net_weight"] == "18" # carried from the row in force
assert pk24("2026-11-25")["numerator"] == 18 # the conversion stays frozen
must(api("POST", "/products/COLA-075/units/PK24/used", SELLING, {"measures_from": "2026-11-25"}))
assert api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.7"})[0] == 409
assert api("POST", "/products/COLA-075/set-measures", CATALOGUE, {"unit_code": "PK24", "gross_weight": "19.7", "valid_from": "2026-11-26"})[0] == 200
def test_a_measure_set_twice_the_same_creates_nothing_more():
cola()
one = {"unit_code": "C62", "gross_weight": "0.79", "weight_unit_code": "KGM"}
must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, one))
with on_day(LATER):
again = must(api("POST", "/products/COLA-075/set-measures", CATALOGUE, one))
assert again["changed"] is False and again["valid_from"] == "2026-11-20"
def test_prd_29_roles_name_the_products_own_units_dated_and_inherited_from_the_template():
"""PRD-29 — purchase PK24, sales C62, issue LTR, production LTR, warehouse PK24, delivery C62; a role of a unit it lacks is refused;
unset roles default to the base unit and say so; a template's role is inherited by a variant without its own."""
mk_product("COLA-075", name="Cola", base_unit_code="LTR", activate=False)
must(api("POST", "/products/COLA-075/set-units", CATALOGUE, {"sales_unit_code": "C62", "units_of": [
{"unit_code": "C62", "numerator": 3, "denominator": 4}, {"unit_code": "PK24", "contains_unit": "C62", "contains_quantity": "24", "einvoice_unit_code": "XPK"}]}))
assert api("POST", "/products/COLA-075/set-units", CATALOGUE, {"purchase_unit_code": "XBX"})[0] == 409
got = must(api("GET", "/products/COLA-075", CATALOGUE))
assert got["roles"]["sales"] == "C62" and got["roles"]["delivery"] == "LTR" and {"production", "warehouse", "delivery"} <= set(got["roles_defaulted"])
must(api("POST", "/products/COLA-075/set-units", CATALOGUE, {"purchase_unit_code": "PK24", "issue_unit_code": "LTR", "production_unit_code": "LTR",
"warehouse_unit_code": "PK24", "delivery_unit_code": "C62"}))
must(api("POST", "/products/COLA-075/activate-product", CATALOGUE, {}))
got = must(api("GET", "/products/COLA-075", SALES))
assert got["roles"] == {"sales": "C62", "purchase": "PK24", "issue": "LTR", "production": "LTR", "warehouse": "PK24", "delivery": "C62"}
assert got["roles_defaulted"] == [] and got["purchase_unit_code"] == "PK24"
# a change is a dated row: refused for today, the default for new lines from tomorrow, never for a date already past
assert api("POST", "/products/COLA-075/set-unit-role", CATALOGUE, {"role": "purchase", "unit_code": "C62", "valid_from": "2026-11-20"})[0] == 409
assert api("POST", "/products/COLA-075/set-unit-role", CATALOGUE, {"role": "purchase", "unit_code": "XBX"})[0] == 409
must(api("POST", "/products/COLA-075/set-unit-role", CATALOGUE, {"role": "purchase", "unit_code": "C62"}))
assert must(api("GET", "/products/COLA-075", SALES))["roles"]["purchase"] == "PK24"
assert must(api("GET", "/products/COLA-075", SALES, {"on": "2026-11-21"}))["roles"]["purchase"] == "C62"
assert must(api("GET", "/products/COLA-075", SALES, {"on": "2026-11-19"}))["roles"]["purchase"] == "PK24"
assert api("POST", "/products/COLA-075/set-unit-role", SALES, {"role": "purchase", "unit_code": "C62"})[0] == 403
def test_prd_29_a_role_set_on_the_template_is_inherited_unless_the_variant_has_its_own():
categories(("drinks", None))
must(api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY",
"name": "Cola", "attributes": [{"name": "volume", "values": [{"value": "0.33 L", "code": "033"}, {"value": "0.75 L", "code": "075"}]}],
"base_unit_code": "LTR", "category_code": "drinks", "tax_category": "standard", "billing_kind": "one_off", "invoicing_policy": "ordered",
"units_of": [{"unit_code": "PK12", "numerator": 12, "denominator": 1, "einvoice_unit_code": "XPK"}]}))
must(api("POST", "/products/COLA-033/set-unit-role", CATALOGUE, {"role": "purchase", "unit_code": "PK12", "scope": "template"}))
for sku in ("COLA-033", "COLA-075"):
assert must(api("GET", f"/products/{sku}", CATALOGUE, {"on": "2026-11-21"}))["roles"]["purchase"] == "PK12"
must(api("POST", "/products/COLA-075/set-unit-role", CATALOGUE, {"role": "purchase", "unit_code": "LTR"}))
assert must(api("GET", "/products/COLA-075", CATALOGUE, {"on": "2026-11-21"}))["roles"]["purchase"] == "LTR"
assert must(api("GET", "/products/COLA-033", CATALOGUE, {"on": "2026-11-21"}))["roles"]["purchase"] == "PK12"
def rules():
cola()
must(api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "purchase", "min_quantity": "80", "rounding_multiple": "80", "unit_code": "PK24"}))
must(api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "delivery", "rounding_multiple": "6", "unit_code": "C62"}))
def rounding(role, quantity, unit, on="2026-11-21"):
return must(api("GET", "/products/COLA-075/rounding", SALES, {"role": role, "quantity": quantity, "unit_code": unit, "on": on}))
def test_prd_30_a_dated_rule_per_role_in_the_unit_it_was_written_in():
"""PRD-30 — "at least 80, in multiples of 80 PK24" (purchase) and "multiples of 6 C62" (delivery) from tomorrow."""
rules()
q = rounding("purchase", "100", "PK24")
assert (q["met"], q["next_down"], q["next_up"], q["reason"]) == (False, "80", "160", "COLA-075 is bought in multiples of 80 PK24")
assert q["rule"] == {"unit_code": "PK24", "min_quantity": "80", "rounding_multiple": "80", "valid_from": "2026-11-21"}
pallet = rounding("purchase", "3", "PALLET80")
assert (pallet["met"], pallet["quantity_in_rule_unit"]) == (True, "240")
assert rounding("delivery", "20", "C62")["met"] is False and rounding("delivery", "24", "C62")["met"] is True
assert rounding("purchase", "50", "PK24")["next_down"] is None and rounding("purchase", "50", "PK24")["next_up"] == "80"
# a quantity that does not convert exactly into the rule's unit is answered in the rule's own unit
odd = rounding("delivery", "1", "LTR")
assert (odd["met"], odd["quantity_in_rule_unit"], odd["next_down"], odd["next_up"]) == (False, "1.333333", "0", "6") or odd["next_down"] is None
# a rule dated today or earlier is refused; a rule needs a minimum or a multiple; product enforces nothing (the answer is a 200)
assert api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "purchase", "rounding_multiple": "40", "valid_from": "2026-11-20"})[0] == 409
assert api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "purchase"})[0] == 409
assert api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "purchase", "rounding_multiple": "0", "unit_code": "PK24"})[0] in (409, 422)
assert api("POST", "/products/COLA-075/set-rounding", SALES, {"role": "purchase", "rounding_multiple": "40"})[0] == 403
def test_prd_30_the_rule_is_inherited_from_the_template_and_a_role_change_carries_it_forward():
categories(("drinks", None))
must(api("POST", "/templates/COLA/generate-variants", CATALOGUE, {"product_type_code": "LEGACY",
"name": "Cola", "attributes": [{"name": "volume", "values": [{"value": "0.33 L", "code": "033"}, {"value": "0.75 L", "code": "075"}]}],
"base_unit_code": "LTR", "category_code": "drinks", "tax_category": "standard", "billing_kind": "one_off", "invoicing_policy": "ordered",
"units_of": [{"unit_code": "PK12", "numerator": 12, "denominator": 1, "einvoice_unit_code": "XPK"}]}))
must(api("POST", "/products/COLA-033/set-rounding", CATALOGUE, {"role": "purchase", "rounding_multiple": "10", "unit_code": "PK12", "scope": "template"}))
for sku in ("COLA-033", "COLA-075"):
r = must(api("GET", f"/products/{sku}/rounding", CATALOGUE, {"role": "purchase", "quantity": "15", "unit_code": "PK12", "on": "2026-11-21"}))
assert r["met"] is False and r["rule_row"]["owner"] == "COLA"
must(api("POST", "/products/COLA-033/set-unit-role", CATALOGUE, {"role": "purchase", "unit_code": "PK12", "valid_from": "2026-11-22"}))
assert must(api("GET", "/products/COLA-033/rounding", CATALOGUE, {"role": "purchase", "quantity": "15", "unit_code": "PK12", "on": "2026-11-23"}))["met"] is False
# ── A2: PRD-31 (a price per a quantity), PRD-32 (the rounding answer for callers), PRD-33 (the Odoo migration of units) ──
from conftest import PRICING, mk_list, person, price, row # noqa: E402
def screws():
mk_product("SCREW-M4", name="Screw M4", base_unit_code="C62", activate=False)
must(api("POST", "/products/SCREW-M4/set-units", CATALOGUE, {"sales_unit_code": "C62", "units_of": [
{"unit_code": "BX500", "numerator": 500, "denominator": 1, "einvoice_unit_code": "XBX"}]}))
must(api("POST", "/products/SCREW-M4/activate-product", CATALOGUE, {}))
def test_prd_31_a_price_per_100_is_kept_per_100_and_the_amount_is_rounded_once():
"""PRD-31 — SCREW-M4 on LIST-GBP at 2.4500 per 100 C62: 250 C62 is 6.13 (6.125 half up), 3 BX500 (1 500 C62) is 36.75."""
screws()
mk_list("LIST-GBP", rows=[row("SCREW-M4", "2026-10-02", "2.4500", unit_code="C62", pricing_quantity=100)], default_from="2026-10-02")
got = must(price("SCREW-M4", quantity="250", unit_code="C62"))
assert (D(got["unit_price"]), got["pricing_quantity"], got["pricing_unit_code"], D(got["amount"])) == (D("2.4500"), 100, "C62", D("6.13"))
assert got["price_row"]["pricing_quantity"] == 100 and got["priced_from"]["row"]["unit_code"] == "C62"
box = must(price("SCREW-M4", quantity="3", unit_code="BX500")) # the box has no row of its own: priced in the row's unit
assert (D(box["unit_price"]), box["pricing_quantity"], box["pricing_unit_code"], box["pricing_numerator"], box["pricing_denominator"],
D(box["amount"])) == (D("2.4500"), 100, "C62", 1, 1, D("36.75"))
assert box["unit_code"] == "BX500" and box["conversion"]["numerator"] == 500 and box["line"]["unit_numerator"] == 500
for bad in (0, 100001, 2.5, "x"):
assert api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [
row("SCREW-M4", "2027-01-01", "2.50", unit_code="C62", pricing_quantity=bad)]})[0] == 422
def test_prd_31_a_percentage_rule_applies_to_the_price_per_pricing_quantity_and_breaks_compare_in_base():
screws()
mk_list("LIST-GBP", rows=[row("SCREW-M4", "2026-10-02", "2.4500", unit_code="C62", pricing_quantity=100)], default_from="2026-10-02")
mk_list("PARTNER-GBP", rounding="none", rules=[{"seq": 1, "applies_to": "all", "min_quantity": "1000", "discount_pct": "20", "base_list": "LIST-GBP"},
{"seq": 2, "applies_to": "all", "discount_pct": "10", "base_list": "LIST-GBP"}])
must(api("POST", "/price-lists/PARTNER-GBP/assign-customer", PRICING, {"bp_id": "7311"}))
small = must(price("SCREW-M4", bp_id="7311", quantity="250", unit_code="C62", priced_on="2026-12-01"))
assert (D(small["unit_price"]), small["pricing_quantity"], D(small["amount"])) == (D("2.205"), 100, D("5.51")) # 5.5125 -> 5.51
big = must(price("SCREW-M4", bp_id="7311", quantity="3", unit_code="BX500", priced_on="2026-12-01")) # 1 500 C62 >= the break
assert (D(big["unit_price"]), big["pricing_quantity"], D(big["amount"])) == (D("1.96"), 100, D("29.40")) # 2.45 x 0.8 = 1.96 per 100
def test_prd_31_a_price_per_one_still_re_expresses_in_the_asked_unit_when_exact():
screws()
mk_list("LIST-GBP", rows=[row("SCREW-M4", "2026-10-02", "0.0250", unit_code="C62")], default_from="2026-10-02")
got = must(price("SCREW-M4", quantity="2", unit_code="BX500"))
assert (D(got["unit_price"]), got["pricing_quantity"], got["pricing_unit_code"], D(got["amount"])) == (D("12.5"), 1, "BX500", D("25.00"))
def test_prd_32_the_four_answers_for_a_purchasing_and_a_sales_line_each_naming_the_rule_row_it_used():
"""PRD-32 — purchase: at least 80, multiple 80 PK24 from the 22nd; delivery: multiples of 6 C62."""
cola()
must(api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "purchase", "min_quantity": "80", "rounding_multiple": "80",
"unit_code": "PK24", "valid_from": "2026-11-22"}))
must(api("POST", "/products/COLA-075/set-rounding", CATALOGUE, {"role": "delivery", "rounding_multiple": "6", "unit_code": "C62"}))
a = rounding("purchase", "100", "PK24", "2026-11-22")
assert (a["met"], a["next_down"], a["next_up"], a["rule_row"]["valid_from"]) == (False, "80", "160", "2026-11-22")
b = rounding("purchase", "100", "PK24", "2026-11-21")
assert (b["met"], b["rule"], b["reason"]) == (True, None, "no rule on 2026-11-21")
c = rounding("delivery", "2", "PK24", "2026-11-22")
assert (c["met"], c["quantity_in_rule_unit"], c["rule"]["unit_code"]) == (True, "48", "C62")
d = rounding("production", "5", "LTR", "2026-11-22")
assert (d["met"], d["reason"]) == (True, "no rule for role production")
assert rounding("purchase", "100", "PK24", "2026-11-22") == a and rounding("production", "5", "LTR", "2026-11-22") == d # repeatable
assert api("GET", "/products/COLA-075/rounding", SALES, {"role": "purchase", "quantity": "1", "unit_code": "XBX"})[0] == 422
assert api("GET", "/products/NOPE/rounding", SALES, {"role": "purchase", "quantity": "1", "unit_code": "C62"})[0] == 404
assert api("GET", "/products/COLA-075/rounding", SALES, {"role": "nonsense", "quantity": "1", "unit_code": "C62"})[0] == 422
# Odoo 19: relative units and product.uom barcodes; Odoo 17: product.packaging.
UOMS19 = [{"odoo_id": 1, "name": "Units"}, {"odoo_id": 2, "name": "Pack of 24", "relative_uom_id": 1, "relative_factor": "24.0", "rounding": "1"},
{"odoo_id": 3, "name": "kg"}, {"odoo_id": 4, "name": "g"}]
COLA19 = {"odoo_id": 100, "default_code": "COLA-075", "type": "product", "name": "Cola 0.75 L", "uom_id": 1, "barcode": "5400000000010", "weight": "0.79",
"volume": "0.001", "uoms": [{"uom_id": 1}, {"odoo_id": 5, "uom_id": 2, "barcode": CASE}]}
IMPORTER = person("importer@shop.example", ["product:catalogue:import", "product:migration:read", "product:product:read"])
def migrate(body, who=IMPORTER):
return api("POST", "/migrations/odoo/units", who, body)
def test_prd_33_an_odoo_19_database_migrates_units_barcodes_and_weights_and_a_second_run_writes_nothing():
run = {"db": "odoo19", "cutover": "2026-11-01", "uoms": UOMS19, "products": [COLA19], "pack_codes": {"Pack of 24": "PK24"}}
first = must(migrate(run))
assert first["units"] == {"Units": "C62", "Pack of 24": "PK24", "kg": "KGM", "g": "GRM"} and first["run_id"] == "odoo:odoo19:2026-11-01"
assert first["written"]["products"] == 1 and first["written"]["unit_rows"] == 1 and first["written"]["gtins"] == 2
assert first["reconcile"]["units_used"]["equal"] and first["reconcile"]["barcodes"]["equal"]
got = {u["unit_code"]: u for u in must(api("GET", "/products/COLA-075", CATALOGUE, {"on": "2026-11-20"}))["units"]}
assert (got["PK24"]["numerator"], got["PK24"]["denominator"]) == (24, 1) and [g["gtin"] for g in got["PK24"]["gtins"]] == [CASE]
assert got["C62"]["gtins"] == [{"gtin": "05400000000010", "kind": "main"}]
assert (got["C62"]["measures"]["gross_weight"], got["C62"]["measures"]["weight_unit_code"], got["C62"]["measures"]["volume"],
got["C62"]["measures"]["valid_from"]) == ("0.79", "KGM", "0.001", "2026-11-01")
assert must(api("GET", f"/gtin/{CASE}", CATALOGUE))["kind"] == "main"
again = must(migrate(run))
assert again["total_written"] == 0 and again["written"] == {"products": 0, "unit_rows": 0, "gtins": 0, "measures": 0, "map_entries": 0}
# the map answers each Odoo unit's unit_code, each product's code and each packaging's (product, unit)
ids = lambda model, ids: must(api("GET", f"/migration-map/odoo19/{model}", SELLING, {"ids": ids})) # noqa: E731
assert [(m["odoo_id"], m["ours"]) for m in ids("uom.uom", "1,2,3,4,99")] == [("1", "C62"), ("2", "PK24"), ("3", "KGM"), ("4", "GRM")]
assert ids("product.product", "100")[0]["ours"] == "COLA-075" and ids("product.packaging", "5")[0]["ours"] == ["COLA-075", "PK24"]
assert api("GET", "/migration-map/odoo19/uom.uom", SALES, {"ids": "1"})[0] == 403 # machine-only: a person role cannot read it
def test_prd_33_a_second_barcode_for_the_same_unit_is_additional_and_odoo_17_packagings_migrate():
two = {**COLA19, "uoms": [{"uom_id": 1}, {"uom_id": 2, "barcode": CASE}, {"uom_id": 2, "barcode": MAIN}]}
must(migrate({"db": "odoo19", "cutover": "2026-11-01", "uoms": UOMS19, "products": [two], "pack_codes": {"Pack of 24": "PK24"}}))
g = {x["gtin"]: x["kind"] for x in must(api("GET", "/products/COLA-075", CATALOGUE))["units"][1]["gtins"]}
assert g == {CASE: "main", MAIN: "additional"}
old = {"odoo_id": 100, "default_code": "COLA-033", "detailed_type": "product", "name": "Cola 0.33 L", "uom_id": 1,
"packagings": [{"odoo_id": 7, "name": "Pack of 24", "qty": "24", "barcode": variants.with_check_digit("540000000002")}]}
out = must(migrate({"db": "odoo17", "cutover": "2026-11-01", "uoms": [{"odoo_id": 1, "name": "Units"}], "products": [{**old, "default_code": "COLA-033"}],
"pack_codes": {"Pack of 24": "PK24"}}))
assert out["written"]["unit_rows"] == 1 and out["written"]["gtins"] == 1
assert must(api("GET", f"/gtin/{variants.with_check_digit('540000000002')}", CATALOGUE))["unit_code"] == "PK24"
assert must(api("GET", "/migration-map/odoo17/product.packaging", SELLING, {"ids": "7"}))[0]["ours"] == ["COLA-033", "PK24"]
def test_prd_33_a_factor_that_is_not_an_exact_fraction_stops_the_run_naming_the_unit_and_writes_nothing():
bad = [{"odoo_id": 1, "name": "Units"}, {"odoo_id": 2, "name": "Weird", "relative_uom_id": 1, "relative_factor": "0.1234567", "rounding": "0.0000001"}]
product = {**COLA19, "uoms": [{"uom_id": 1}, {"uom_id": 2}]}
status, body = migrate({"db": "odoo19", "cutover": "2026-11-01", "uoms": bad, "products": [product], "pack_codes": {"Weird": "PKW"}})
assert status == 422 and "unit Weird" in body["detail"] and "nothing was written" in body["detail"]
assert api("GET", "/products/COLA-075", CATALOGUE)[0] == 404
# a barcode on two products stops the run naming both; a unit nobody named stops it too
twin = {**COLA19, "odoo_id": 101, "default_code": "COLA-150", "uoms": [{"uom_id": 1}, {"uom_id": 2, "barcode": CASE}]}
status, body = migrate({"db": "odoo19", "cutover": "2026-11-01", "uoms": UOMS19, "products": [COLA19, twin], "pack_codes": {"Pack of 24": "PK24"}})
assert status == 422 and "COLA-075" in body["detail"] and "COLA-150" in body["detail"]
assert migrate({"db": "odoo19", "cutover": "2026-11-01", "uoms": UOMS19, "products": [COLA19]})[0] == 422 # no pack code for Pack of 24
assert migrate({"db": "odoo19", "cutover": "2026-11-01", "uoms": UOMS19, "products": [COLA19], "pack_codes": {"Pack of 24": "PK24"}}, CATALOGUE)[0] == 403
def test_prd_33_a_product_that_exists_in_another_base_unit_stops_the_run():
mk_product("COLA-075", name="Cola", base_unit_code="LTR", activate=False)
status, body = migrate({"db": "odoo19", "cutover": "2026-11-01", "uoms": UOMS19, "products": [COLA19], "pack_codes": {"Pack of 24": "PK24"}})
assert status == 409 and "COLA-075" in body["detail"] and "LTR" in body["detail"]