inventory.py 58 KB

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