updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -0,0 +1,429 @@
"""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 _clean_stock(raw: Any, field: str) -> int | None:
"""Stock-ish int or None. ``stock: None`` = inventory not tracked."""
if raw in (None, ""):
return None
try:
v = int(raw)
except (TypeError, ValueError) as err:
raise PartValidationError(f"{field} must be an integer") from err
if not 0 <= v <= MAX_PART_STOCK:
raise PartValidationError(f"{field} out of range (0-{MAX_PART_STOCK})")
return 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 = int(item.get("quantity", 1))
except (TypeError, ValueError):
qty = 1
out[part_id] = {"part_id": part_id, "quantity": max(1, min(qty, MAX_CONSUME_QUANTITY))}
return list(out.values())
# ── Stock rules ──────────────────────────────────────────────────────────────
def part_is_low(part: Mapping[str, Any], stock: int | 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 <= int(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: int | None, new: int | 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: int | 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: int | 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, int | 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