Treat InvenTree as filament assignment source
This commit is contained in:
@@ -48,6 +48,7 @@ FILAMENT_PRINTER_LOCATION_MAP=B1:93,B2:94,B3:95,B4:96
|
|||||||
FILAMENT_BATCH_SOURCE=tag_uid
|
FILAMENT_BATCH_SOURCE=tag_uid
|
||||||
FILAMENT_SYNC_SPOOLS=true
|
FILAMENT_SYNC_SPOOLS=true
|
||||||
FILAMENT_SYNC_ASSIGNMENTS=true
|
FILAMENT_SYNC_ASSIGNMENTS=true
|
||||||
|
FILAMENT_UNASSIGN_MISSING_ASSIGNMENTS=true
|
||||||
FILAMENT_SYNC_LOCATIONS=true
|
FILAMENT_SYNC_LOCATIONS=true
|
||||||
FILAMENT_SYNC_USAGE=true
|
FILAMENT_SYNC_USAGE=true
|
||||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -21,7 +21,8 @@ For filament, the service can:
|
|||||||
|
|
||||||
- use InvenTree `StockItem.batch` as the spool identity;
|
- use InvenTree `StockItem.batch` as the spool identity;
|
||||||
- create/update Bambuddy spool records from InvenTree filament stock;
|
- create/update Bambuddy spool records from InvenTree filament stock;
|
||||||
- move InvenTree spool stock between storage and printer locations from Bambuddy assignments;
|
- create/remove Bambuddy assignments from InvenTree printer locations;
|
||||||
|
- move InvenTree spool stock between storage and printer locations from Bambuddy assignments when needed;
|
||||||
- subtract Bambuddy filament usage from the matching InvenTree stock item;
|
- subtract Bambuddy filament usage from the matching InvenTree stock item;
|
||||||
- store usage sync state in SQLite to prevent duplicate subtraction.
|
- store usage sync state in SQLite to prevent duplicate subtraction.
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ The service deliberately starts with `FILAMENT_DRY_RUN=true`. In dry-run mode it
|
|||||||
Filament sync has four independent parts:
|
Filament sync has four independent parts:
|
||||||
|
|
||||||
- spool catalog sync: InvenTree stock items create/update Bambuddy spools;
|
- spool catalog sync: InvenTree stock items create/update Bambuddy spools;
|
||||||
- assignment sync: InvenTree printer locations create Bambuddy spool assignments;
|
- assignment sync: InvenTree printer locations create Bambuddy spool assignments and remove assignments that no longer exist in InvenTree printer locations;
|
||||||
- location sync: Bambuddy assignments move InvenTree stock to printer locations; returning unassigned loaded spools to storage is optional;
|
- location sync: Bambuddy assignments move InvenTree stock to printer locations; returning unassigned loaded spools to storage is optional;
|
||||||
- usage sync: Bambuddy usage history subtracts grams from the matching InvenTree stock item.
|
- usage sync: Bambuddy usage history subtracts grams from the matching InvenTree stock item.
|
||||||
|
|
||||||
@@ -235,6 +236,7 @@ FILAMENT_PRINTER_LOCATION_MAP=B1:93,B2:94,B3:95,B4:96
|
|||||||
FILAMENT_BATCH_SOURCE=tag_uid
|
FILAMENT_BATCH_SOURCE=tag_uid
|
||||||
FILAMENT_SYNC_SPOOLS=true
|
FILAMENT_SYNC_SPOOLS=true
|
||||||
FILAMENT_SYNC_ASSIGNMENTS=true
|
FILAMENT_SYNC_ASSIGNMENTS=true
|
||||||
|
FILAMENT_UNASSIGN_MISSING_ASSIGNMENTS=true
|
||||||
FILAMENT_SYNC_LOCATIONS=true
|
FILAMENT_SYNC_LOCATIONS=true
|
||||||
FILAMENT_SYNC_USAGE=true
|
FILAMENT_SYNC_USAGE=true
|
||||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||||
@@ -314,6 +316,9 @@ Do not commit `.env`. It contains API tokens and is ignored by git.
|
|||||||
`FILAMENT_SYNC_ASSIGNMENTS`
|
`FILAMENT_SYNC_ASSIGNMENTS`
|
||||||
: Creates Bambuddy assignments from InvenTree stock items currently stored in printer locations.
|
: Creates Bambuddy assignments from InvenTree stock items currently stored in printer locations.
|
||||||
|
|
||||||
|
`FILAMENT_UNASSIGN_MISSING_ASSIGNMENTS`
|
||||||
|
: Removes Bambuddy assignments for managed batch codes when the matching InvenTree stock item is no longer in a printer location. This makes InvenTree the source of truth for loaded spools.
|
||||||
|
|
||||||
`FILAMENT_SYNC_LOCATIONS`
|
`FILAMENT_SYNC_LOCATIONS`
|
||||||
: Moves InvenTree stock items between storage and printer locations from Bambuddy assignments.
|
: Moves InvenTree stock items between storage and printer locations from Bambuddy assignments.
|
||||||
|
|
||||||
@@ -407,6 +412,7 @@ FILAMENT_TRACKING_ENABLED=true
|
|||||||
FILAMENT_DRY_RUN=true
|
FILAMENT_DRY_RUN=true
|
||||||
FILAMENT_SYNC_SPOOLS=true
|
FILAMENT_SYNC_SPOOLS=true
|
||||||
FILAMENT_SYNC_ASSIGNMENTS=true
|
FILAMENT_SYNC_ASSIGNMENTS=true
|
||||||
|
FILAMENT_UNASSIGN_MISSING_ASSIGNMENTS=true
|
||||||
FILAMENT_SYNC_LOCATIONS=true
|
FILAMENT_SYNC_LOCATIONS=true
|
||||||
FILAMENT_SYNC_USAGE=true
|
FILAMENT_SYNC_USAGE=true
|
||||||
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
FILAMENT_RETURN_UNASSIGNED_TO_STORAGE=false
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ class BambuddyClient:
|
|||||||
data = await self._request("POST", "/inventory/assignments", json=payload)
|
data = await self._request("POST", "/inventory/assignments", json=payload)
|
||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
async def unassign_spool(self, *, printer_id: int, ams_id: int, tray_id: int) -> dict[str, Any]:
|
||||||
|
data = await self._request("DELETE", f"/inventory/assignments/{printer_id}/{ams_id}/{tray_id}")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
async def list_usage(self, *, limit: int, printer_id: int | None = None) -> list[dict[str, Any]]:
|
async def list_usage(self, *, limit: int, printer_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
params: dict[str, Any] = {"limit": limit}
|
params: dict[str, Any] = {"limit": limit}
|
||||||
if printer_id is not None:
|
if printer_id is not None:
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class Settings(BaseSettings):
|
|||||||
filament_batch_source: str = "tag_uid"
|
filament_batch_source: str = "tag_uid"
|
||||||
filament_sync_spools: bool = True
|
filament_sync_spools: bool = True
|
||||||
filament_sync_assignments: bool = True
|
filament_sync_assignments: bool = True
|
||||||
|
filament_unassign_missing_assignments: bool = True
|
||||||
filament_sync_locations: bool = True
|
filament_sync_locations: bool = True
|
||||||
filament_sync_usage: bool = True
|
filament_sync_usage: bool = True
|
||||||
filament_return_unassigned_to_storage: bool = False
|
filament_return_unassigned_to_storage: bool = False
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class FilamentTrackingService:
|
|||||||
"printer_locations": self.settings.filament_printer_locations,
|
"printer_locations": self.settings.filament_printer_locations,
|
||||||
"printer_ids": self.settings.filament_printer_ids,
|
"printer_ids": self.settings.filament_printer_ids,
|
||||||
"batch_source": self.settings.filament_batch_source,
|
"batch_source": self.settings.filament_batch_source,
|
||||||
|
"unassign_missing_assignments": self.settings.filament_unassign_missing_assignments,
|
||||||
"return_unassigned_to_storage": self.settings.filament_return_unassigned_to_storage,
|
"return_unassigned_to_storage": self.settings.filament_return_unassigned_to_storage,
|
||||||
"assignment_default_ams_id": self.settings.filament_assignment_default_ams_id,
|
"assignment_default_ams_id": self.settings.filament_assignment_default_ams_id,
|
||||||
"assignment_start_tray_id": self.settings.filament_assignment_start_tray_id,
|
"assignment_start_tray_id": self.settings.filament_assignment_start_tray_id,
|
||||||
@@ -102,6 +103,15 @@ class FilamentTrackingService:
|
|||||||
assignments=assignments,
|
assignments=assignments,
|
||||||
printers=printers,
|
printers=printers,
|
||||||
)[:50],
|
)[:50],
|
||||||
|
"stale_assignments": self._stale_assignment_summaries(
|
||||||
|
assignments=assignments,
|
||||||
|
spools=spools,
|
||||||
|
planned=self._planned_assignments(
|
||||||
|
stock_items=stock_items,
|
||||||
|
spools_by_batch=spools_by_batch,
|
||||||
|
printer_ids=self._printer_ids_by_key(printers),
|
||||||
|
),
|
||||||
|
)[:50],
|
||||||
"pending_usage": pending_usage[:50],
|
"pending_usage": pending_usage[:50],
|
||||||
},
|
},
|
||||||
"database": self.database.counts(),
|
"database": self.database.counts(),
|
||||||
@@ -210,17 +220,62 @@ class FilamentTrackingService:
|
|||||||
spools_by_batch=spools_by_batch,
|
spools_by_batch=spools_by_batch,
|
||||||
printer_ids=printer_ids,
|
printer_ids=printer_ids,
|
||||||
)
|
)
|
||||||
|
stale_assignments = self._stale_assignment_summaries(
|
||||||
|
assignments=assignments,
|
||||||
|
spools=spools,
|
||||||
|
planned=planned,
|
||||||
|
)
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"dry_run": dry,
|
"dry_run": dry,
|
||||||
"seen": len(planned),
|
"seen": len(planned),
|
||||||
"created": 0,
|
"created": 0,
|
||||||
|
"deleted": 0,
|
||||||
"unchanged": 0,
|
"unchanged": 0,
|
||||||
"skipped": 0,
|
"skipped": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"would_create": [],
|
"would_create": [],
|
||||||
|
"would_delete": [],
|
||||||
"missing_printers": self._missing_printer_keys(printer_ids),
|
"missing_printers": self._missing_printer_keys(printer_ids),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.settings.filament_unassign_missing_assignments:
|
||||||
|
cleared_stale_spool_ids: set[int] = set()
|
||||||
|
cleared_stale_slot_keys: set[tuple[int, int, int]] = set()
|
||||||
|
for item in stale_assignments:
|
||||||
|
if dry:
|
||||||
|
result["would_delete"].append(item)
|
||||||
|
cleared_stale_spool_ids.add(int(item["spool_id"]))
|
||||||
|
cleared_stale_slot_keys.add((int(item["printer_id"]), int(item["ams_id"]), int(item["tray_id"])))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self.bambuddy.unassign_spool(
|
||||||
|
printer_id=int(item["printer_id"]),
|
||||||
|
ams_id=int(item["ams_id"]),
|
||||||
|
tray_id=int(item["tray_id"]),
|
||||||
|
)
|
||||||
|
result["deleted"] += 1
|
||||||
|
cleared_stale_spool_ids.add(int(item["spool_id"]))
|
||||||
|
cleared_stale_slot_keys.add((int(item["printer_id"]), int(item["ams_id"]), int(item["tray_id"])))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to unassign Bambuddy spool %s from printer %s",
|
||||||
|
item.get("spool_id"),
|
||||||
|
item.get("printer_id"),
|
||||||
|
)
|
||||||
|
result["failed"] += 1
|
||||||
|
result.setdefault("errors", []).append({**item, "error": str(exc)})
|
||||||
|
|
||||||
|
assigned_by_spool_id = {
|
||||||
|
spool_id: assignment
|
||||||
|
for spool_id, assignment in assigned_by_spool_id.items()
|
||||||
|
if spool_id not in cleared_stale_spool_ids
|
||||||
|
}
|
||||||
|
assigned_by_slot = {
|
||||||
|
slot_key: assignment
|
||||||
|
for slot_key, assignment in assigned_by_slot.items()
|
||||||
|
if slot_key not in cleared_stale_slot_keys
|
||||||
|
}
|
||||||
|
|
||||||
for item in planned:
|
for item in planned:
|
||||||
spool_id = int(item["spool_id"])
|
spool_id = int(item["spool_id"])
|
||||||
slot_key = (int(item["printer_id"]), int(item["ams_id"]), int(item["tray_id"]))
|
slot_key = (int(item["printer_id"]), int(item["ams_id"]), int(item["tray_id"]))
|
||||||
@@ -615,6 +670,60 @@ class FilamentTrackingService:
|
|||||||
item["already_assigned"] = int(item["spool_id"]) in existing_spool_ids
|
item["already_assigned"] = int(item["spool_id"]) in existing_spool_ids
|
||||||
return planned
|
return planned
|
||||||
|
|
||||||
|
def _stale_assignment_summaries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
assignments: list[dict[str, Any]],
|
||||||
|
spools: list[dict[str, Any]],
|
||||||
|
planned: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
planned_by_spool_id = {int(item["spool_id"]): item for item in planned if item.get("spool_id") is not None}
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for assignment in assignments:
|
||||||
|
spool_id = assignment.get("spool_id")
|
||||||
|
slot_key = self._assignment_slot_key(assignment)
|
||||||
|
if spool_id is None or slot_key is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
batch = self._batch_for_assignment(assignment, spools)
|
||||||
|
if not batch:
|
||||||
|
continue
|
||||||
|
|
||||||
|
planned_item = planned_by_spool_id.get(int(spool_id))
|
||||||
|
if not planned_item:
|
||||||
|
reason = "not_loaded_in_inventree_printer_location"
|
||||||
|
elif slot_key == (
|
||||||
|
int(planned_item["printer_id"]),
|
||||||
|
int(planned_item["ams_id"]),
|
||||||
|
int(planned_item["tray_id"]),
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
reason = "slot_differs_from_inventree"
|
||||||
|
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"assignment_id": assignment.get("id"),
|
||||||
|
"spool_id": int(spool_id),
|
||||||
|
"batch": batch,
|
||||||
|
"printer_id": int(slot_key[0]),
|
||||||
|
"printer_name": assignment.get("printer_name"),
|
||||||
|
"ams_id": int(slot_key[1]),
|
||||||
|
"tray_id": int(slot_key[2]),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
if planned_item:
|
||||||
|
item["expected"] = {
|
||||||
|
"printer_key": planned_item.get("printer_key"),
|
||||||
|
"printer_id": planned_item.get("printer_id"),
|
||||||
|
"ams_id": planned_item.get("ams_id"),
|
||||||
|
"tray_id": planned_item.get("tray_id"),
|
||||||
|
"location_id": planned_item.get("location_id"),
|
||||||
|
}
|
||||||
|
result.append(item)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def _planned_assignments(
|
def _planned_assignments(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user