inventory.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471
  1. import json
  2. import logging
  3. import httpx
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from fastapi.responses import StreamingResponse
  6. from pydantic import BaseModel
  7. from sqlalchemy import func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
  11. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  12. from backend.app.core.database import get_db
  13. from backend.app.core.permissions import Permission
  14. from backend.app.core.websocket import ws_manager
  15. from backend.app.models.ams_label import AmsLabel
  16. from backend.app.models.color_catalog import ColorCatalogEntry
  17. from backend.app.models.spool import Spool
  18. from backend.app.models.spool_assignment import SpoolAssignment
  19. from backend.app.models.spool_catalog import SpoolCatalogEntry
  20. from backend.app.models.spool_k_profile import SpoolKProfile
  21. from backend.app.models.user import User
  22. from backend.app.schemas.spool import (
  23. SpoolAssignmentCreate,
  24. SpoolAssignmentResponse,
  25. SpoolBulkCreate,
  26. SpoolCreate,
  27. SpoolKProfileBase,
  28. SpoolKProfileResponse,
  29. SpoolResponse,
  30. SpoolUpdate,
  31. )
  32. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  33. from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
  34. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  35. logger = logging.getLogger(__name__)
  36. router = APIRouter(prefix="/inventory", tags=["inventory"])
  37. # Material temperature defaults (nozzle min/max)
  38. MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
  39. "PLA": (190, 230),
  40. "PETG": (220, 260),
  41. "ABS": (240, 270),
  42. "ASA": (240, 270),
  43. "TPU": (200, 240),
  44. "PA": (260, 290),
  45. "PC": (250, 280),
  46. "PVA": (190, 210),
  47. "PLA-CF": (210, 240),
  48. "PETG-CF": (240, 270),
  49. "PA-CF": (270, 300),
  50. }
  51. # FilamentColors.xyz API
  52. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  53. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  54. class CatalogEntryResponse(BaseModel):
  55. id: int
  56. name: str
  57. weight: int
  58. is_default: bool
  59. class Config:
  60. from_attributes = True
  61. class CatalogEntryCreate(BaseModel):
  62. name: str
  63. weight: int
  64. class CatalogEntryUpdate(BaseModel):
  65. name: str
  66. weight: int
  67. class BulkDeleteIdsRequest(BaseModel):
  68. ids: list[int]
  69. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  70. class ColorEntryResponse(BaseModel):
  71. id: int
  72. manufacturer: str
  73. color_name: str
  74. hex_color: str
  75. material: str | None
  76. is_default: bool
  77. class Config:
  78. from_attributes = True
  79. class ColorEntryCreate(BaseModel):
  80. manufacturer: str
  81. color_name: str
  82. hex_color: str
  83. material: str | None = None
  84. class ColorEntryUpdate(BaseModel):
  85. manufacturer: str
  86. color_name: str
  87. hex_color: str
  88. material: str | None = None
  89. class ColorLookupResult(BaseModel):
  90. found: bool
  91. hex_color: str | None = None
  92. material: str | None = None
  93. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  94. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  95. async def get_spool_catalog(
  96. db: AsyncSession = Depends(get_db),
  97. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  98. ):
  99. """Get all spool catalog entries."""
  100. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  101. return list(result.scalars().all())
  102. @router.post("/catalog", response_model=CatalogEntryResponse)
  103. async def add_catalog_entry(
  104. entry: CatalogEntryCreate,
  105. db: AsyncSession = Depends(get_db),
  106. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  107. ):
  108. """Add a new spool catalog entry."""
  109. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  110. db.add(row)
  111. await db.commit()
  112. await db.refresh(row)
  113. return row
  114. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  115. async def update_catalog_entry(
  116. entry_id: int,
  117. entry: CatalogEntryUpdate,
  118. db: AsyncSession = Depends(get_db),
  119. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  120. ):
  121. """Update a spool catalog entry."""
  122. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  123. row = result.scalar_one_or_none()
  124. if not row:
  125. raise HTTPException(404, "Entry not found")
  126. row.name = entry.name
  127. row.weight = entry.weight
  128. await db.commit()
  129. await db.refresh(row)
  130. return row
  131. @router.delete("/catalog/{entry_id}")
  132. async def delete_catalog_entry(
  133. entry_id: int,
  134. db: AsyncSession = Depends(get_db),
  135. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  136. ):
  137. """Delete a spool catalog entry."""
  138. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  139. row = result.scalar_one_or_none()
  140. if not row:
  141. raise HTTPException(404, "Entry not found")
  142. await db.delete(row)
  143. await db.commit()
  144. return {"status": "deleted"}
  145. @router.post("/catalog/bulk-delete")
  146. async def bulk_delete_catalog_entries(
  147. data: BulkDeleteIdsRequest,
  148. db: AsyncSession = Depends(get_db),
  149. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  150. ):
  151. """Delete multiple spool catalog entries by ID."""
  152. if not data.ids:
  153. return {"deleted": 0}
  154. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  155. rows = result.scalars().all()
  156. for row in rows:
  157. await db.delete(row)
  158. await db.commit()
  159. return {"deleted": len(rows)}
  160. @router.post("/catalog/reset")
  161. async def reset_spool_catalog(
  162. db: AsyncSession = Depends(get_db),
  163. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  164. ):
  165. """Reset spool catalog to defaults."""
  166. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  167. # Delete all
  168. result = await db.execute(select(SpoolCatalogEntry))
  169. for row in result.scalars().all():
  170. await db.delete(row)
  171. # Re-seed defaults
  172. for name, weight in DEFAULT_SPOOL_CATALOG:
  173. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  174. await db.commit()
  175. return {"status": "reset"}
  176. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  177. @router.get("/colors", response_model=list[ColorEntryResponse])
  178. async def get_color_catalog(
  179. db: AsyncSession = Depends(get_db),
  180. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  181. ):
  182. """Get all color catalog entries."""
  183. result = await db.execute(
  184. select(ColorCatalogEntry).order_by(
  185. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  186. )
  187. )
  188. return list(result.scalars().all())
  189. @router.get("/colors/map")
  190. async def get_color_name_map(
  191. db: AsyncSession = Depends(get_db),
  192. _: User | None = Depends(require_auth_if_enabled),
  193. ):
  194. """Compact {hex: name} map for frontend color-name resolution.
  195. Not gated on INVENTORY_READ — every page that renders a spool color needs
  196. this, including read-only views available to users without inventory access.
  197. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  198. share the same hex (different materials or manufacturers), Bambu Lab wins,
  199. then default entries, then the first encountered.
  200. """
  201. result = await db.execute(
  202. select(
  203. ColorCatalogEntry.hex_color,
  204. ColorCatalogEntry.color_name,
  205. ColorCatalogEntry.manufacturer,
  206. ColorCatalogEntry.is_default,
  207. )
  208. )
  209. mapping: dict[str, tuple[str, int]] = {} # hex → (name, priority); higher priority wins
  210. for hex_color, color_name, manufacturer, is_default in result.all():
  211. if not hex_color or not color_name:
  212. continue
  213. key = hex_color.lstrip("#").lower()[:6]
  214. if len(key) != 6:
  215. continue
  216. priority = 0
  217. if manufacturer and manufacturer.strip().lower() == "bambu lab":
  218. priority += 2
  219. if is_default:
  220. priority += 1
  221. existing = mapping.get(key)
  222. if existing is None or priority > existing[1]:
  223. mapping[key] = (color_name, priority)
  224. return {"colors": {k: v[0] for k, v in mapping.items()}}
  225. @router.post("/colors", response_model=ColorEntryResponse)
  226. async def add_color_entry(
  227. entry: ColorEntryCreate,
  228. db: AsyncSession = Depends(get_db),
  229. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  230. ):
  231. """Add a new color catalog entry."""
  232. row = ColorCatalogEntry(
  233. manufacturer=entry.manufacturer,
  234. color_name=entry.color_name,
  235. hex_color=entry.hex_color,
  236. material=entry.material,
  237. is_default=False,
  238. )
  239. db.add(row)
  240. await db.commit()
  241. await db.refresh(row)
  242. return row
  243. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  244. async def update_color_entry(
  245. entry_id: int,
  246. entry: ColorEntryUpdate,
  247. db: AsyncSession = Depends(get_db),
  248. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  249. ):
  250. """Update a color catalog entry."""
  251. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  252. row = result.scalar_one_or_none()
  253. if not row:
  254. raise HTTPException(404, "Entry not found")
  255. row.manufacturer = entry.manufacturer
  256. row.color_name = entry.color_name
  257. row.hex_color = entry.hex_color
  258. row.material = entry.material
  259. await db.commit()
  260. await db.refresh(row)
  261. return row
  262. @router.delete("/colors/{entry_id}")
  263. async def delete_color_entry(
  264. entry_id: int,
  265. db: AsyncSession = Depends(get_db),
  266. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  267. ):
  268. """Delete a color catalog entry."""
  269. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  270. row = result.scalar_one_or_none()
  271. if not row:
  272. raise HTTPException(404, "Entry not found")
  273. await db.delete(row)
  274. await db.commit()
  275. return {"status": "deleted"}
  276. @router.post("/colors/bulk-delete")
  277. async def bulk_delete_color_entries(
  278. data: BulkDeleteIdsRequest,
  279. db: AsyncSession = Depends(get_db),
  280. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  281. ):
  282. """Delete multiple color catalog entries by ID."""
  283. if not data.ids:
  284. return {"deleted": 0}
  285. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  286. rows = result.scalars().all()
  287. for row in rows:
  288. await db.delete(row)
  289. await db.commit()
  290. return {"deleted": len(rows)}
  291. @router.post("/colors/reset")
  292. async def reset_color_catalog(
  293. db: AsyncSession = Depends(get_db),
  294. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  295. ):
  296. """Reset color catalog to defaults."""
  297. result = await db.execute(select(ColorCatalogEntry))
  298. for row in result.scalars().all():
  299. await db.delete(row)
  300. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  301. db.add(
  302. ColorCatalogEntry(
  303. manufacturer=manufacturer,
  304. color_name=color_name,
  305. hex_color=hex_color,
  306. material=material,
  307. is_default=True,
  308. )
  309. )
  310. await db.commit()
  311. return {"status": "reset"}
  312. @router.get("/colors/lookup", response_model=ColorLookupResult)
  313. async def lookup_color(
  314. manufacturer: str,
  315. color_name: str,
  316. material: str | None = None,
  317. db: AsyncSession = Depends(get_db),
  318. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  319. ):
  320. """Look up a color by manufacturer and color name."""
  321. query = select(ColorCatalogEntry).where(
  322. ColorCatalogEntry.manufacturer == manufacturer,
  323. ColorCatalogEntry.color_name == color_name,
  324. )
  325. if material:
  326. query = query.where(ColorCatalogEntry.material == material)
  327. query = query.limit(1)
  328. result = await db.execute(query)
  329. row = result.scalar_one_or_none()
  330. if row:
  331. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  332. return ColorLookupResult(found=False)
  333. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  334. async def search_colors(
  335. manufacturer: str | None = None,
  336. material: str | None = None,
  337. db: AsyncSession = Depends(get_db),
  338. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  339. ):
  340. """Search colors by manufacturer and/or material."""
  341. query = select(ColorCatalogEntry)
  342. if manufacturer:
  343. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  344. if material:
  345. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  346. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  347. result = await db.execute(query)
  348. return list(result.scalars().all())
  349. @router.post("/colors/sync")
  350. async def sync_from_filamentcolors(
  351. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  352. ):
  353. """Sync colors from FilamentColors.xyz API with progress streaming."""
  354. async def generate():
  355. from backend.app.core.database import async_session
  356. added = 0
  357. skipped = 0
  358. total_fetched = 0
  359. total_available = 0
  360. try:
  361. async with httpx.AsyncClient(timeout=120.0) as client:
  362. page = 1
  363. while True:
  364. response = await client.get(
  365. f"{FILAMENT_COLORS_API}/swatch/",
  366. params={"page": page},
  367. )
  368. response.raise_for_status()
  369. data = response.json()
  370. total_available = data.get("count", total_available)
  371. results = data.get("results", [])
  372. if not results:
  373. break
  374. async with async_session() as db:
  375. for swatch in results:
  376. total_fetched += 1
  377. manufacturer_data = swatch.get("manufacturer")
  378. manufacturer_name = (
  379. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  380. )
  381. filament_type_data = swatch.get("filament_type")
  382. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  383. color_name_val = swatch.get("color_name", "")
  384. hex_color_val = swatch.get("hex_color", "")
  385. if not manufacturer_name or not color_name_val or not hex_color_val:
  386. skipped += 1
  387. continue
  388. if not hex_color_val.startswith("#"):
  389. hex_color_val = f"#{hex_color_val}"
  390. # Check if entry already exists
  391. existing = await db.execute(
  392. select(ColorCatalogEntry)
  393. .where(
  394. ColorCatalogEntry.manufacturer == manufacturer_name,
  395. ColorCatalogEntry.color_name == color_name_val,
  396. ColorCatalogEntry.material == mat,
  397. )
  398. .limit(1)
  399. )
  400. if existing.scalar_one_or_none():
  401. skipped += 1
  402. else:
  403. db.add(
  404. ColorCatalogEntry(
  405. manufacturer=manufacturer_name,
  406. color_name=color_name_val,
  407. hex_color=hex_color_val.upper(),
  408. material=mat,
  409. is_default=False,
  410. )
  411. )
  412. added += 1
  413. await db.commit()
  414. progress = {
  415. "type": "progress",
  416. "added": added,
  417. "skipped": skipped,
  418. "total_fetched": total_fetched,
  419. "total_available": total_available,
  420. }
  421. yield f"data: {json.dumps(progress)}\n\n"
  422. if not data.get("next") or total_fetched >= total_available:
  423. break
  424. page += 1
  425. result = {
  426. "type": "complete",
  427. "added": added,
  428. "skipped": skipped,
  429. "total_fetched": total_fetched,
  430. "total_available": total_available,
  431. }
  432. yield f"data: {json.dumps(result)}\n\n"
  433. except httpx.HTTPError as e:
  434. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  435. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  436. except Exception as e:
  437. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  438. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  439. return StreamingResponse(generate(), media_type="text/event-stream")
  440. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  441. @router.get("/spools", response_model=list[SpoolResponse])
  442. async def list_spools(
  443. include_archived: bool = False,
  444. db: AsyncSession = Depends(get_db),
  445. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  446. ):
  447. """List all spools, excluding archived by default."""
  448. query = select(Spool).options(selectinload(Spool.k_profiles))
  449. if not include_archived:
  450. query = query.where(Spool.archived_at.is_(None))
  451. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  452. result = await db.execute(query)
  453. return list(result.scalars().all())
  454. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  455. async def get_spool(
  456. spool_id: int,
  457. db: AsyncSession = Depends(get_db),
  458. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  459. ):
  460. """Get a single spool with k_profiles."""
  461. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  462. spool = result.scalar_one_or_none()
  463. if not spool:
  464. raise HTTPException(404, "Spool not found")
  465. return spool
  466. @router.post("/spools", response_model=SpoolResponse)
  467. async def create_spool(
  468. spool_data: SpoolCreate,
  469. db: AsyncSession = Depends(get_db),
  470. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  471. ):
  472. """Create a new spool."""
  473. spool = Spool(**spool_data.model_dump())
  474. db.add(spool)
  475. await db.commit()
  476. await db.refresh(spool)
  477. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  478. await ws_manager.broadcast({"type": "inventory_changed"})
  479. return result.scalar_one()
  480. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  481. async def bulk_create_spools(
  482. data: SpoolBulkCreate,
  483. db: AsyncSession = Depends(get_db),
  484. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  485. ):
  486. """Create multiple identical spools."""
  487. spools = []
  488. for _ in range(data.quantity):
  489. spool = Spool(**data.spool.model_dump())
  490. db.add(spool)
  491. spools.append(spool)
  492. await db.commit()
  493. ids = [s.id for s in spools]
  494. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
  495. await ws_manager.broadcast({"type": "inventory_changed"})
  496. return list(result.scalars().all())
  497. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  498. async def update_spool(
  499. spool_id: int,
  500. spool_data: SpoolUpdate,
  501. db: AsyncSession = Depends(get_db),
  502. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  503. ):
  504. """Update a spool."""
  505. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  506. spool = result.scalar_one_or_none()
  507. if not spool:
  508. raise HTTPException(404, "Spool not found")
  509. update_data = spool_data.model_dump(exclude_unset=True)
  510. # Auto-lock weight when user explicitly sets weight_used
  511. if "weight_used" in update_data and "weight_locked" not in update_data:
  512. update_data["weight_locked"] = True
  513. for field, value in update_data.items():
  514. setattr(spool, field, value)
  515. await db.commit()
  516. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  517. await ws_manager.broadcast({"type": "inventory_changed"})
  518. return result.scalar_one()
  519. @router.delete("/spools/{spool_id}")
  520. async def delete_spool(
  521. spool_id: int,
  522. db: AsyncSession = Depends(get_db),
  523. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  524. ):
  525. """Hard delete a spool."""
  526. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  527. spool = result.scalar_one_or_none()
  528. if not spool:
  529. raise HTTPException(404, "Spool not found")
  530. await db.delete(spool)
  531. await db.commit()
  532. await ws_manager.broadcast({"type": "inventory_changed"})
  533. return {"status": "deleted"}
  534. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  535. async def archive_spool(
  536. spool_id: int,
  537. db: AsyncSession = Depends(get_db),
  538. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  539. ):
  540. """Soft-delete a spool by setting archived_at."""
  541. from datetime import datetime, timezone
  542. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  543. spool = result.scalar_one_or_none()
  544. if not spool:
  545. raise HTTPException(404, "Spool not found")
  546. spool.archived_at = datetime.now(timezone.utc)
  547. await db.commit()
  548. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  549. await ws_manager.broadcast({"type": "inventory_changed"})
  550. return result.scalar_one()
  551. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  552. async def restore_spool(
  553. spool_id: int,
  554. db: AsyncSession = Depends(get_db),
  555. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  556. ):
  557. """Restore an archived spool."""
  558. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  559. spool = result.scalar_one_or_none()
  560. if not spool:
  561. raise HTTPException(404, "Spool not found")
  562. spool.archived_at = None
  563. await db.commit()
  564. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  565. await ws_manager.broadcast({"type": "inventory_changed"})
  566. return result.scalar_one()
  567. # ── K-Profiles ───────────────────────────────────────────────────────────────
  568. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  569. async def list_k_profiles(
  570. spool_id: int,
  571. db: AsyncSession = Depends(get_db),
  572. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  573. ):
  574. """List K-profiles for a spool."""
  575. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  576. return list(result.scalars().all())
  577. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  578. async def replace_k_profiles(
  579. spool_id: int,
  580. profiles: list[SpoolKProfileBase],
  581. db: AsyncSession = Depends(get_db),
  582. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  583. ):
  584. """Replace all K-profiles for a spool (batch save)."""
  585. # Verify spool exists
  586. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  587. if not result.scalar_one_or_none():
  588. raise HTTPException(404, "Spool not found")
  589. # Delete existing
  590. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  591. for old in existing.scalars().all():
  592. await db.delete(old)
  593. # Create new
  594. new_profiles = []
  595. for p in profiles:
  596. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  597. db.add(kp)
  598. new_profiles.append(kp)
  599. await db.commit()
  600. for kp in new_profiles:
  601. await db.refresh(kp)
  602. return new_profiles
  603. # ── Spool Assignments ────────────────────────────────────────────────────────
  604. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  605. async def list_assignments(
  606. printer_id: int | None = None,
  607. db: AsyncSession = Depends(get_db),
  608. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  609. ):
  610. """List spool assignments, optionally filtered by printer."""
  611. from backend.app.services.printer_manager import printer_manager
  612. query = select(SpoolAssignment).options(
  613. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  614. selectinload(SpoolAssignment.printer),
  615. )
  616. if printer_id is not None:
  617. query = query.where(SpoolAssignment.printer_id == printer_id)
  618. result = await db.execute(query)
  619. assignments = list(result.scalars().all())
  620. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  621. # Fetch all statuses in one call rather than one get_status() call per printer.
  622. serial_map: dict[tuple[int, int], str] = {}
  623. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  624. all_statuses = printer_manager.get_all_statuses()
  625. for pid in seen_printer_ids:
  626. state = all_statuses.get(pid)
  627. if state and state.raw_data:
  628. for ams_unit in state.raw_data.get("ams", []):
  629. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  630. if sn:
  631. try:
  632. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  633. except (ValueError, TypeError):
  634. continue
  635. # Fetch all relevant AMS labels keyed by serial number
  636. all_serials = set(serial_map.values())
  637. # Also include synthetic fallback keys for assignments without a known serial
  638. synthetic_keys: dict[str, tuple[int, int]] = {}
  639. for a in assignments:
  640. if (a.printer_id, a.ams_id) not in serial_map:
  641. synthetic = f"p{a.printer_id}a{a.ams_id}"
  642. synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
  643. all_serials.add(synthetic)
  644. label_by_serial: dict[str, str] = {}
  645. if all_serials:
  646. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  647. for lbl in lbl_result.scalars().all():
  648. label_by_serial[lbl.ams_serial_number] = lbl.label
  649. # Build response objects, attaching ams_label where available
  650. responses: list[SpoolAssignmentResponse] = []
  651. for a in assignments:
  652. resp = SpoolAssignmentResponse.model_validate(a)
  653. sn = serial_map.get((a.printer_id, a.ams_id))
  654. if sn and sn in label_by_serial:
  655. resp.ams_label = label_by_serial[sn]
  656. elif not sn:
  657. synthetic = f"p{a.printer_id}a{a.ams_id}"
  658. resp.ams_label = label_by_serial.get(synthetic)
  659. responses.append(resp)
  660. return responses
  661. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  662. async def assign_spool(
  663. data: SpoolAssignmentCreate,
  664. db: AsyncSession = Depends(get_db),
  665. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  666. ):
  667. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  668. from backend.app.services.printer_manager import printer_manager
  669. # 1. Validate spool exists and is not archived
  670. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  671. spool = result.scalar_one_or_none()
  672. if not spool:
  673. raise HTTPException(404, "Spool not found")
  674. if spool.archived_at:
  675. raise HTTPException(400, "Cannot assign an archived spool")
  676. # 2. Get current AMS tray state for fingerprint + existing filament ID
  677. fingerprint_color = None
  678. fingerprint_type = None
  679. current_tray_info_idx = ""
  680. state = printer_manager.get_status(data.printer_id)
  681. if state and state.raw_data:
  682. if data.ams_id == 255:
  683. # External slot: look up tray from vt_tray by global ID
  684. vt_tray = state.raw_data.get("vt_tray") or []
  685. ext_id = data.tray_id + 254 # 0→254, 1→255
  686. for vt in vt_tray:
  687. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  688. fingerprint_color = vt.get("tray_color", "")
  689. fingerprint_type = vt.get("tray_type", "")
  690. current_tray_info_idx = vt.get("tray_info_idx", "")
  691. break
  692. else:
  693. ams_data = state.raw_data.get("ams", {})
  694. ams_list = (
  695. ams_data.get("ams", [])
  696. if isinstance(ams_data, dict)
  697. else ams_data
  698. if isinstance(ams_data, list)
  699. else []
  700. )
  701. tray = _find_tray_in_ams_data(
  702. ams_list,
  703. data.ams_id,
  704. data.tray_id,
  705. )
  706. if tray:
  707. fingerprint_color = tray.get("tray_color", "")
  708. fingerprint_type = tray.get("tray_type", "")
  709. current_tray_info_idx = tray.get("tray_info_idx", "")
  710. # 3. Upsert assignment (replace if same printer+ams+tray)
  711. existing = await db.execute(
  712. select(SpoolAssignment).where(
  713. SpoolAssignment.printer_id == data.printer_id,
  714. SpoolAssignment.ams_id == data.ams_id,
  715. SpoolAssignment.tray_id == data.tray_id,
  716. )
  717. )
  718. old = existing.scalar_one_or_none()
  719. if old:
  720. await db.delete(old)
  721. await db.flush()
  722. assignment = SpoolAssignment(
  723. spool_id=data.spool_id,
  724. printer_id=data.printer_id,
  725. ams_id=data.ams_id,
  726. tray_id=data.tray_id,
  727. fingerprint_color=fingerprint_color,
  728. fingerprint_type=fingerprint_type,
  729. )
  730. db.add(assignment)
  731. await db.commit()
  732. await db.refresh(assignment)
  733. # 4. Auto-configure AMS slot via MQTT
  734. configured = False
  735. try:
  736. client = printer_manager.get_client(data.printer_id)
  737. if client:
  738. # Build filament setting from spool data
  739. tray_type = spool.material
  740. tray_sub_brands = (
  741. f"{spool.brand} {spool.material} {spool.subtype}".strip()
  742. if spool.brand
  743. else f"{spool.material} {spool.subtype}"
  744. if spool.subtype
  745. else spool.material
  746. )
  747. tray_color = spool.rgba or "FFFFFFFF"
  748. _GENERIC_IDS = {
  749. "PLA": "GFL99",
  750. "PETG": "GFG99",
  751. "ABS": "GFB99",
  752. "ASA": "GFB98",
  753. "PC": "GFC99",
  754. "PA": "GFN99",
  755. "NYLON": "GFN99",
  756. "TPU": "GFU99",
  757. "PVA": "GFS99",
  758. "HIPS": "GFS98",
  759. "PLA-CF": "GFL98",
  760. "PETG-CF": "GFG98",
  761. "PA-CF": "GFN98",
  762. "PETG HF": "GFG96",
  763. }
  764. _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
  765. # Resolve tray_info_idx + setting_id for the MQTT command.
  766. # Three sources in priority order:
  767. # 1. Cloud profile (if cloud connected) — resolve filament_id
  768. # from setting_id via cloud API
  769. # 2. Local profile — use generic filament ID for material
  770. # 3. Hard-coded fallback — generic Bambu filament IDs
  771. tray_info_idx = ""
  772. setting_id = ""
  773. sf = spool.slicer_filament or ""
  774. if sf:
  775. # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
  776. base_sf = sf.split("_")[0] if "_" in sf else sf
  777. if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
  778. # Cloud setting_id — need to resolve real filament_id
  779. # Use base_sf (version suffix stripped) for cloud API + MQTT
  780. setting_id = base_sf
  781. try:
  782. from backend.app.services.bambu_cloud import get_cloud_service
  783. cloud = get_cloud_service()
  784. if cloud.is_authenticated:
  785. detail = await cloud.get_setting_detail(base_sf)
  786. if detail.get("filament_id"):
  787. tray_info_idx = detail["filament_id"]
  788. logger.info(
  789. "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
  790. tray_info_idx,
  791. sf,
  792. )
  793. # Use cloud preset name for tray_sub_brands if available
  794. cloud_name = detail.get("name", "")
  795. if cloud_name:
  796. tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  797. elif detail.get("base_id"):
  798. # Derive from base_id (e.g. "GFSL05" → "GFL05")
  799. bid = detail["base_id"].split("_")[0]
  800. if bid.startswith("GFS") and len(bid) >= 5:
  801. tray_info_idx = f"GF{bid[3:]}"
  802. else:
  803. tray_info_idx = bid
  804. logger.info(
  805. "Spool assign: derived filament_id=%r from base_id=%r",
  806. tray_info_idx,
  807. detail["base_id"],
  808. )
  809. except Exception as e:
  810. logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
  811. if not tray_info_idx:
  812. # Cloud lookup failed — use normalize as fallback
  813. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  814. elif base_sf.startswith("GF"):
  815. # Official Bambu filament_id (e.g. "GFL05")
  816. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  817. logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
  818. else:
  819. # Could be a local preset ID or material type — try local DB
  820. try:
  821. local_id = int(sf)
  822. from backend.app.models.local_preset import LocalPreset as LP
  823. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  824. lp = lp_result.scalar_one_or_none()
  825. if lp:
  826. mat = (spool.material or lp.filament_type or "").upper().strip()
  827. tray_info_idx = (
  828. _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
  829. )
  830. # Use local preset name for tray_sub_brands
  831. if lp.name:
  832. tray_sub_brands = lp.name.split("@")[0].strip()
  833. logger.info(
  834. "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
  835. local_id,
  836. mat,
  837. tray_info_idx,
  838. )
  839. except (ValueError, TypeError):
  840. # Not a numeric ID — treat as material type string
  841. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  842. # Cross-check: the cloud API returns the base filament_id for
  843. # versioned setting_ids (e.g. GFSL99 → GFL99 for all PLA variants).
  844. # If the spool has a specific preset name (e.g. "Generic PLA Silk"),
  845. # reverse-lookup the correct filament_id from the built-in table.
  846. if tray_info_idx and spool.slicer_filament_name:
  847. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  848. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  849. if expected_name and expected_name != spool.slicer_filament_name:
  850. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  851. if fname == spool.slicer_filament_name:
  852. logger.info(
  853. "Spool assign: corrected filament_id %r→%r (name=%r)",
  854. tray_info_idx,
  855. fid,
  856. spool.slicer_filament_name,
  857. )
  858. tray_info_idx = fid
  859. setting_id = filament_id_to_setting_id(fid)
  860. break
  861. if not tray_info_idx:
  862. # Fallback: reuse slot's existing tray_info_idx or generic ID
  863. if (
  864. current_tray_info_idx
  865. and current_tray_info_idx not in _GENERIC_ID_VALUES
  866. and fingerprint_type
  867. and fingerprint_type.upper() == tray_type.upper()
  868. ):
  869. logger.info(
  870. "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
  871. current_tray_info_idx,
  872. tray_type,
  873. )
  874. tray_info_idx = current_tray_info_idx
  875. elif tray_type:
  876. material = tray_type.upper().strip()
  877. generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
  878. if generic:
  879. logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
  880. tray_info_idx = generic
  881. # Temperature: use spool overrides if set, else material defaults
  882. temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
  883. if spool.nozzle_temp_min is not None:
  884. temp_min = spool.nozzle_temp_min
  885. if spool.nozzle_temp_max is not None:
  886. temp_max = spool.nozzle_temp_max
  887. # a. Set filament setting
  888. client.ams_set_filament_setting(
  889. ams_id=data.ams_id,
  890. tray_id=data.tray_id,
  891. tray_info_idx=tray_info_idx,
  892. tray_type=tray_type,
  893. tray_sub_brands=tray_sub_brands,
  894. tray_color=tray_color,
  895. nozzle_temp_min=temp_min,
  896. nozzle_temp_max=temp_max,
  897. setting_id=setting_id,
  898. )
  899. # b. Look up K-profile for this spool + printer + nozzle + extruder
  900. nozzle_diameter = "0.4"
  901. if state and state.nozzles:
  902. nd = state.nozzles[0].nozzle_diameter
  903. if nd:
  904. nozzle_diameter = nd
  905. # Determine slot's extruder from ams_extruder_map
  906. slot_extruder = None
  907. if state and state.ams_extruder_map:
  908. if data.ams_id == 255:
  909. # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  910. slot_extruder = 1 - data.tray_id # 0→1, 1→0
  911. else:
  912. slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
  913. matching_kp = None
  914. for kp in spool.k_profiles:
  915. if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
  916. if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
  917. continue
  918. matching_kp = kp
  919. break
  920. if matching_kp and matching_kp.cali_idx is not None:
  921. client.extrusion_cali_sel(
  922. ams_id=data.ams_id,
  923. tray_id=data.tray_id,
  924. cali_idx=matching_kp.cali_idx,
  925. filament_id=tray_info_idx,
  926. nozzle_diameter=nozzle_diameter,
  927. )
  928. configured = True
  929. logger.info(
  930. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  931. data.ams_id,
  932. data.tray_id,
  933. spool.id,
  934. data.printer_id,
  935. )
  936. # Save slot preset mapping so the UI shows the correct preset name.
  937. # Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.
  938. try:
  939. from backend.app.models.slot_preset import SlotPresetMapping
  940. preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
  941. preset_source = "cloud"
  942. if sf:
  943. base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
  944. try:
  945. local_id = int(base_sf_mapping)
  946. preset_id_to_save = f"local_{local_id}"
  947. preset_source = "local"
  948. except (ValueError, TypeError):
  949. # Cloud or builtin preset — convert filament_id to setting_id
  950. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
  951. else:
  952. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
  953. if preset_id_to_save:
  954. existing_mapping = await db.execute(
  955. select(SlotPresetMapping).where(
  956. SlotPresetMapping.printer_id == data.printer_id,
  957. SlotPresetMapping.ams_id == data.ams_id,
  958. SlotPresetMapping.tray_id == data.tray_id,
  959. )
  960. )
  961. mapping = existing_mapping.scalar_one_or_none()
  962. if mapping:
  963. mapping.preset_id = preset_id_to_save
  964. mapping.preset_name = preset_name
  965. mapping.preset_source = preset_source
  966. else:
  967. mapping = SlotPresetMapping(
  968. printer_id=data.printer_id,
  969. ams_id=data.ams_id,
  970. tray_id=data.tray_id,
  971. preset_id=preset_id_to_save,
  972. preset_name=preset_name,
  973. preset_source=preset_source,
  974. )
  975. db.add(mapping)
  976. await db.commit()
  977. logger.info(
  978. "Saved slot preset mapping: preset_id=%r, preset_name=%r",
  979. preset_id_to_save,
  980. preset_name,
  981. )
  982. except Exception as e:
  983. logger.warning("Failed to save slot preset mapping: %s", e)
  984. except Exception as e:
  985. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  986. # Return assignment with spool data
  987. result = await db.execute(
  988. select(SpoolAssignment)
  989. .options(
  990. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  991. selectinload(SpoolAssignment.printer),
  992. )
  993. .where(SpoolAssignment.id == assignment.id)
  994. )
  995. resp = result.scalar_one()
  996. response = SpoolAssignmentResponse.model_validate(resp)
  997. response.configured = configured
  998. await ws_manager.broadcast(
  999. {
  1000. "type": "spool_assignment_changed",
  1001. "printer_id": data.printer_id,
  1002. "ams_id": data.ams_id,
  1003. "tray_id": data.tray_id,
  1004. }
  1005. )
  1006. return response
  1007. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  1008. async def unassign_spool(
  1009. printer_id: int,
  1010. ams_id: int,
  1011. tray_id: int,
  1012. db: AsyncSession = Depends(get_db),
  1013. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1014. ):
  1015. """Unassign a spool from an AMS slot."""
  1016. result = await db.execute(
  1017. select(SpoolAssignment).where(
  1018. SpoolAssignment.printer_id == printer_id,
  1019. SpoolAssignment.ams_id == ams_id,
  1020. SpoolAssignment.tray_id == tray_id,
  1021. )
  1022. )
  1023. assignment = result.scalar_one_or_none()
  1024. if not assignment:
  1025. raise HTTPException(404, "Assignment not found")
  1026. await db.delete(assignment)
  1027. await db.commit()
  1028. await ws_manager.broadcast(
  1029. {
  1030. "type": "spool_assignment_changed",
  1031. "printer_id": printer_id,
  1032. "ams_id": ams_id,
  1033. "tray_id": tray_id,
  1034. }
  1035. )
  1036. return {"status": "deleted"}
  1037. # ── Tag Linking ───────────────────────────────────────────────────────────────
  1038. class LinkTagRequest(BaseModel):
  1039. tag_uid: str | None = None
  1040. tray_uuid: str | None = None
  1041. tag_type: str | None = None
  1042. data_origin: str | None = "nfc_link"
  1043. def _validate_tag_input(
  1044. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  1045. ) -> None:
  1046. if raw_value is None:
  1047. return
  1048. raw = str(raw_value).strip()
  1049. if not raw:
  1050. return
  1051. if normalized_value is None:
  1052. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  1053. if len(normalized_value) % 2 != 0:
  1054. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  1055. if exact_len is not None and len(normalized_value) != exact_len:
  1056. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  1057. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  1058. async def link_tag_to_spool(
  1059. spool_id: int,
  1060. data: LinkTagRequest,
  1061. db: AsyncSession = Depends(get_db),
  1062. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1063. ):
  1064. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  1065. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1066. spool = result.scalar_one_or_none()
  1067. if not spool:
  1068. raise HTTPException(404, "Spool not found")
  1069. if spool.archived_at:
  1070. raise HTTPException(400, "Cannot link tag to archived spool")
  1071. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  1072. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  1073. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  1074. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  1075. # Check for conflicts: tag already linked to another active spool
  1076. if normalized_tag_uid:
  1077. conflict = await db.execute(
  1078. select(Spool).where(
  1079. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1080. Spool.id != spool_id,
  1081. Spool.archived_at.is_(None),
  1082. )
  1083. )
  1084. if conflict.scalar_one_or_none():
  1085. raise HTTPException(409, "Tag UID already linked to another active spool")
  1086. # Auto-clear from archived spools (tag recycling)
  1087. archived_with_tag = await db.execute(
  1088. select(Spool).where(
  1089. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1090. Spool.id != spool_id,
  1091. Spool.archived_at.is_not(None),
  1092. )
  1093. )
  1094. for old_spool in archived_with_tag.scalars().all():
  1095. old_spool.tag_uid = None
  1096. if normalized_tray_uuid:
  1097. conflict = await db.execute(
  1098. select(Spool).where(
  1099. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1100. Spool.id != spool_id,
  1101. Spool.archived_at.is_(None),
  1102. )
  1103. )
  1104. if conflict.scalar_one_or_none():
  1105. raise HTTPException(409, "Tray UUID already linked to another active spool")
  1106. archived_with_uuid = await db.execute(
  1107. select(Spool).where(
  1108. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1109. Spool.id != spool_id,
  1110. Spool.archived_at.is_not(None),
  1111. )
  1112. )
  1113. for old_spool in archived_with_uuid.scalars().all():
  1114. old_spool.tray_uuid = None
  1115. if data.tag_uid is not None:
  1116. spool.tag_uid = normalized_tag_uid
  1117. if data.tray_uuid is not None:
  1118. spool.tray_uuid = normalized_tray_uuid
  1119. if data.tag_type is not None:
  1120. spool.tag_type = data.tag_type
  1121. if data.data_origin is not None:
  1122. spool.data_origin = data.data_origin
  1123. await db.commit()
  1124. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1125. return result.scalar_one()
  1126. # ── Usage History ─────────────────────────────────────────────────────────────
  1127. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  1128. async def get_spool_usage_history(
  1129. spool_id: int,
  1130. limit: int = 50,
  1131. db: AsyncSession = Depends(get_db),
  1132. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1133. ):
  1134. """Get usage history for a specific spool."""
  1135. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1136. # Verify spool exists
  1137. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1138. if not spool_result.scalar_one_or_none():
  1139. raise HTTPException(404, "Spool not found")
  1140. result = await db.execute(
  1141. select(SpoolUsageHistory)
  1142. .where(SpoolUsageHistory.spool_id == spool_id)
  1143. .order_by(SpoolUsageHistory.created_at.desc())
  1144. .limit(limit)
  1145. )
  1146. return list(result.scalars().all())
  1147. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  1148. async def get_all_usage_history(
  1149. limit: int = 100,
  1150. printer_id: int | None = None,
  1151. db: AsyncSession = Depends(get_db),
  1152. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1153. ):
  1154. """Get global usage history, optionally filtered by printer."""
  1155. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1156. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  1157. if printer_id is not None:
  1158. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  1159. result = await db.execute(query)
  1160. return list(result.scalars().all())
  1161. @router.delete("/spools/{spool_id}/usage")
  1162. async def clear_spool_usage_history(
  1163. spool_id: int,
  1164. db: AsyncSession = Depends(get_db),
  1165. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1166. ):
  1167. """Clear usage history for a spool."""
  1168. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1169. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  1170. for row in result.scalars().all():
  1171. await db.delete(row)
  1172. await db.commit()
  1173. return {"status": "cleared"}
  1174. # ── AMS Weight Sync ──────────────────────────────────────────────────────────
  1175. @router.post("/sync-ams-weights")
  1176. async def sync_weights_from_ams(
  1177. db: AsyncSession = Depends(get_db),
  1178. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1179. ):
  1180. """Force-sync spool weight_used from live AMS remain% data.
  1181. Overwrites the database weight_used for every assigned spool using the
  1182. current AMS remain% from connected printers. This is a manual recovery
  1183. tool — it bypasses the normal "only increase" guard.
  1184. """
  1185. from backend.app.services.printer_manager import printer_manager
  1186. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  1187. assignments = list(result.scalars().all())
  1188. logger.info("AMS weight sync: found %d assignments", len(assignments))
  1189. synced = 0
  1190. skipped = 0
  1191. for assignment in assignments:
  1192. spool = assignment.spool
  1193. if not spool:
  1194. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  1195. skipped += 1
  1196. continue
  1197. if spool.weight_locked:
  1198. logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
  1199. skipped += 1
  1200. continue
  1201. state = printer_manager.get_status(assignment.printer_id)
  1202. if not state or not state.raw_data:
  1203. logger.info(
  1204. "AMS weight sync: printer %d not connected, skipping spool %d",
  1205. assignment.printer_id,
  1206. spool.id,
  1207. )
  1208. skipped += 1
  1209. continue
  1210. ams_raw = state.raw_data.get("ams", [])
  1211. if isinstance(ams_raw, dict):
  1212. ams_raw = ams_raw.get("ams", [])
  1213. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  1214. if not tray:
  1215. logger.info(
  1216. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1217. spool.id,
  1218. assignment.printer_id,
  1219. assignment.ams_id,
  1220. assignment.tray_id,
  1221. )
  1222. skipped += 1
  1223. continue
  1224. remain_raw = tray.get("remain")
  1225. if remain_raw is None:
  1226. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  1227. skipped += 1
  1228. continue
  1229. try:
  1230. remain_val = int(remain_raw)
  1231. except (TypeError, ValueError):
  1232. skipped += 1
  1233. continue
  1234. if remain_val < 0 or remain_val > 100:
  1235. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  1236. skipped += 1
  1237. continue
  1238. lw = spool.label_weight or 1000
  1239. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  1240. old_used = spool.weight_used or 0
  1241. if round(old_used, 1) != new_used:
  1242. logger.info(
  1243. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  1244. spool.id,
  1245. old_used,
  1246. new_used,
  1247. remain_val,
  1248. )
  1249. spool.weight_used = new_used
  1250. synced += 1
  1251. else:
  1252. skipped += 1
  1253. await db.commit()
  1254. return {"synced": synced, "skipped": skipped}
  1255. # ── Helpers ──────────────────────────────────────────────────────────────────
  1256. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  1257. """Find a specific tray in the AMS data structure."""
  1258. if not ams_data:
  1259. return None
  1260. for ams_unit in ams_data:
  1261. if int(ams_unit.get("id", -1)) != ams_id:
  1262. continue
  1263. for tray in ams_unit.get("tray", []):
  1264. if int(tray.get("id", -1)) == tray_id:
  1265. return tray
  1266. return None