"""Product templates and their variants (PRD-24), and barcodes (PRD-25).
A template holds what several products share (Cola), with attributes (volume: 0.33 L, 0.75 L, 1.5 L). Each combination is its
OWN product — a SKU, named as the customer names it (COLA-033). Generating again adds only the combinations that are missing.
Every price, document line and stock row names a product, never the template.
"""
import itertools
import re
from typing import Iterable
from brick_product.domain.errors import RuleBroken
from brick_product.domain.product import valid_code
MAX_VARIANTS = 500
def slug(value: str) -> str:
"""A SKU part from a value when none is given: its letters and digits, upper case (0.75 L -> 075L)."""
return re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
def check_attributes(attributes: Iterable[dict]) -> list:
"""The attributes of a template as [{name, values: [{value, code}]}], each checked: a name, at least one value, no value
or code twice within an attribute."""
out, names = [], set()
attributes = list(attributes)
if not attributes:
raise RuleBroken("a template needs at least one attribute and its values (volume: 0.33 L, 0.75 L)")
for a in attributes:
name = (a.get("name") or "").strip()
if not name or name in names:
raise RuleBroken("every attribute has its own name")
names.add(name)
values, seen_v, seen_c = [], set(), set()
for v in a.get("values") or []:
value = (v.get("value") or "").strip()
code = (v.get("code") or slug(value)).strip()
if not value or not code:
raise RuleBroken(f"attribute {name}: every value needs a name")
if value in seen_v or code in seen_c:
raise RuleBroken(f"attribute {name}: '{value}' (or its code {code}) is given twice")
seen_v.add(value)
seen_c.add(code)
values.append({"value": value, "code": code})
if not values:
raise RuleBroken(f"attribute {name} needs at least one value")
out.append({"name": name, "values": values, **({"characteristic_code": a["characteristic_code"]} if a.get("characteristic_code") else {})})
return out
def merge_attributes(stored: list, wanted: list) -> list:
"""A template's attributes after a second generate: the same attribute names, with any new values added. A new attribute
would leave the products that exist without it, so it is refused; a value's code is fixed once set."""
if [a["name"] for a in stored] != [a["name"] for a in wanted]:
raise RuleBroken("the template's attributes are fixed once products exist: "
+ ", ".join(a["name"] for a in stored) + " — a new attribute is a new template")
out = []
for s, w in zip(stored, wanted):
values = {v["value"]: v for v in s["values"]}
for v in w["values"]:
if v["value"] in values and values[v["value"]]["code"] != v["code"]:
raise RuleBroken(f"attribute {s['name']}: {v['value']} already has the code {values[v['value']]['code']}")
values.setdefault(v["value"], v)
codes = [v["code"] for v in values.values()]
if len(set(codes)) != len(codes):
raise RuleBroken(f"attribute {s['name']}: two values share a code")
code = s.get("characteristic_code") or w.get("characteristic_code")
out.append({"name": s["name"], "values": list(values.values()), **({"characteristic_code": code} if code else {})})
return out
def combinations(template_id: str, attributes: list) -> list:
"""Every combination as (sku, {attribute: value}), in the order the values were given."""
names = [a["name"] for a in attributes]
total = 1
for a in attributes:
total *= len(a["values"])
if total > MAX_VARIANTS:
raise RuleBroken(f"{total} combinations: a template has up to {MAX_VARIANTS} variants")
out = []
for combo in itertools.product(*(a["values"] for a in attributes)):
out.append((valid_code(template_id + "-" + "-".join(v["code"] for v in combo)),
{n: v["value"] for n, v in zip(names, combo)}))
return out
def variant_name(template_name: str, values: dict) -> str:
return " ".join([template_name] + list(values.values()))
def valid_gtin(gtin: str) -> str:
"""A GS1 GTIN (8, 12, 13 or 14 digits) whose last digit is the right check digit."""
g = (gtin or "").strip()
if not g.isdigit() or len(g) not in (8, 12, 13, 14):
raise RuleBroken(f"a GTIN is 8, 12, 13 or 14 digits, not '{gtin}'")
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(g[:-1])))
if (10 - total % 10) % 10 != int(g[-1]):
raise RuleBroken(f"{g} is not a valid GTIN: its check digit should be {(10 - total % 10) % 10}")
return g
def gtin14(gtin: str) -> str:
"""The GTIN as it is claimed and scanned: valid, left-padded with zeros to 14 digits (8, 12 and 13 digit forms name the same item)."""
return valid_gtin(gtin).zfill(14)
def with_check_digit(body: str) -> str:
"""The GTIN for 7, 11, 12 or 13 digits: the body plus its GS1 check digit (for tests and imports)."""
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body)))
return body + str((10 - total % 10) % 10)