198 files
This commit is contained in:
1 parent
4a64770282
commit
669d229519
198 files changed
+4891
-2815
No files matched your search
@@ -1,7 +1,8 @@
|
||||
"""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
|
||||
Owns the mutations (consume on completion, restock, manual adjust, a
|
||||
reusable part ready again) 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.
|
||||
"""
|
||||
@@ -33,6 +34,7 @@ from .helpers.global_options import global_option
|
||||
from .helpers.i18n import normalize_language
|
||||
from .helpers.parts import (
|
||||
PART_REF_FIELD,
|
||||
on_hand,
|
||||
reconcile_buy_tasks,
|
||||
stock_transition,
|
||||
)
|
||||
@@ -111,6 +113,33 @@ def _fire_transition(
|
||||
)
|
||||
|
||||
|
||||
def _owned(store: Any, part: dict[str, Any], stock: float | None = None) -> float | None:
|
||||
"""The part's on-hand count (stock, plus the waiting pile of a reusable
|
||||
part, #208) — what the low / out / restocked edges compare."""
|
||||
pid = part["id"]
|
||||
return on_hand(part, store.get_part_stock(pid) if stock is None else stock, store.get_part_restoring(pid))
|
||||
|
||||
|
||||
def _book_use(store: Any, part: dict[str, Any], qty: float) -> tuple[float | None, float | None] | None:
|
||||
"""Book ``qty`` of a part as used (positive) or given back (negative).
|
||||
|
||||
The stock goes down (up); a reusable part's used ones are not gone but
|
||||
wait to be ready again (#208), so its pile goes up (down) by as much.
|
||||
Returns the on-hand count before and after, None for an untracked part
|
||||
(catalog-only: nothing to book). The one booking behind completions,
|
||||
history edits and the battery fleet.
|
||||
"""
|
||||
pid = part["id"]
|
||||
old = store.get_part_stock(pid)
|
||||
if old is None:
|
||||
return None
|
||||
before = _owned(store, part, old)
|
||||
store.set_part_stock(pid, max(0.0, old - qty))
|
||||
if part.get("reuse"):
|
||||
store.set_part_restoring(pid, max(0.0, store.get_part_restoring(pid) + qty))
|
||||
return before, _owned(store, part)
|
||||
|
||||
|
||||
def _signal_parts_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
@@ -124,28 +153,42 @@ async def async_change_part_stock(
|
||||
*,
|
||||
delta: float | None = None,
|
||||
absolute: float | None = None,
|
||||
use: bool = False,
|
||||
) -> 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.
|
||||
|
||||
``use``: the delta is items going into service (negative) — booked like a
|
||||
completion's parts (:func:`_book_use`), so a reusable part's used ones
|
||||
wait to be ready again (#208); an untracked part is left untracked.
|
||||
"""
|
||||
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))
|
||||
if use:
|
||||
booked = _book_use(store, part, -float(delta or 0))
|
||||
if booked is None:
|
||||
return None
|
||||
old_owned, new_owned = booked
|
||||
else:
|
||||
new = max(0.0, (old or 0) + float(delta or 0))
|
||||
store.set_part_stock(part_id, new)
|
||||
old_owned = _owned(store, part)
|
||||
old = store.get_part_stock(part_id)
|
||||
if absolute is not None:
|
||||
target = max(0.0, float(absolute))
|
||||
else:
|
||||
target = max(0.0, (old or 0) + float(delta or 0))
|
||||
store.set_part_stock(part_id, target)
|
||||
new_owned = _owned(store, part)
|
||||
new: float | None = store.get_part_stock(part_id)
|
||||
# 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))
|
||||
_fire_transition(hass, entry, part, new, stock_transition(part, old_owned, new_owned))
|
||||
_signal_parts_updated(hass, entry)
|
||||
schedule_buy_task_reconcile(hass, entry)
|
||||
return new
|
||||
@@ -181,10 +224,10 @@ async def async_handle_completion_parts(
|
||||
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)
|
||||
old_owned = _owned(store, part)
|
||||
new = max(0, (store.get_part_stock(part["id"]) or 0) + qty)
|
||||
store.set_part_stock(part["id"], new)
|
||||
_fire_transition(hass, entry, part, new, stock_transition(part, old, new))
|
||||
_fire_transition(hass, entry, part, new, stock_transition(part, old_owned, _owned(store, part)))
|
||||
changed = True
|
||||
|
||||
# #99: an explicit per-completion selection REPLACES the fixed links —
|
||||
@@ -223,13 +266,10 @@ async def async_handle_completion_parts(
|
||||
# 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:
|
||||
booked = _book_use(target_store, part, float(link.get("quantity", 1) or 1))
|
||||
if booked 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))
|
||||
_fire_transition(hass, target_entry, part, target_store.get_part_stock(part["id"]), stock_transition(part, *booked))
|
||||
touched[target_entry.entry_id] = (target_entry, target_store)
|
||||
changed = True
|
||||
|
||||
@@ -330,12 +370,10 @@ def apply_history_parts_edit(
|
||||
if resolved is None:
|
||||
continue
|
||||
owner, part, store = resolved
|
||||
old_stock = store.get_part_stock(part["id"])
|
||||
if old_stock is None:
|
||||
edge = _book_use(store, part, delta)
|
||||
if edge 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))
|
||||
_fire_transition(hass, owner, part, store.get_part_stock(part["id"]), stock_transition(part, *edge))
|
||||
touched[owner.entry_id] = (owner, store)
|
||||
|
||||
# #104: a part already on the entry keeps the price it was booked at; one
|
||||
@@ -369,6 +407,47 @@ def apply_history_parts_edit(
|
||||
return enriched, list(touched.values())
|
||||
|
||||
|
||||
async def async_part_ready_again(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
part_id: str,
|
||||
quantity: float | None = None,
|
||||
) -> dict[str, float] | None:
|
||||
"""Move used items of a reusable part back to the stock (#208): the
|
||||
cells are charged, the blades sharpened. ``quantity`` defaults to every
|
||||
waiting one and is capped at them. None for an unknown, untracked or
|
||||
not reusable part. What is owned does not change, so no edge fires."""
|
||||
part = (entry.data.get(CONF_PARTS) or {}).get(part_id)
|
||||
store = _get_store(hass, entry)
|
||||
if part is None or store is None or not part.get("reuse"):
|
||||
return None
|
||||
stock = store.get_part_stock(part_id)
|
||||
if stock is None:
|
||||
return None
|
||||
waiting = store.get_part_restoring(part_id)
|
||||
moved = waiting if quantity is None else min(waiting, max(0.0, float(quantity)))
|
||||
store.set_part_restoring(part_id, waiting - moved)
|
||||
store.set_part_stock(part_id, stock + moved)
|
||||
await store.async_save()
|
||||
_signal_parts_updated(hass, entry)
|
||||
return {"moved": moved, "stock": store.get_part_stock(part_id) or 0, "restoring": store.get_part_restoring(part_id)}
|
||||
|
||||
|
||||
async def async_set_part_restoring(hass: HomeAssistant, entry: ConfigEntry, part_id: str, count: float) -> None:
|
||||
"""Set how many used ones wait (a correction, or a worn-out one thrown
|
||||
away): fires the edge event when what is owned crosses the threshold."""
|
||||
part = (entry.data.get(CONF_PARTS) or {}).get(part_id)
|
||||
store = _get_store(hass, entry)
|
||||
if part is None or store is None:
|
||||
return
|
||||
before = _owned(store, part)
|
||||
store.set_part_restoring(part_id, count)
|
||||
await store.async_save()
|
||||
_fire_transition(hass, entry, part, store.get_part_stock(part_id), stock_transition(part, before, _owned(store, part)))
|
||||
_signal_parts_updated(hass, entry)
|
||||
schedule_buy_task_reconcile(hass, entry)
|
||||
|
||||
|
||||
def schedule_buy_task_reconcile(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Run the buy-task reconcile as a background task.
|
||||
|
||||
@@ -438,9 +517,12 @@ async def _reconcile_buy_tasks_locked(hass: HomeAssistant, entry: ConfigEntry) -
|
||||
return False
|
||||
tasks: dict[str, Any] = entry.data.get(CONF_TASKS, {})
|
||||
|
||||
# A reusable part's used ones are still owned (#208): nobody buys cells
|
||||
# that are only waiting to be charged.
|
||||
owned = {pid: (_owned(store, parts[pid], stock) if pid in parts else stock) for pid, stock in store.all_part_stocks().items()}
|
||||
new_tasks, created, removed, changed = reconcile_buy_tasks(
|
||||
parts,
|
||||
store.all_part_stocks(),
|
||||
owned,
|
||||
tasks,
|
||||
object_id=entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
lang=normalize_language(hass),
|
||||
|
||||
Reference in new issue
Block a user