108 files
This commit is contained in:
@@ -580,6 +580,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
from .parts import (
|
||||
ws_create_part,
|
||||
ws_delete_part,
|
||||
ws_parts_overview,
|
||||
ws_restock_part,
|
||||
ws_update_part,
|
||||
)
|
||||
@@ -651,6 +652,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_create_part)
|
||||
websocket_api.async_register_command(hass, ws_update_part)
|
||||
websocket_api.async_register_command(hass, ws_delete_part)
|
||||
websocket_api.async_register_command(hass, ws_parts_overview)
|
||||
websocket_api.async_register_command(hass, ws_restock_part)
|
||||
websocket_api.async_register_command(hass, ws_update_history_entry)
|
||||
websocket_api.async_register_command(hass, ws_get_templates)
|
||||
|
||||
@@ -536,6 +536,24 @@ async def ws_import_json(
|
||||
if val is not None:
|
||||
task_data[key] = val
|
||||
|
||||
# #130: history entries carry used_parts, and since they are
|
||||
# editable (stock reconciled by delta), the part ids must follow
|
||||
# the regenerated ones. Own-part ids remap via part_id_map; links
|
||||
# into another object's pool (entry_id set) are kept verbatim —
|
||||
# if that entry doesn't exist in this instance they degrade to
|
||||
# the safe recorded-only path, name preserved.
|
||||
for hist_entry in task_data.get("history") or []:
|
||||
used = hist_entry.get("used_parts")
|
||||
if not isinstance(used, list):
|
||||
continue
|
||||
for link in used:
|
||||
if (
|
||||
isinstance(link, dict)
|
||||
and not link.get("entry_id")
|
||||
and link.get("part_id") in part_id_map
|
||||
):
|
||||
link["part_id"] = part_id_map[link["part_id"]]
|
||||
|
||||
# Remap part links to the regenerated part ids; drop dangling ones.
|
||||
links = task_data.get("consumes_parts")
|
||||
if isinstance(links, list):
|
||||
|
||||
@@ -251,3 +251,66 @@ async def ws_restock_part(
|
||||
connection.send_error(msg["id"], "not_found", "Part not found")
|
||||
return
|
||||
connection.send_result(msg["id"], {"stock": new})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/parts/overview"})
|
||||
@websocket_api.async_response
|
||||
async def ws_parts_overview(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Instance-wide parts inventory (#130).
|
||||
|
||||
Every part across all objects with its owner, live stock, low state and
|
||||
every consuming task — the object's own tasks and pooled #111 links from
|
||||
other objects. Read-only; the per-object CRUD stays on part/*.
|
||||
"""
|
||||
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID
|
||||
from ..helpers.parts import part_is_low
|
||||
from . import _get_merged_tasks
|
||||
|
||||
entries = [e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id != GLOBAL_UNIQUE_ID]
|
||||
names = {e.entry_id: (e.data.get(CONF_OBJECT) or {}).get("name") or e.title for e in entries}
|
||||
|
||||
# (owner_entry_id, part_id) -> consuming task links, own AND pooled.
|
||||
consumers: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for e in entries:
|
||||
for task_id, task in _get_merged_tasks(e).items():
|
||||
for link in task.get("consumes_parts") or []:
|
||||
if not isinstance(link, dict) or not link.get("part_id"):
|
||||
continue
|
||||
owner = link.get("entry_id") or e.entry_id
|
||||
consumers.setdefault((owner, link["part_id"]), []).append(
|
||||
{
|
||||
"entry_id": e.entry_id,
|
||||
"object_name": names.get(e.entry_id),
|
||||
"task_id": task_id,
|
||||
"task_name": task.get("name"),
|
||||
"quantity": link.get("quantity", 1),
|
||||
"pooled": owner != e.entry_id,
|
||||
}
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for e in entries:
|
||||
parts = _parts_of(e)
|
||||
if not parts:
|
||||
continue
|
||||
rd = _get_runtime_data(hass, e.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
for part_id, part in parts.items():
|
||||
stock = store.get_part_stock(part_id) if store is not None else None
|
||||
rows.append(
|
||||
{
|
||||
**part,
|
||||
"part_id": part_id,
|
||||
"entry_id": e.entry_id,
|
||||
"object_name": names.get(e.entry_id),
|
||||
"stock": stock,
|
||||
"low": part_is_low(part, stock),
|
||||
"consumers": consumers.get((e.entry_id, part_id), []),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: ((r.get("name") or "").casefold(), r.get("object_name") or ""))
|
||||
connection.send_result(msg["id"], {"parts": rows, "count": len(rows)})
|
||||
|
||||
@@ -30,9 +30,10 @@ from . import (
|
||||
# browser between read and write — timestamp is more stable. If multiple
|
||||
# entries share a timestamp (rare), the first match is patched.
|
||||
#
|
||||
# Patchable fields: timestamp, notes, cost, duration, completed_by. Anything
|
||||
# else (type, trigger_value, checklist_state, feedback) is intentionally
|
||||
# read-only — those carry semantic meaning that shouldn't be silently rewritten.
|
||||
# Patchable fields: timestamp, notes, cost, duration, completed_by and — since
|
||||
# #130 — used_parts (stock reconciled by the per-part delta). Anything else
|
||||
# (type, trigger_value, checklist_state, feedback) is intentionally read-only —
|
||||
# those carry semantic meaning that shouldn't be silently rewritten.
|
||||
#
|
||||
# After the patch we recompute last_performed if the edited entry is the
|
||||
# latest type=completed/reset/skipped entry — otherwise the next_due math
|
||||
@@ -52,6 +53,19 @@ from . import (
|
||||
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), None),
|
||||
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), None),
|
||||
vol.Optional("completed_by"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
# #130: edit the entry's part consumption. The stock is reconciled by
|
||||
# the per-part DELTA against the entry's previous used_parts; None (or
|
||||
# []) clears the consumption and returns the old quantities to stock.
|
||||
vol.Optional("used_parts"): vol.Any(
|
||||
[
|
||||
{
|
||||
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("quantity"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=999)),
|
||||
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
],
|
||||
None,
|
||||
),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@@ -121,6 +135,23 @@ async def ws_update_history_entry(
|
||||
else:
|
||||
patched[field] = value
|
||||
|
||||
# #130: part consumption on the entry. The stock is adjusted by the
|
||||
# per-part delta between the stored and the submitted selection, so
|
||||
# corrections and backfills keep the shelf honest. Best-effort like the
|
||||
# live completion path — a vanished part skips its stock math.
|
||||
if "used_parts" in msg:
|
||||
from ..parts_runtime import async_apply_history_parts_edit
|
||||
from . import _get_merged_tasks
|
||||
|
||||
old_used = patched.get("used_parts") or []
|
||||
new_used = msg["used_parts"] or []
|
||||
task_data = _get_merged_tasks(entry).get(task_id) or {}
|
||||
enriched = await async_apply_history_parts_edit(hass, entry, task_data, old_used, new_used)
|
||||
if enriched:
|
||||
patched["used_parts"] = enriched
|
||||
else:
|
||||
patched.pop("used_parts", None)
|
||||
|
||||
history[target_index] = patched
|
||||
store.set_history(task_id, history)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user