"""Spare parts & consumables — pure domain logic. Maintenance consumes things (filters, seals, descaler, salt). This module owns the part model and every rule around it, hass-free so it is trivially unit-testable: - normalization/validation of the part dict stored in ``entry.data["parts"]`` - GTIN validation (GS1 check digit; the GTIN family covers EAN-13/UPC-A/EAN-8) - edge-triggered stock transitions (``low`` / ``out`` / ``restocked``) - the **declarative buy-task reconciler**: an auto "Buy {part}" task exists exactly while its part opts in AND is low. Computing the desired set and diffing against reality makes idempotence and self-healing fall out as properties instead of special cases. - the shopping-search URL resolver (product_url wins; otherwise a configurable ``{q}`` template with query precedence GTIN → "{vendor} {mpn}" → name). Static part definitions live in ``entry.data["parts"]`` (like ``tasks``); the mutable stock count lives in the per-entry Store (like ``last_performed``) and is passed in here as a plain ``{part_id: stock}`` map. """ from __future__ import annotations import re from collections.abc import Mapping from datetime import date from typing import Any from urllib.parse import quote_plus from uuid import uuid4 # ── Limits (mirrored in the WS schemas + panel dialog) ────────────────────── MAX_PARTS_PER_OBJECT = 50 MAX_PART_NAME = 100 MAX_PART_MPN = 64 MAX_PART_VENDOR = 64 MAX_PART_STORAGE_LOCATION = 120 MAX_PART_NOTES = 500 MAX_PART_UNIT = 16 MAX_PART_URL = 500 MAX_PART_COST = 100_000.0 MAX_PART_STOCK = 9_999 MAX_CONSUMES_PER_TASK = 10 MAX_CONSUME_QUANTITY = 999 # Marker on auto-created buy tasks: {"part_id": "..."} — the reconciler # exclusively owns tasks carrying it. Detached (popped) from a COMPLETED buy # task once its part is restocked, so the task survives as a plain done # one-off (its cost history stays in the statistics) while the next low # episode is free to create a fresh reminder. PART_REF_FIELD = "part_ref" # Label + icon stamped on auto-created buy tasks (design decision: existing # ``custom`` task type + label instead of a new task-type enum). BUY_TASK_LABEL = "shopping" BUY_TASK_ICON = "mdi:cart" # Localized "Buy {name}" templates (HA config language; EN fallback). Kept as # a flat table like templates_i18n — the backend can't reach strings.json for # runtime-generated names. _BUY_NAME_TEMPLATES = { "en": "Buy {name}", "de": "{name} kaufen", "fr": "Acheter {name}", "es": "Comprar {name}", "it": "Acquistare {name}", "nl": "{name} kopen", "pl": "Kup {name}", "pt": "Comprar {name}", "cs": "Koupit {name}", "da": "Køb {name}", "fi": "Osta {name}", "hi": "{name} खरीदें", "ja": "{name}を購入", "nb": "Kjøp {name}", "ru": "Купить {name}", "sv": "Köp {name}", "uk": "Купити {name}", "zh": "购买{name}", } # Default shopping-search templates by UI language (the "Amazon as fallback" # decision); overridable via the global ``part_search_url_template`` setting. _DEFAULT_SEARCH_TEMPLATES = { "de": "https://www.amazon.de/s?k={q}", "fr": "https://www.amazon.fr/s?k={q}", "it": "https://www.amazon.it/s?k={q}", "es": "https://www.amazon.es/s?k={q}", "nl": "https://www.amazon.nl/s?k={q}", } _FALLBACK_SEARCH_TEMPLATE = "https://www.amazon.com/s?k={q}" class PartValidationError(ValueError): """A part payload failed validation.""" # ── GTIN ───────────────────────────────────────────────────────────────────── def validate_gtin(raw: Any) -> str | None: """Normalize + validate a GTIN (EAN-13 / UPC-A / EAN-8 / GTIN-14). The GS1 GTIN family is the worldwide standard — EAN-13 is GTIN-13, the North-American UPC-A is GTIN-12 (an EAN-13 with a leading zero). Accepts 8/12/13/14 digits (spaces/dashes tolerated), verifies the GS1 mod-10 check digit, and returns the normalized digit string. ``None`` for empty input; raises :class:`PartValidationError` for malformed input. """ if raw is None: return None s = re.sub(r"[\s-]", "", str(raw)) if not s: return None if not s.isdigit() or len(s) not in (8, 12, 13, 14): raise PartValidationError("gtin must be 8, 12, 13 or 14 digits (EAN/UPC/GTIN)") digits = [int(c) for c in s] check = digits[-1] total = 0 # GS1: number positions from the RIGHT starting at 1 (the check digit); # even positions weigh 3, odd weigh 1. for i, d in enumerate(reversed(digits[:-1]), start=2): total += d * (3 if i % 2 == 0 else 1) if (10 - total % 10) % 10 != check: raise PartValidationError("gtin check digit is invalid") return s # ── Part normalization ─────────────────────────────────────────────────────── def _clean_str(raw: Any, field: str, max_len: int) -> str: s = str(raw or "").strip() if len(s) > max_len: raise PartValidationError(f"{field} must be at most {max_len} characters") return s def _clean_url(raw: Any) -> str: s = str(raw or "").strip() if not s: return "" if len(s) > MAX_PART_URL or not re.match(r"^https?://", s): raise PartValidationError("product_url must be an http(s) URL") return s def _clean_cost(raw: Any) -> float | None: if raw in (None, ""): return None try: v = round(float(raw), 2) except (TypeError, ValueError) as err: raise PartValidationError("cost must be a number") from err if not 0 <= v <= MAX_PART_COST: raise PartValidationError("cost out of range") return v def round_qty(v: float) -> float | int: """Canonical quantity rounding (#98 decimal consumables): 2 decimals, whole numbers collapse back to int so exports/UI stay clean.""" r = round(float(v), 2) return int(r) if r.is_integer() else r def _clean_stock(raw: Any, field: str) -> float | int | None: """Stock-ish number or None. ``stock: None`` = inventory not tracked. Decimal quantities are allowed (#98 — half a can of spray is 0.5), rounded to 2 decimals. """ if raw in (None, ""): return None try: v = float(raw) except (TypeError, ValueError) as err: raise PartValidationError(f"{field} must be a number") from err if not 0 <= v <= MAX_PART_STOCK: raise PartValidationError(f"{field} out of range (0-{MAX_PART_STOCK})") return round_qty(v) def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]: """Validate + normalize one part definition (the stored static shape). ``stock`` is intentionally NOT part of this dict — the mutable count lives in the per-entry Store. A part with no tracked stock is a catalog-only entry (identifiers + links), which is a valid, useful state. """ if not isinstance(raw, Mapping): raise PartValidationError("part must be an object") name = _clean_str(raw.get("name"), "name", MAX_PART_NAME) if not name: raise PartValidationError("part name must not be empty") part: dict[str, Any] = { "id": str(raw.get("id") or uuid4().hex), "name": name, "mpn": _clean_str(raw.get("mpn"), "mpn", MAX_PART_MPN), "gtin": validate_gtin(raw.get("gtin")), "vendor": _clean_str(raw.get("vendor"), "vendor", MAX_PART_VENDOR), "storage_location": _clean_str(raw.get("storage_location"), "storage_location", MAX_PART_STORAGE_LOCATION), "product_url": _clean_url(raw.get("product_url")), "notes": _clean_str(raw.get("notes"), "notes", MAX_PART_NOTES), "unit": _clean_str(raw.get("unit"), "unit", MAX_PART_UNIT), "cost": _clean_cost(raw.get("cost")), "reorder_threshold": _clean_stock(raw.get("reorder_threshold"), "reorder_threshold"), "restock_quantity": _clean_stock(raw.get("restock_quantity"), "restock_quantity"), "auto_buy_task": bool(raw.get("auto_buy_task")), # Receipt/datasheet via the refcounted DocumentStore (not inline files). "doc_id": _clean_str(raw.get("doc_id"), "doc_id", 64) or None, } return part def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) -> list[dict[str, Any]]: """Cap/clean a task's ``consumes_parts`` list ([{part_id, quantity}]). Unknown part ids are dropped when ``valid_part_ids`` is given; quantity is clamped to 1..MAX_CONSUME_QUANTITY; duplicates collapse (last wins). """ if not isinstance(raw, list): return [] out: dict[str, dict[str, Any]] = {} for item in raw[:MAX_CONSUMES_PER_TASK]: if not isinstance(item, Mapping): continue part_id = str(item.get("part_id") or "").strip() if not part_id or (valid_part_ids is not None and part_id not in valid_part_ids): continue try: qty = float(item.get("quantity", 1)) except (TypeError, ValueError): qty = 1.0 # Decimal consumption is allowed (#98). Zero/negative behaves like # invalid input (falls back to 1), matching the old integer clamp. if qty <= 0: qty = 1.0 out[part_id] = {"part_id": part_id, "quantity": round_qty(min(qty, MAX_CONSUME_QUANTITY))} return list(out.values()) # ── Stock rules ────────────────────────────────────────────────────────────── def part_is_low(part: Mapping[str, Any], stock: float | None) -> bool: """Tracked stock at/below the reorder threshold.""" threshold = part.get("reorder_threshold") return stock is not None and threshold is not None and stock <= float(threshold) def part_wants_buy_task(part: Mapping[str, Any]) -> bool: return bool(part.get("auto_buy_task")) and part.get("reorder_threshold") is not None def stock_transition(part: Mapping[str, Any], old: float | None, new: float | None) -> str | None: """The edge this stock change crossed, if any: ``low`` / ``out`` / ``restocked``. Edge-triggered on purpose — a further decrease while already low never re-nags, and automations get exactly one event per crossing. """ if new is None: return None was_low = part_is_low(part, old) is_low = part_is_low(part, new) if new == 0 and (old is None or old > 0): return "out" if is_low and not was_low: return "low" if was_low and not is_low: return "restocked" return None # ── Shopping-search URL ────────────────────────────────────────────────────── def default_search_template(lang: str) -> str: return _DEFAULT_SEARCH_TEMPLATES.get((lang or "en")[:2].lower(), _FALLBACK_SEARCH_TEMPLATE) def search_query(part: Mapping[str, Any]) -> str: """Query precedence: GTIN (most precise) → "{vendor} {mpn}" → name.""" if part.get("gtin"): return str(part["gtin"]) mpn = str(part.get("mpn") or "").strip() if mpn: vendor = str(part.get("vendor") or "").strip() return f"{vendor} {mpn}".strip() return str(part.get("name") or "") def resolve_shopping_url(part: Mapping[str, Any], template: str | None, lang: str) -> str: """The link to buy this part: ``product_url`` wins; else the search template.""" if part.get("product_url"): return str(part["product_url"]) tpl = (template or "").strip() or default_search_template(lang) if "{q}" not in tpl: tpl = tpl.rstrip("/") + "?q={q}" return tpl.replace("{q}", quote_plus(search_query(part))) # ── Buy-task construction + declarative reconcile ──────────────────────────── def buy_task_name(part_name: str, lang: str) -> str: tpl = _BUY_NAME_TEMPLATES.get((lang or "en")[:2].lower(), _BUY_NAME_TEMPLATES["en"]) return tpl.replace("{name}", part_name) def buy_task_notes(part: Mapping[str, Any], stock: float | None) -> str: """Self-contained purchase notes: identifiers, qty, price, storage spot. Deliberately mostly language-neutral (labels are identifiers like MPN/GTIN; the storage location is the user's own text). """ lines: list[str] = [] qty = part.get("restock_quantity") or 1 unit = f" {part['unit']}" if part.get("unit") else "" lines.append(f"{qty}×{unit} {part['name']}".replace("× ", "× ").strip()) idents = " · ".join( p for p in ( f"{part['vendor']}" if part.get("vendor") else "", f"MPN: {part['mpn']}" if part.get("mpn") else "", f"GTIN: {part['gtin']}" if part.get("gtin") else "", ) if p ) if idents: lines.append(idents) if part.get("cost") is not None: lines.append(f"≈ {part['cost']:.2f} × {qty}") if stock is not None: lines.append(f"◎ {stock}") if part.get("storage_location"): lines.append(f"→ {part['storage_location']}") return "\n".join(lines) def build_buy_task( part: Mapping[str, Any], stock: float | None, *, object_id: str, lang: str, search_template: str | None, today: date, ) -> dict[str, Any]: """The one-off shopping reminder for a low part (due today, actionable now).""" return { "id": uuid4().hex, "object_id": object_id, "name": buy_task_name(str(part["name"]), lang), "type": "custom", "enabled": True, "schedule": {"kind": "one_time", "due_date": today.isoformat()}, "warning_days": 0, "created_at": today.isoformat(), "labels": [BUY_TASK_LABEL], "custom_icon": BUY_TASK_ICON, "notes": buy_task_notes(part, stock), "documentation_url": resolve_shopping_url(part, search_template, lang), PART_REF_FIELD: {"part_id": str(part["id"])}, } def reconcile_buy_tasks( parts: Mapping[str, Mapping[str, Any]], stocks: Mapping[str, float | None], tasks: Mapping[str, Mapping[str, Any]], *, object_id: str, lang: str, search_template: str | None, today: date, is_task_done: Any, ) -> tuple[dict[str, dict[str, Any]], list[str], list[str], bool]: """Compute the task map with auto "buy" reminders synced to low parts. Declarative: a buy task is **desired** for every part that opts in (:func:`part_wants_buy_task`) and is low (:func:`part_is_low`). Diffing desired vs. the tasks carrying a ``part_ref`` yields exactly the creates and removals — idempotence and self-healing are properties, not bookkeeping. Episode semantics: an existing buy task — open OR already completed — occupies its part's low episode, so completing the reminder (when the restock didn't lift the stock above the threshold) never respawns a duplicate. When the part leaves the desired set (restocked / opted out / deleted): - an **open** reminder is removed (orphan cleanup), while - a **completed** one keeps its cost history: its ``part_ref`` is detached so it survives as a plain done one-off (retention applies normally) and the next low episode starts fresh. ``is_task_done(task_dict) -> bool`` is injected so this module stays free of the task model. Returns ``(new_tasks, created_ids, removed_ids, changed)``. """ desired: set[str] = { part_id for part_id, part in parts.items() if part_wants_buy_task(part) and part_is_low(part, stocks.get(part_id)) } result: dict[str, dict[str, Any]] = {tid: dict(t) for tid, t in tasks.items()} existing_by_part: dict[str, str] = {} for tid, task in result.items(): ref = task.get(PART_REF_FIELD) if isinstance(ref, Mapping) and ref.get("part_id"): existing_by_part[str(ref["part_id"])] = tid created: list[str] = [] removed: list[str] = [] for part_id, tid in list(existing_by_part.items()): if part_id in desired: continue if is_task_done(result[tid]): # Keep the completed reminder (and its cost history); detach the # marker so the next low episode can create a fresh one. result[tid].pop(PART_REF_FIELD, None) else: removed.append(tid) del result[tid] existing_by_part.pop(part_id, None) for part_id in sorted(desired): if part_id in existing_by_part: continue task = build_buy_task( parts[part_id], stocks.get(part_id), object_id=object_id, lang=lang, search_template=search_template, today=today, ) result[task["id"]] = task created.append(task["id"]) changed = bool(created or removed) or any( tasks[tid].get(PART_REF_FIELD) != result[tid].get(PART_REF_FIELD) for tid in tasks if tid in result ) return result, created, removed, changed