393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -23,14 +23,13 @@ from .const import (
CONF_PARTS,
CONF_TASK_CONSUMES_PARTS,
CONF_TASKS,
DEFAULT_CURRENCY_DECIMALS,
DOMAIN,
EVENT_PART_RESTOCKED,
EVENT_PART_STOCK_LOW,
EVENT_PART_STOCK_OUT,
)
from .helpers.aggregate import object_name as _object_name
from .helpers.global_options import get_global_options
from .helpers.global_options import global_option
from .helpers.i18n import normalize_language
from .helpers.parts import (
PART_REF_FIELD,
@@ -124,7 +123,7 @@ async def async_change_part_stock(
part_id: str,
*,
delta: float | None = None,
absolute: int | None = None,
absolute: float | None = None,
) -> float | None:
"""Change one part's stock (clamped at 0), fire the edge event, save.
@@ -245,18 +244,54 @@ async def async_apply_history_parts_edit(
) -> list[dict[str, Any]]:
"""Reconcile stock for an edited history entry's parts (#130).
:func:`apply_history_parts_edit` + :func:`async_commit_parts_edit` in
one call; returns the enriched list to store on the entry.
"""
enriched, touched = apply_history_parts_edit(hass, entry, task_data, old_used, new_used)
await async_commit_parts_edit(hass, touched)
return enriched
async def async_commit_parts_edit(hass: HomeAssistant, touched: list[tuple[ConfigEntry, Any]]) -> None:
"""Save the Stores :func:`apply_history_parts_edit` changed, signal the
parts sensors and reconcile the buy tasks."""
for owner, store in touched:
await store.async_save()
_signal_parts_updated(hass, owner)
schedule_buy_task_reconcile(hass, owner)
def apply_history_parts_edit(
hass: HomeAssistant,
entry: ConfigEntry,
task_data: dict[str, Any],
old_used: list[dict[str, Any]],
new_used: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[tuple[ConfigEntry, Any]]]:
"""The synchronous half of a history entry's parts edit (#130).
Applies the per-part difference between the entry's previous and new
``used_parts`` to the owning stocks (consuming more decrements, reducing
a quantity returns the difference) and returns the enriched list to store
on the entry (``{part_id, name, quantity}`` + ``entry_id`` for pooled
parts). Owner resolution per part: an explicit ``entry_id`` on the link,
else the task's ``consumes_parts`` link for that part (#111 pools), else
the object's own catalog. Best-effort like the completion path — a
vanished part skips its stock math but stays recorded by name.
parts) plus the touched ``(entry, store)`` pairs for
:func:`async_commit_parts_edit`. Owner resolution per part: an explicit
``entry_id`` on the link, else the task's ``consumes_parts`` link for
that part (#111 pools), else the object's own catalog. Best-effort like
the completion path — a vanished part skips its stock math but stays
recorded by name.
Synchronous on purpose: the history edit applies the delta and writes
the entry in one go, so the delta is computed from the entry as it is
stored at that moment and a vanished entry is refused BEFORE any stock
moved (bug audit 2026-09-27).
"""
from .helpers.parts import iter_part_links
# Phase links name pools too (a phase may consume another object's part).
link_owners: dict[str, str] = {}
for link in task_data.get(CONF_TASK_CONSUMES_PARTS) or []:
if isinstance(link, dict) and link.get("part_id") and link.get("entry_id"):
for link in iter_part_links(task_data):
if link.get("part_id") and link.get("entry_id"):
link_owners[str(link["part_id"])] = str(link["entry_id"])
def resolve(link: dict[str, Any]) -> tuple[ConfigEntry, dict[str, Any], Any] | None:
@@ -296,11 +331,6 @@ async def async_apply_history_parts_edit(
_fire_transition(hass, owner, part, new_stock, stock_transition(part, old_stock, new_stock))
touched[owner.entry_id] = (owner, store)
for owner, store in touched.values():
await store.async_save()
_signal_parts_updated(hass, owner)
schedule_buy_task_reconcile(hass, owner)
enriched: list[dict[str, Any]] = []
for link in new_used:
if not isinstance(link, dict) or not link.get("part_id"):
@@ -315,7 +345,7 @@ async def async_apply_history_parts_edit(
if resolved and resolved[0].entry_id != entry.entry_id:
item["entry_id"] = resolved[0].entry_id
enriched.append(item)
return enriched
return enriched, list(touched.values())
def schedule_buy_task_reconcile(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -374,7 +404,14 @@ async def _reconcile_buy_tasks_locked(hass: HomeAssistant, entry: ConfigEntry) -
# resume/unarchive setup catch-up recreates them if the part is still low.
obj = entry.data.get(CONF_OBJECT, {})
if obj.get("archived_at") is not None or obj.get("paused_at") is not None:
parts = {}
# ...except for a pool OTHER live objects still draw on (#111): the
# shelf is in use, only its owner retired. Suppressing those too
# meant the borrowers ran the pool empty without a single reminder
# (bug audit 2026-09-27).
from .helpers.shared_parts import borrowed_part_ids
in_use = borrowed_part_ids(hass, entry.entry_id, active_only=True)
parts = {pid: part for pid, part in parts.items() if pid in in_use}
store = _get_store(hass, entry)
if store is None:
return False
@@ -386,10 +423,10 @@ async def _reconcile_buy_tasks_locked(hass: HomeAssistant, entry: ConfigEntry) -
tasks,
object_id=entry.data.get(CONF_OBJECT, {}).get("id", ""),
lang=normalize_language(hass),
search_template=get_global_options(hass).get(CONF_PART_SEARCH_URL_TEMPLATE),
search_template=global_option(hass, CONF_PART_SEARCH_URL_TEMPLATE),
country=hass.config.country,
today=dt_util.now().date(),
decimals=int(get_global_options(hass).get(CONF_CURRENCY_DECIMALS, DEFAULT_CURRENCY_DECIMALS)),
decimals=int(global_option(hass, CONF_CURRENCY_DECIMALS)),
is_task_done=lambda td: store.get_last_performed(td["id"]) is not None,
)
if not changed:
@@ -454,9 +491,6 @@ async def _reconcile_buy_tasks_locked(hass: HomeAssistant, entry: ConfigEntry) -
return True
BROKEN_LINK_ISSUE_PREFIX = "broken_part_link_"
def _raise_broken_link_issue(hass: HomeAssistant, entry: ConfigEntry, part_ids: list[str]) -> None:
"""Tell the user a completion could not decrement what it was linked to.
@@ -467,11 +501,13 @@ def _raise_broken_link_issue(hass: HomeAssistant, entry: ConfigEntry, part_ids:
"""
from homeassistant.helpers import issue_registry as ir
from .helpers.issues import broken_part_link_issue_id
object_name = _object_name(entry)
ir.async_create_issue(
hass,
DOMAIN,
f"{BROKEN_LINK_ISSUE_PREFIX}{entry.entry_id}",
broken_part_link_issue_id(entry.entry_id),
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="broken_part_link",