dodil/erp-brick-product

src/brick_product/domain/variants.py

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