546 lines
21 KiB
Python
546 lines
21 KiB
Python
"""Spare-parts runtime — the hass-aware driver around helpers/parts.py.
|
||
|
||
Owns the three mutations (consume on completion, restock, manual adjust) and
|
||
applies the declarative buy-task reconcile to the entry. The pure rules live in
|
||
:mod:`helpers.parts`; this module only wires them to the Store, the ConfigEntry
|
||
and the event bus.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from homeassistant.config_entries import ConfigEntry
|
||
from homeassistant.core import HomeAssistant
|
||
from homeassistant.util import dt as dt_util
|
||
|
||
from .const import (
|
||
BATTERY_FLEET_TASK_FLAG,
|
||
CONF_CURRENCY_DECIMALS,
|
||
CONF_OBJECT,
|
||
CONF_PART_SEARCH_URL_TEMPLATE,
|
||
CONF_PARTS,
|
||
CONF_TASK_CONSUMES_PARTS,
|
||
CONF_TASKS,
|
||
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 global_option
|
||
from .helpers.i18n import normalize_language
|
||
from .helpers.parts import (
|
||
PART_REF_FIELD,
|
||
reconcile_buy_tasks,
|
||
stock_transition,
|
||
)
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
_TRANSITION_EVENTS = {
|
||
"low": EVENT_PART_STOCK_LOW,
|
||
"out": EVENT_PART_STOCK_OUT,
|
||
"restocked": EVENT_PART_RESTOCKED,
|
||
}
|
||
|
||
# Dispatcher signal fired after any stock change — the parts sensors listen.
|
||
SIGNAL_PARTS_UPDATED = f"{DOMAIN}_parts_updated"
|
||
|
||
|
||
def _get_store(hass: HomeAssistant, entry: ConfigEntry) -> Any:
|
||
rd = getattr(entry, "runtime_data", None)
|
||
return getattr(rd, "store", None) if rd else None
|
||
|
||
|
||
def resolve_part_link(
|
||
hass: HomeAssistant,
|
||
entry: ConfigEntry,
|
||
link: dict[str, Any],
|
||
*,
|
||
link_owners: dict[str, str] | None = None,
|
||
) -> tuple[ConfigEntry | None, dict[str, Any] | None, Any]:
|
||
"""Resolve a task→part link to ``(owner_entry, part, store)``.
|
||
|
||
The owner is the link's ``entry_id`` (#111 pooled link), a
|
||
``link_owners`` fallback (part_id → entry_id, derived from the task's
|
||
consumes_parts), or the task's own entry. Any element can be None when
|
||
that leg is broken — what a broken link MEANS (repair issue, silent
|
||
skip, raw-id name fallback) stays the caller's business, which is
|
||
exactly where the three hand-copied resolvers had drifted.
|
||
"""
|
||
part_id = str(link.get("part_id") or "")
|
||
owner_id = str(link.get("entry_id") or "").strip() or (link_owners or {}).get(part_id) or entry.entry_id
|
||
owner = entry if owner_id == entry.entry_id else hass.config_entries.async_get_entry(owner_id)
|
||
if owner is None:
|
||
return None, None, None
|
||
part = (owner.data.get(CONF_PARTS) or {}).get(part_id)
|
||
return owner, part, _get_store(hass, owner)
|
||
|
||
|
||
def part_link_name(hass: HomeAssistant, entry: ConfigEntry, link: dict[str, Any]) -> str:
|
||
"""Display name for a link's part, falling back to the raw part id."""
|
||
_owner, part, _store = resolve_part_link(hass, entry, link)
|
||
name = (part or {}).get("name")
|
||
return str(name) if name else str(link.get("part_id"))
|
||
|
||
|
||
def _fire_transition(
|
||
hass: HomeAssistant,
|
||
entry: ConfigEntry,
|
||
part: dict[str, Any],
|
||
stock: float | None,
|
||
transition: str | None,
|
||
) -> None:
|
||
event = _TRANSITION_EVENTS.get(transition or "")
|
||
if event is None:
|
||
return
|
||
obj = entry.data.get(CONF_OBJECT, {})
|
||
hass.bus.async_fire(
|
||
event,
|
||
{
|
||
"entry_id": entry.entry_id,
|
||
"object_id": obj.get("id", ""),
|
||
"object_name": _object_name(entry),
|
||
"part_id": part["id"],
|
||
"part_name": part.get("name", ""),
|
||
"stock": stock,
|
||
"reorder_threshold": part.get("reorder_threshold"),
|
||
},
|
||
)
|
||
|
||
|
||
def _signal_parts_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||
|
||
async_dispatcher_send(hass, SIGNAL_PARTS_UPDATED, entry.entry_id)
|
||
|
||
|
||
async def async_change_part_stock(
|
||
hass: HomeAssistant,
|
||
entry: ConfigEntry,
|
||
part_id: str,
|
||
*,
|
||
delta: float | None = None,
|
||
absolute: float | None = None,
|
||
) -> float | None:
|
||
"""Change one part's stock (clamped at 0), fire the edge event, save.
|
||
|
||
Returns the new stock, or None when the part is unknown / untracked with a
|
||
pure delta (a delta on an untracked part starts tracking from 0 so a first
|
||
"restock" works naturally). Schedules the buy-task reconcile.
|
||
"""
|
||
part = (entry.data.get(CONF_PARTS) or {}).get(part_id)
|
||
store = _get_store(hass, entry)
|
||
if part is None or store is None:
|
||
return None
|
||
old = store.get_part_stock(part_id)
|
||
if absolute is not None:
|
||
new = max(0.0, float(absolute))
|
||
else:
|
||
new = max(0.0, (old or 0) + float(delta or 0))
|
||
store.set_part_stock(part_id, new)
|
||
# Immediate save: part CRUD and the buy-task reconcile may reload the entry
|
||
# right after, which re-reads the store from disk — a debounced save would
|
||
# silently lose the stock write across that reload.
|
||
await store.async_save()
|
||
_fire_transition(hass, entry, part, new, stock_transition(part, old, new))
|
||
_signal_parts_updated(hass, entry)
|
||
schedule_buy_task_reconcile(hass, entry)
|
||
return new
|
||
|
||
|
||
async def async_handle_completion_parts(
|
||
hass: HomeAssistant,
|
||
entry: ConfigEntry,
|
||
task_data: dict[str, Any],
|
||
*,
|
||
restock_quantity: float | None = None,
|
||
used_parts: list[dict[str, Any]] | None = None,
|
||
) -> None:
|
||
"""Completion-side part effects: consume linked parts / restock a buy task.
|
||
|
||
Called from the coordinator's complete path with the (pre-completion) task
|
||
dict. Consumption only tracks parts that HAVE a tracked stock; a buy task
|
||
(carrying ``part_ref``) restocks its part by ``restock_quantity`` (dialog
|
||
override) or the part's configured default (min 1). Best-effort by design:
|
||
a broken part link must never block the completion itself.
|
||
"""
|
||
parts = entry.data.get(CONF_PARTS) or {}
|
||
store = _get_store(hass, entry)
|
||
if store is None:
|
||
return
|
||
changed = False
|
||
|
||
ref = task_data.get(PART_REF_FIELD)
|
||
if isinstance(ref, dict) and ref.get("part_id") in parts:
|
||
part = parts[ref["part_id"]]
|
||
# Packages bought × package size (#98 follow-up); without a package
|
||
# size one package is one unit.
|
||
from .helpers.parts import restock_units
|
||
|
||
qty = restock_units(part, restock_quantity)
|
||
old = store.get_part_stock(part["id"])
|
||
new = max(0, (old or 0) + qty)
|
||
store.set_part_stock(part["id"], new)
|
||
_fire_transition(hass, entry, part, new, stock_transition(part, old, new))
|
||
changed = True
|
||
|
||
# #99: an explicit per-completion selection REPLACES the fixed links —
|
||
# including the empty selection ("nothing used this time"). None keeps the
|
||
# automatic consumes_parts behaviour — phase-aware (#139): the phase
|
||
# currently due may override which parts a completion consumes.
|
||
from .helpers.phases import effective_field
|
||
|
||
# #181: the battery fleet task consumes per DEVICE (Battery Notes quantity,
|
||
# in ``async_mark_replaced`` / ``async_record_replacement``); a task-level
|
||
# link on it would be charged again on every fleet completion, so the
|
||
# automatic links are ignored there. An explicit selection still applies.
|
||
# Any: the links come from storage or a completion request — each one is
|
||
# shape-checked below.
|
||
links: list[Any]
|
||
if used_parts is not None:
|
||
links = used_parts
|
||
elif task_data.get(BATTERY_FLEET_TASK_FLAG):
|
||
links = []
|
||
else:
|
||
links = effective_field(task_data, CONF_TASK_CONSUMES_PARTS) or []
|
||
# #111: a link may name another object's pool. Every touched entry has its
|
||
# own parts dict and its own Store, so collect them per entry and save each.
|
||
touched: dict[str, tuple[ConfigEntry, Any]] = {}
|
||
if changed:
|
||
touched[entry.entry_id] = (entry, store)
|
||
broken: list[str] = []
|
||
|
||
for link in links:
|
||
if not isinstance(link, dict):
|
||
continue
|
||
target_entry, part, target_store = resolve_part_link(hass, entry, link)
|
||
if target_entry is None or target_store is None or part is None:
|
||
# The pool is gone (owner deleted, its store unavailable, or the
|
||
# part removed from it). Silence here would record a completion
|
||
# that consumed nothing and tell nobody — surface it instead.
|
||
broken.append(str(link.get("part_id") or "?"))
|
||
continue
|
||
old = target_store.get_part_stock(part["id"])
|
||
if old is None:
|
||
continue # catalog-only part — nothing to decrement
|
||
qty = float(link.get("quantity", 1) or 1)
|
||
new = max(0, old - qty)
|
||
target_store.set_part_stock(part["id"], new)
|
||
_fire_transition(hass, target_entry, part, new, stock_transition(part, old, new))
|
||
touched[target_entry.entry_id] = (target_entry, target_store)
|
||
changed = True
|
||
|
||
if broken:
|
||
_raise_broken_link_issue(hass, entry, broken)
|
||
|
||
for target_entry, target_store in touched.values():
|
||
await target_store.async_save() # reconcile may reload — see async_change_part_stock
|
||
_signal_parts_updated(hass, target_entry)
|
||
schedule_buy_task_reconcile(hass, target_entry)
|
||
|
||
|
||
async def async_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]],
|
||
) -> 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) 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 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:
|
||
owner, part, store = resolve_part_link(hass, entry, link, link_owners=link_owners)
|
||
if owner is None or part is None or store is None:
|
||
return None
|
||
return owner, part, store
|
||
|
||
def quantities(links: list[dict[str, Any]]) -> dict[str, float]:
|
||
out: dict[str, float] = {}
|
||
for link in links:
|
||
if isinstance(link, dict) and link.get("part_id"):
|
||
out[str(link["part_id"])] = out.get(str(link["part_id"]), 0.0) + float(link.get("quantity", 1) or 1)
|
||
return out
|
||
|
||
old_q = quantities(old_used)
|
||
new_q = quantities(new_used)
|
||
by_id = {str(link["part_id"]): link for link in new_used if isinstance(link, dict) and link.get("part_id")}
|
||
for link in old_used:
|
||
if isinstance(link, dict) and link.get("part_id"):
|
||
by_id.setdefault(str(link["part_id"]), link)
|
||
|
||
touched: dict[str, tuple[ConfigEntry, Any]] = {}
|
||
for part_id in set(old_q) | set(new_q):
|
||
delta = new_q.get(part_id, 0.0) - old_q.get(part_id, 0.0)
|
||
if delta == 0:
|
||
continue
|
||
resolved = resolve(by_id.get(part_id) or {"part_id": part_id})
|
||
if resolved is None:
|
||
continue
|
||
owner, part, store = resolved
|
||
old_stock = store.get_part_stock(part["id"])
|
||
if old_stock is None:
|
||
continue # catalog-only part — nothing to adjust
|
||
new_stock = max(0.0, old_stock - delta)
|
||
store.set_part_stock(part["id"], new_stock)
|
||
_fire_transition(hass, owner, part, new_stock, stock_transition(part, old_stock, new_stock))
|
||
touched[owner.entry_id] = (owner, store)
|
||
|
||
# #104: a part already on the entry keeps the price it was booked at; one
|
||
# added by the edit is valued at today's price.
|
||
from .helpers.parts import unit_price
|
||
|
||
booked = {
|
||
str(link["part_id"]): link["unit_cost"]
|
||
for link in old_used
|
||
if isinstance(link, dict) and link.get("part_id") and isinstance(link.get("unit_cost"), (int, float))
|
||
}
|
||
enriched: list[dict[str, Any]] = []
|
||
for link in new_used:
|
||
if not isinstance(link, dict) or not link.get("part_id"):
|
||
continue
|
||
resolved = resolve(link)
|
||
name = resolved[1].get("name") if resolved else link.get("name")
|
||
item: dict[str, Any] = {
|
||
"part_id": str(link["part_id"]),
|
||
"name": name or str(link["part_id"]),
|
||
"quantity": float(link.get("quantity", 1) or 1),
|
||
}
|
||
if resolved and resolved[0].entry_id != entry.entry_id:
|
||
item["entry_id"] = resolved[0].entry_id
|
||
price = booked.get(item["part_id"])
|
||
if price is None and resolved:
|
||
price = unit_price(resolved[1])
|
||
if price is not None:
|
||
item["unit_cost"] = price
|
||
enriched.append(item)
|
||
return enriched, list(touched.values())
|
||
|
||
|
||
def schedule_buy_task_reconcile(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||
"""Run the buy-task reconcile as a background task.
|
||
|
||
Deferred because applying a diff reloads the entry — which must never
|
||
happen from inside the coordinator call (complete/restock) that triggered
|
||
the stock change. The reconcile is declarative/idempotent, so overlapping
|
||
schedules converge.
|
||
"""
|
||
entry_id = entry.entry_id
|
||
|
||
async def _run() -> None:
|
||
current = hass.config_entries.async_get_entry(entry_id)
|
||
if current is not None:
|
||
await async_reconcile_buy_tasks(hass, current)
|
||
|
||
hass.async_create_task(_run(), name=f"{DOMAIN}_buy_task_reconcile_{entry_id}")
|
||
|
||
|
||
_RECONCILE_LOCKS: dict[str, Any] = {}
|
||
|
||
|
||
def discard_reconcile_lock(entry_id: str) -> None:
|
||
"""Forget a removed entry's reconcile lock (called from async_remove_entry)."""
|
||
_RECONCILE_LOCKS.pop(entry_id, None)
|
||
|
||
|
||
async def async_reconcile_buy_tasks(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||
"""Apply the declarative buy-task reconcile to *entry*.
|
||
|
||
A buy task exists exactly while its part opts in AND is low (see
|
||
helpers/parts.reconcile_buy_tasks for the episode semantics). Creates and
|
||
removals are applied through the same primitives the WS CRUD uses (store
|
||
init / full delete-cleanup), then the entry reloads ONCE so per-task
|
||
entities appear/disappear. Returns True when anything changed.
|
||
|
||
Concurrency: serialized per entry (reconciles overlap freely with rapid
|
||
CRUD), and the ConfigEntry write applies only the computed DIFF onto a
|
||
fresh read of entry.data — a whole-map write from the pre-await snapshot
|
||
could clobber a part/task another handler persisted in between (a lost
|
||
update seen live when three part creates raced the first reconcile).
|
||
"""
|
||
import asyncio
|
||
|
||
lock = _RECONCILE_LOCKS.setdefault(entry.entry_id, asyncio.Lock())
|
||
async with lock:
|
||
return await _reconcile_buy_tasks_locked(hass, entry)
|
||
|
||
|
||
async def _reconcile_buy_tasks_locked(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||
parts = entry.data.get(CONF_PARTS) or {}
|
||
# An archived or paused object must stay quiet (journey S4): no shopping
|
||
# reminders while it's retired/out of season. Declaratively: an inert
|
||
# object desires NO buy tasks — open reminders are removed, and the
|
||
# 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:
|
||
# ...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
|
||
tasks: dict[str, Any] = entry.data.get(CONF_TASKS, {})
|
||
|
||
new_tasks, created, removed, changed = reconcile_buy_tasks(
|
||
parts,
|
||
store.all_part_stocks(),
|
||
tasks,
|
||
object_id=entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||
lang=normalize_language(hass),
|
||
search_template=global_option(hass, CONF_PART_SEARCH_URL_TEMPLATE),
|
||
country=hass.config.country,
|
||
today=dt_util.now().date(),
|
||
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:
|
||
return False
|
||
|
||
# The diff relative to the snapshot: brand-new buy tasks, and existing
|
||
# tasks whose part_ref marker was detached. Only these keys are written.
|
||
detached = [
|
||
tid
|
||
for tid in tasks
|
||
if tid in new_tasks and tasks[tid].get(PART_REF_FIELD) != new_tasks[tid].get(PART_REF_FIELD)
|
||
]
|
||
|
||
# Removals first, via the shared full-cleanup primitive (entity registry,
|
||
# store state, group refs, notification state) — without per-task reloads.
|
||
from .websocket.tasks_crud import async_delete_task
|
||
|
||
for tid in removed:
|
||
await async_delete_task(hass, entry, tid)
|
||
|
||
# Apply the diff onto a FRESH read (never write back pre-await snapshots).
|
||
current = hass.config_entries.async_get_entry(entry.entry_id)
|
||
if current is None:
|
||
return True
|
||
new_data = dict(current.data)
|
||
merged_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||
obj = dict(new_data.get(CONF_OBJECT, {}))
|
||
task_ids = list(obj.get("task_ids", []))
|
||
for tid in created:
|
||
merged_tasks[tid] = new_tasks[tid]
|
||
if tid not in task_ids:
|
||
task_ids.append(tid)
|
||
for tid in detached:
|
||
if tid in merged_tasks:
|
||
td = dict(merged_tasks[tid])
|
||
td.pop(PART_REF_FIELD, None)
|
||
merged_tasks[tid] = td
|
||
obj["task_ids"] = [t for t in task_ids if t in merged_tasks]
|
||
new_data[CONF_TASKS] = merged_tasks
|
||
new_data[CONF_OBJECT] = obj
|
||
hass.config_entries.async_update_entry(current, data=new_data)
|
||
|
||
for tid in created:
|
||
store.init_task(tid)
|
||
await store.async_save()
|
||
|
||
_LOGGER.debug(
|
||
"Buy-task reconcile for %s: +%d / -%d",
|
||
entry.title,
|
||
len(created),
|
||
len(removed),
|
||
)
|
||
if created or removed:
|
||
# Entities for created/removed tasks appear/vanish on reload.
|
||
await hass.config_entries.async_reload(entry.entry_id)
|
||
|
||
# v2.67: mirror the new buy-task set into the configured shopping list
|
||
# (no-op unless the global shopping_list_entity option is set).
|
||
from .shopping_sync import schedule_resync
|
||
|
||
schedule_resync(hass)
|
||
return True
|
||
|
||
|
||
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.
|
||
|
||
Deliberately a repair issue rather than a refused completion: the work WAS
|
||
done, and losing the history entry would be the bigger harm. What must not
|
||
happen is the silent version — a tick that consumed nothing and said so
|
||
nowhere.
|
||
"""
|
||
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,
|
||
broken_part_link_issue_id(entry.entry_id),
|
||
is_fixable=False,
|
||
severity=ir.IssueSeverity.WARNING,
|
||
translation_key="broken_part_link",
|
||
translation_placeholders={
|
||
"object_name": object_name,
|
||
"count": str(len(part_ids)),
|
||
},
|
||
)
|
||
_LOGGER.warning(
|
||
"Completion on %s referenced %d spare part(s) that no longer exist: %s",
|
||
object_name,
|
||
len(part_ids),
|
||
", ".join(part_ids),
|
||
)
|