inventory.py 69 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888
  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 delete, func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.core.auth import (
  11. RequireAnyPermissionIfAuthEnabled,
  12. RequirePermissionIfAuthEnabled,
  13. require_auth_if_enabled,
  14. )
  15. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.websocket import ws_manager
  19. from backend.app.models.ams_label import AmsLabel
  20. from backend.app.models.color_catalog import ColorCatalogEntry
  21. from backend.app.models.spool import Spool
  22. from backend.app.models.spool_assignment import SpoolAssignment
  23. from backend.app.models.spool_catalog import SpoolCatalogEntry
  24. from backend.app.models.spool_k_profile import SpoolKProfile
  25. from backend.app.models.user import User
  26. from backend.app.schemas.spool import (
  27. SpoolAssignmentCreate,
  28. SpoolAssignmentResponse,
  29. SpoolBulkCreate,
  30. SpoolCreate,
  31. SpoolKProfileBase,
  32. SpoolKProfileResponse,
  33. SpoolResponse,
  34. SpoolUpdate,
  35. normalize_effect_type,
  36. normalize_extra_colors,
  37. )
  38. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  39. from backend.app.utils.filament_ids import (
  40. GENERIC_FILAMENT_IDS,
  41. MATERIAL_TEMPS,
  42. filament_id_to_setting_id,
  43. normalize_slicer_filament,
  44. )
  45. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  46. logger = logging.getLogger(__name__)
  47. _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
  48. router = APIRouter(prefix="/inventory", tags=["inventory"])
  49. # FilamentColors.xyz API
  50. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  51. # Generic Bambu filament IDs by material โ€” fallback when no specific
  52. # preset is resolvable. Keep aligned with the inline table in
  53. # apply_spool_to_slot_via_mqtt below; both paths must produce the same
  54. # value for a given material.
  55. _GENERIC_FILAMENT_IDS: dict[str, str] = {
  56. "PLA": "GFL99",
  57. "PETG": "GFG99",
  58. "ABS": "GFB99",
  59. "ASA": "GFB98",
  60. "PC": "GFC99",
  61. "PA": "GFN99",
  62. "NYLON": "GFN99",
  63. "TPU": "GFU99",
  64. "PVA": "GFS99",
  65. "HIPS": "GFS98",
  66. "PLA-CF": "GFL98",
  67. "PETG-CF": "GFG98",
  68. "PA-CF": "GFN98",
  69. "PETG HF": "GFG96",
  70. }
  71. async def apply_spool_to_slot_via_mqtt(
  72. *,
  73. db: AsyncSession,
  74. current_user: User | None,
  75. spool: Spool,
  76. printer_id: int,
  77. ams_id: int,
  78. tray_id: int,
  79. current_tray_info_idx: str = "",
  80. current_tray_type: str = "",
  81. ) -> bool:
  82. """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
  83. Shared by `assign_spool` (initial assign for a loaded slot) and
  84. `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
  85. empty โ†’ loaded). Returns True when MQTT commands were published, False if
  86. no client was available or setup failed mid-way.
  87. `current_tray_info_idx` / `current_tray_type` describe the live tray state
  88. used as fallback hints when the spool's slicer_filament can't be resolved.
  89. Caller should not pass these for the empty-slot re-fire path (they'll be
  90. the freshly-loaded values, which is the intended fallback).
  91. """
  92. from backend.app.services.printer_manager import printer_manager
  93. client = printer_manager.get_client(printer_id)
  94. if client is None:
  95. return False
  96. state = printer_manager.get_status(printer_id)
  97. tray_type = spool.material
  98. tray_sub_brands = (
  99. f"{spool.brand} {spool.material} {spool.subtype}".strip()
  100. if spool.brand
  101. else f"{spool.material} {spool.subtype}"
  102. if spool.subtype
  103. else spool.material
  104. )
  105. tray_color = spool.rgba or "FFFFFFFF"
  106. _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
  107. tray_info_idx = ""
  108. setting_id = ""
  109. sf = spool.slicer_filament or ""
  110. if sf:
  111. base_sf = sf.split("_")[0] if "_" in sf else sf
  112. if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
  113. setting_id = base_sf
  114. try:
  115. from backend.app.api.routes.cloud import build_authenticated_cloud
  116. cloud = await build_authenticated_cloud(db, current_user)
  117. if cloud is not None and cloud.is_authenticated:
  118. try:
  119. detail = await cloud.get_setting_detail(base_sf)
  120. if detail.get("filament_id"):
  121. tray_info_idx = detail["filament_id"]
  122. cloud_name = detail.get("name", "")
  123. if cloud_name:
  124. tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  125. elif detail.get("base_id"):
  126. bid = detail["base_id"].split("_")[0]
  127. if bid.startswith("GFS") and len(bid) >= 5:
  128. tray_info_idx = f"GF{bid[3:]}"
  129. else:
  130. tray_info_idx = bid
  131. finally:
  132. await cloud.close()
  133. elif cloud is not None:
  134. await cloud.close()
  135. except Exception as e:
  136. logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
  137. if not tray_info_idx:
  138. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  139. elif base_sf.startswith("GF"):
  140. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  141. else:
  142. try:
  143. local_id = int(sf)
  144. from backend.app.models.local_preset import LocalPreset as LP
  145. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  146. lp = lp_result.scalar_one_or_none()
  147. if lp:
  148. mat = (spool.material or lp.filament_type or "").upper().strip()
  149. tray_info_idx = (
  150. _GENERIC_FILAMENT_IDS.get(mat)
  151. or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
  152. or ""
  153. )
  154. if lp.name:
  155. tray_sub_brands = lp.name.split("@")[0].strip()
  156. except (ValueError, TypeError):
  157. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  158. if tray_info_idx and spool.slicer_filament_name:
  159. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  160. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  161. if expected_name and expected_name != spool.slicer_filament_name:
  162. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  163. if fname == spool.slicer_filament_name:
  164. tray_info_idx = fid
  165. setting_id = filament_id_to_setting_id(fid)
  166. break
  167. if not tray_info_idx:
  168. if (
  169. current_tray_info_idx
  170. and current_tray_info_idx not in _generic_id_values
  171. and current_tray_type
  172. and current_tray_type.upper() == tray_type.upper()
  173. ):
  174. tray_info_idx = current_tray_info_idx
  175. elif tray_type:
  176. material = tray_type.upper().strip()
  177. generic = (
  178. _GENERIC_FILAMENT_IDS.get(material)
  179. or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  180. or ""
  181. )
  182. if generic:
  183. tray_info_idx = generic
  184. temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
  185. if spool.nozzle_temp_min is not None:
  186. temp_min = spool.nozzle_temp_min
  187. if spool.nozzle_temp_max is not None:
  188. temp_max = spool.nozzle_temp_max
  189. nozzle_diameter = "0.4"
  190. if state and state.nozzles:
  191. nd = state.nozzles[0].nozzle_diameter
  192. if nd:
  193. nozzle_diameter = nd
  194. slot_extruder = None
  195. if state and state.ams_extruder_map:
  196. if ams_id == 255:
  197. slot_extruder = 1 - tray_id # ext-L (tray 0) โ†’ extruder 1, ext-R (tray 1) โ†’ extruder 0
  198. else:
  199. slot_extruder = state.ams_extruder_map.get(str(ams_id))
  200. # Prefer exact extruder match, fall back to extruder-agnostic kp for the
  201. # same nozzle. Hard-skipping on mismatch silently drops valid stored
  202. # profiles when the AMS-extruder mapping has shifted.
  203. exact_kp = None
  204. fallback_kp = None
  205. for kp in spool.k_profiles:
  206. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
  207. continue
  208. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  209. exact_kp = kp
  210. break
  211. if fallback_kp is None:
  212. fallback_kp = kp
  213. matching_kp = exact_kp or fallback_kp
  214. # Resolve the printer-side calibration entry by looking up the cali_idx
  215. # in state.kprofiles. The printer keys its calibration table by
  216. # (filament_id, cali_idx) โ€” for the cali_idx to stick, the slot's
  217. # filament_id must match the kp's. PFUS-prefix cloud user presets are
  218. # rejected by the slicer in tray_info_idx; the printer-reported
  219. # filament_id is typically a P-prefix local preset which is valid.
  220. printer_kp = None
  221. if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
  222. for pkp in state.kprofiles:
  223. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  224. printer_kp = pkp
  225. break
  226. effective_tray_info_idx = tray_info_idx
  227. effective_setting_id = setting_id
  228. if printer_kp and printer_kp.filament_id:
  229. effective_tray_info_idx = printer_kp.filament_id
  230. target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
  231. matching_kp.setting_id if matching_kp else None
  232. )
  233. if target_setting_id:
  234. effective_setting_id = target_setting_id
  235. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  236. logger.info(
  237. "Spool assign: realigning tray_info_idx %r โ†’ %r, setting_id %r โ†’ %r (source=%s)",
  238. tray_info_idx,
  239. effective_tray_info_idx,
  240. setting_id,
  241. effective_setting_id,
  242. "printer" if printer_kp else "stored",
  243. )
  244. client.ams_set_filament_setting(
  245. ams_id=ams_id,
  246. tray_id=tray_id,
  247. tray_info_idx=effective_tray_info_idx,
  248. tray_type=tray_type,
  249. tray_sub_brands=tray_sub_brands,
  250. tray_color=tray_color,
  251. nozzle_temp_min=temp_min,
  252. nozzle_temp_max=temp_max,
  253. setting_id=effective_setting_id,
  254. )
  255. if matching_kp and matching_kp.cali_idx is not None:
  256. # filament_id for cali_sel must match the preset under which the kp
  257. # was registered. Priority: live printer kp > stored kp.setting_id >
  258. # spool.slicer_filament > realigned tray_info_idx.
  259. if printer_kp and printer_kp.filament_id:
  260. cali_filament_id = printer_kp.filament_id
  261. elif matching_kp.setting_id:
  262. cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
  263. else:
  264. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  265. client.extrusion_cali_sel(
  266. ams_id=ams_id,
  267. tray_id=tray_id,
  268. cali_idx=matching_kp.cali_idx,
  269. filament_id=cali_filament_id,
  270. nozzle_diameter=nozzle_diameter,
  271. )
  272. else:
  273. # No stored K-profile for this slot โ€” preserve the slot's current live
  274. # cali_idx if the printer has one. cali_idx is read from state.raw_data
  275. # using the same idiom as the route's `current_tray_info_idx` lookup.
  276. # Negative values (e.g. -1) mean "no calibration recorded" and must not
  277. # be sent.
  278. live_cali_idx: int | None = None
  279. if state and getattr(state, "raw_data", None):
  280. if ams_id == 255:
  281. for vt in state.raw_data.get("vt_tray") or []:
  282. if isinstance(vt, dict) and int(vt.get("id", 254)) == (tray_id + 254):
  283. raw = vt.get("cali_idx")
  284. if isinstance(raw, int):
  285. live_cali_idx = raw
  286. break
  287. else:
  288. ams_section = state.raw_data.get("ams", {})
  289. ams_list = (
  290. ams_section.get("ams", [])
  291. if isinstance(ams_section, dict)
  292. else ams_section
  293. if isinstance(ams_section, list)
  294. else []
  295. )
  296. tray_dict = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
  297. if tray_dict:
  298. raw = tray_dict.get("cali_idx")
  299. if isinstance(raw, int):
  300. live_cali_idx = raw
  301. if live_cali_idx is not None and live_cali_idx >= 0:
  302. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  303. client.extrusion_cali_sel(
  304. ams_id=ams_id,
  305. tray_id=tray_id,
  306. cali_idx=live_cali_idx,
  307. filament_id=cali_filament_id,
  308. nozzle_diameter=nozzle_diameter,
  309. )
  310. logger.info(
  311. "No stored K-profile for spool %d โ€” preserved live cali_idx=%d",
  312. spool.id,
  313. live_cali_idx,
  314. )
  315. # Persist slot preset mapping for UI display (preset_name on hover card).
  316. try:
  317. from backend.app.models.slot_preset import SlotPresetMapping
  318. preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
  319. preset_source = "cloud"
  320. if sf:
  321. base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
  322. try:
  323. int(base_sf_mapping)
  324. preset_id_to_save = f"local_{base_sf_mapping}"
  325. preset_source = "local"
  326. except (ValueError, TypeError):
  327. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
  328. else:
  329. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
  330. if preset_id_to_save:
  331. existing_mapping = await db.execute(
  332. select(SlotPresetMapping).where(
  333. SlotPresetMapping.printer_id == printer_id,
  334. SlotPresetMapping.ams_id == ams_id,
  335. SlotPresetMapping.tray_id == tray_id,
  336. )
  337. )
  338. mapping = existing_mapping.scalar_one_or_none()
  339. if mapping:
  340. mapping.preset_id = preset_id_to_save
  341. mapping.preset_name = preset_name
  342. mapping.preset_source = preset_source
  343. else:
  344. mapping = SlotPresetMapping(
  345. printer_id=printer_id,
  346. ams_id=ams_id,
  347. tray_id=tray_id,
  348. preset_id=preset_id_to_save,
  349. preset_name=preset_name,
  350. preset_source=preset_source,
  351. )
  352. db.add(mapping)
  353. await db.commit()
  354. except Exception as e:
  355. logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
  356. logger.info(
  357. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  358. ams_id,
  359. tray_id,
  360. spool.id,
  361. printer_id,
  362. )
  363. return True
  364. # โ”€โ”€ Spool Catalog Schemas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  365. class CatalogEntryResponse(BaseModel):
  366. id: int
  367. name: str
  368. weight: int
  369. is_default: bool
  370. class Config:
  371. from_attributes = True
  372. class CatalogEntryCreate(BaseModel):
  373. name: str
  374. weight: int
  375. class CatalogEntryUpdate(BaseModel):
  376. name: str
  377. weight: int
  378. class BulkDeleteIdsRequest(BaseModel):
  379. ids: list[int]
  380. # โ”€โ”€ Color Catalog Schemas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  381. class ColorEntryResponse(BaseModel):
  382. id: int
  383. manufacturer: str
  384. color_name: str
  385. hex_color: str
  386. material: str | None
  387. is_default: bool
  388. extra_colors: str | None = None
  389. effect_type: str | None = None
  390. class Config:
  391. from_attributes = True
  392. _HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
  393. class ColorEntryCreate(BaseModel):
  394. manufacturer: str
  395. color_name: str
  396. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  397. material: str | None = None
  398. extra_colors: str | None = None
  399. effect_type: str | None = None
  400. @field_validator("extra_colors")
  401. @classmethod
  402. def _validate_extra_colors(cls, v: str | None) -> str | None:
  403. return normalize_extra_colors(v)
  404. @field_validator("effect_type")
  405. @classmethod
  406. def _validate_effect_type(cls, v: str | None) -> str | None:
  407. return normalize_effect_type(v)
  408. class ColorEntryUpdate(BaseModel):
  409. manufacturer: str
  410. color_name: str
  411. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  412. material: str | None = None
  413. extra_colors: str | None = None
  414. effect_type: str | None = None
  415. @field_validator("extra_colors")
  416. @classmethod
  417. def _validate_extra_colors(cls, v: str | None) -> str | None:
  418. return normalize_extra_colors(v)
  419. @field_validator("effect_type")
  420. @classmethod
  421. def _validate_effect_type(cls, v: str | None) -> str | None:
  422. return normalize_effect_type(v)
  423. class ColorLookupResult(BaseModel):
  424. found: bool
  425. hex_color: str | None = None
  426. material: str | None = None
  427. # โ”€โ”€ Spool Catalog CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  428. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  429. async def get_spool_catalog(
  430. db: AsyncSession = Depends(get_db),
  431. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  432. ):
  433. """Get all spool catalog entries."""
  434. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  435. return list(result.scalars().all())
  436. @router.post("/catalog", response_model=CatalogEntryResponse)
  437. async def add_catalog_entry(
  438. entry: CatalogEntryCreate,
  439. db: AsyncSession = Depends(get_db),
  440. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  441. ):
  442. """Add a new spool catalog entry."""
  443. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  444. db.add(row)
  445. await db.commit()
  446. await db.refresh(row)
  447. return row
  448. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  449. async def update_catalog_entry(
  450. entry_id: int,
  451. entry: CatalogEntryUpdate,
  452. db: AsyncSession = Depends(get_db),
  453. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  454. ):
  455. """Update a spool catalog entry."""
  456. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  457. row = result.scalar_one_or_none()
  458. if not row:
  459. raise HTTPException(404, "Entry not found")
  460. row.name = entry.name
  461. row.weight = entry.weight
  462. await db.commit()
  463. await db.refresh(row)
  464. return row
  465. @router.delete("/catalog/{entry_id}")
  466. async def delete_catalog_entry(
  467. entry_id: int,
  468. db: AsyncSession = Depends(get_db),
  469. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  470. ):
  471. """Delete a spool catalog entry."""
  472. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  473. row = result.scalar_one_or_none()
  474. if not row:
  475. raise HTTPException(404, "Entry not found")
  476. await db.delete(row)
  477. await db.commit()
  478. return {"status": "deleted"}
  479. @router.post("/catalog/bulk-delete")
  480. async def bulk_delete_catalog_entries(
  481. data: BulkDeleteIdsRequest,
  482. db: AsyncSession = Depends(get_db),
  483. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  484. ):
  485. """Delete multiple spool catalog entries by ID."""
  486. if not data.ids:
  487. return {"deleted": 0}
  488. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  489. rows = result.scalars().all()
  490. for row in rows:
  491. await db.delete(row)
  492. await db.commit()
  493. return {"deleted": len(rows)}
  494. @router.post("/catalog/reset")
  495. async def reset_spool_catalog(
  496. db: AsyncSession = Depends(get_db),
  497. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  498. ):
  499. """Reset spool catalog to defaults."""
  500. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  501. # Delete all
  502. result = await db.execute(select(SpoolCatalogEntry))
  503. for row in result.scalars().all():
  504. await db.delete(row)
  505. # Re-seed defaults
  506. for name, weight in DEFAULT_SPOOL_CATALOG:
  507. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  508. await db.commit()
  509. return {"status": "reset"}
  510. # โ”€โ”€ Color Catalog CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  511. @router.get("/colors", response_model=list[ColorEntryResponse])
  512. async def get_color_catalog(
  513. db: AsyncSession = Depends(get_db),
  514. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  515. ):
  516. """Get all color catalog entries."""
  517. result = await db.execute(
  518. select(ColorCatalogEntry).order_by(
  519. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  520. )
  521. )
  522. return list(result.scalars().all())
  523. @router.get("/colors/map")
  524. async def get_color_name_map(
  525. db: AsyncSession = Depends(get_db),
  526. _: User | None = Depends(require_auth_if_enabled),
  527. ):
  528. """Compact {hex: name} map for frontend color-name resolution.
  529. Not gated on INVENTORY_READ โ€” every page that renders a spool color needs
  530. this, including read-only views available to users without inventory access.
  531. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  532. share the same hex (different materials or manufacturers), Bambu Lab wins,
  533. then default entries, then the first encountered.
  534. """
  535. result = await db.execute(
  536. select(
  537. ColorCatalogEntry.hex_color,
  538. ColorCatalogEntry.color_name,
  539. ColorCatalogEntry.manufacturer,
  540. ColorCatalogEntry.is_default,
  541. )
  542. )
  543. mapping: dict[str, tuple[str, int]] = {} # hex โ†’ (name, priority); higher priority wins
  544. for hex_color, color_name, manufacturer, is_default in result.all():
  545. if not hex_color or not color_name:
  546. continue
  547. key = hex_color.lstrip("#").lower()[:6]
  548. if len(key) != 6:
  549. continue
  550. priority = 0
  551. if manufacturer and manufacturer.strip().lower() == "bambu lab":
  552. priority += 2
  553. if is_default:
  554. priority += 1
  555. existing = mapping.get(key)
  556. if existing is None or priority > existing[1]:
  557. mapping[key] = (color_name, priority)
  558. return {"colors": {k: v[0] for k, v in mapping.items()}}
  559. @router.post("/colors", response_model=ColorEntryResponse)
  560. async def add_color_entry(
  561. entry: ColorEntryCreate,
  562. db: AsyncSession = Depends(get_db),
  563. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  564. ):
  565. """Add a new color catalog entry."""
  566. row = ColorCatalogEntry(
  567. manufacturer=entry.manufacturer,
  568. color_name=entry.color_name,
  569. hex_color=entry.hex_color,
  570. material=entry.material,
  571. is_default=False,
  572. extra_colors=entry.extra_colors,
  573. effect_type=entry.effect_type,
  574. )
  575. db.add(row)
  576. await db.commit()
  577. await db.refresh(row)
  578. return row
  579. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  580. async def update_color_entry(
  581. entry_id: int,
  582. entry: ColorEntryUpdate,
  583. db: AsyncSession = Depends(get_db),
  584. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  585. ):
  586. """Update a color catalog entry."""
  587. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  588. row = result.scalar_one_or_none()
  589. if not row:
  590. raise HTTPException(404, "Entry not found")
  591. row.manufacturer = entry.manufacturer
  592. row.color_name = entry.color_name
  593. row.hex_color = entry.hex_color
  594. row.material = entry.material
  595. row.extra_colors = entry.extra_colors
  596. row.effect_type = entry.effect_type
  597. await db.commit()
  598. await db.refresh(row)
  599. return row
  600. @router.delete("/colors/{entry_id}")
  601. async def delete_color_entry(
  602. entry_id: int,
  603. db: AsyncSession = Depends(get_db),
  604. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  605. ):
  606. """Delete a color catalog entry."""
  607. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  608. row = result.scalar_one_or_none()
  609. if not row:
  610. raise HTTPException(404, "Entry not found")
  611. await db.delete(row)
  612. await db.commit()
  613. return {"status": "deleted"}
  614. @router.post("/colors/bulk-delete")
  615. async def bulk_delete_color_entries(
  616. data: BulkDeleteIdsRequest,
  617. db: AsyncSession = Depends(get_db),
  618. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  619. ):
  620. """Delete multiple color catalog entries by ID."""
  621. if not data.ids:
  622. return {"deleted": 0}
  623. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  624. rows = result.scalars().all()
  625. for row in rows:
  626. await db.delete(row)
  627. await db.commit()
  628. return {"deleted": len(rows)}
  629. @router.post("/colors/reset")
  630. async def reset_color_catalog(
  631. db: AsyncSession = Depends(get_db),
  632. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  633. ):
  634. """Reset color catalog to defaults."""
  635. result = await db.execute(select(ColorCatalogEntry))
  636. for row in result.scalars().all():
  637. await db.delete(row)
  638. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  639. db.add(
  640. ColorCatalogEntry(
  641. manufacturer=manufacturer,
  642. color_name=color_name,
  643. hex_color=hex_color,
  644. material=material,
  645. is_default=True,
  646. )
  647. )
  648. await db.commit()
  649. return {"status": "reset"}
  650. @router.get("/colors/lookup", response_model=ColorLookupResult)
  651. async def lookup_color(
  652. manufacturer: str,
  653. color_name: str,
  654. material: str | None = None,
  655. db: AsyncSession = Depends(get_db),
  656. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  657. ):
  658. """Look up a color by manufacturer and color name."""
  659. query = select(ColorCatalogEntry).where(
  660. ColorCatalogEntry.manufacturer == manufacturer,
  661. ColorCatalogEntry.color_name == color_name,
  662. )
  663. if material:
  664. query = query.where(ColorCatalogEntry.material == material)
  665. query = query.limit(1)
  666. result = await db.execute(query)
  667. row = result.scalar_one_or_none()
  668. if row:
  669. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  670. return ColorLookupResult(found=False)
  671. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  672. async def search_colors(
  673. manufacturer: str | None = None,
  674. material: str | None = None,
  675. db: AsyncSession = Depends(get_db),
  676. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  677. ):
  678. """Search colors by manufacturer and/or material."""
  679. query = select(ColorCatalogEntry)
  680. if manufacturer:
  681. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  682. if material:
  683. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  684. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  685. result = await db.execute(query)
  686. return list(result.scalars().all())
  687. @router.post("/colors/sync")
  688. async def sync_from_filamentcolors(
  689. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  690. ):
  691. """Sync colors from FilamentColors.xyz API with progress streaming."""
  692. async def generate():
  693. from backend.app.core.database import async_session
  694. added = 0
  695. skipped = 0
  696. total_fetched = 0
  697. total_available = 0
  698. try:
  699. async with httpx.AsyncClient(timeout=120.0) as client:
  700. page = 1
  701. while True:
  702. response = await client.get(
  703. f"{FILAMENT_COLORS_API}/swatch/",
  704. params={"page": page},
  705. )
  706. response.raise_for_status()
  707. data = response.json()
  708. total_available = data.get("count", total_available)
  709. results = data.get("results", [])
  710. if not results:
  711. break
  712. async with async_session() as db:
  713. for swatch in results:
  714. total_fetched += 1
  715. manufacturer_data = swatch.get("manufacturer")
  716. manufacturer_name = (
  717. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  718. )
  719. filament_type_data = swatch.get("filament_type")
  720. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  721. color_name_val = swatch.get("color_name", "")
  722. hex_color_val = swatch.get("hex_color", "")
  723. if not manufacturer_name or not color_name_val or not hex_color_val:
  724. skipped += 1
  725. continue
  726. if not hex_color_val.startswith("#"):
  727. hex_color_val = f"#{hex_color_val}"
  728. # Check if entry already exists
  729. existing = await db.execute(
  730. select(ColorCatalogEntry)
  731. .where(
  732. ColorCatalogEntry.manufacturer == manufacturer_name,
  733. ColorCatalogEntry.color_name == color_name_val,
  734. ColorCatalogEntry.material == mat,
  735. )
  736. .limit(1)
  737. )
  738. if existing.scalar_one_or_none():
  739. skipped += 1
  740. else:
  741. db.add(
  742. ColorCatalogEntry(
  743. manufacturer=manufacturer_name,
  744. color_name=color_name_val,
  745. hex_color=hex_color_val.upper(),
  746. material=mat,
  747. is_default=False,
  748. )
  749. )
  750. added += 1
  751. await db.commit()
  752. progress = {
  753. "type": "progress",
  754. "added": added,
  755. "skipped": skipped,
  756. "total_fetched": total_fetched,
  757. "total_available": total_available,
  758. }
  759. yield f"data: {json.dumps(progress)}\n\n"
  760. if not data.get("next") or total_fetched >= total_available:
  761. break
  762. page += 1
  763. result = {
  764. "type": "complete",
  765. "added": added,
  766. "skipped": skipped,
  767. "total_fetched": total_fetched,
  768. "total_available": total_available,
  769. }
  770. yield f"data: {json.dumps(result)}\n\n"
  771. except httpx.HTTPError as e:
  772. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  773. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  774. except Exception as e:
  775. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  776. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  777. return StreamingResponse(generate(), media_type="text/event-stream")
  778. # โ”€โ”€ Spool CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  779. @router.get("/spools", response_model=list[SpoolResponse])
  780. async def list_spools(
  781. include_archived: bool = False,
  782. db: AsyncSession = Depends(get_db),
  783. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  784. ):
  785. """List all spools, excluding archived by default."""
  786. query = select(Spool).options(selectinload(Spool.k_profiles))
  787. if not include_archived:
  788. query = query.where(Spool.archived_at.is_(None))
  789. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  790. result = await db.execute(query)
  791. return list(result.scalars().all())
  792. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  793. async def get_spool(
  794. spool_id: int,
  795. db: AsyncSession = Depends(get_db),
  796. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  797. ):
  798. """Get a single spool with k_profiles."""
  799. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  800. spool = result.scalar_one_or_none()
  801. if not spool:
  802. raise HTTPException(404, "Spool not found")
  803. return spool
  804. @router.post("/spools", response_model=SpoolResponse)
  805. async def create_spool(
  806. spool_data: SpoolCreate,
  807. db: AsyncSession = Depends(get_db),
  808. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  809. ):
  810. """Create a new spool."""
  811. spool = Spool(**spool_data.model_dump())
  812. db.add(spool)
  813. await db.commit()
  814. await db.refresh(spool)
  815. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  816. await ws_manager.broadcast({"type": "inventory_changed"})
  817. return result.scalar_one()
  818. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  819. async def bulk_create_spools(
  820. data: SpoolBulkCreate,
  821. db: AsyncSession = Depends(get_db),
  822. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  823. ):
  824. """Create multiple identical spools."""
  825. spools = []
  826. for _ in range(data.quantity):
  827. spool = Spool(**data.spool.model_dump())
  828. db.add(spool)
  829. spools.append(spool)
  830. await db.commit()
  831. ids = [s.id for s in spools]
  832. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
  833. await ws_manager.broadcast({"type": "inventory_changed"})
  834. return list(result.scalars().all())
  835. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  836. async def update_spool(
  837. spool_id: int,
  838. spool_data: SpoolUpdate,
  839. db: AsyncSession = Depends(get_db),
  840. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  841. ):
  842. """Update a spool."""
  843. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  844. spool = result.scalar_one_or_none()
  845. if not spool:
  846. raise HTTPException(404, "Spool not found")
  847. update_data = spool_data.model_dump(exclude_unset=True)
  848. # Auto-lock weight when user explicitly sets weight_used
  849. if "weight_used" in update_data and "weight_locked" not in update_data:
  850. update_data["weight_locked"] = True
  851. for field, value in update_data.items():
  852. setattr(spool, field, value)
  853. await db.commit()
  854. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  855. await ws_manager.broadcast({"type": "inventory_changed"})
  856. return result.scalar_one()
  857. @router.delete("/spools/{spool_id}")
  858. async def delete_spool(
  859. spool_id: int,
  860. db: AsyncSession = Depends(get_db),
  861. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  862. ):
  863. """Hard delete a spool."""
  864. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  865. spool = result.scalar_one_or_none()
  866. if not spool:
  867. raise HTTPException(404, "Spool not found")
  868. await db.delete(spool)
  869. await db.commit()
  870. await ws_manager.broadcast({"type": "inventory_changed"})
  871. return {"status": "deleted"}
  872. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  873. async def archive_spool(
  874. spool_id: int,
  875. db: AsyncSession = Depends(get_db),
  876. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  877. ):
  878. """Soft-delete a spool by setting archived_at."""
  879. from datetime import datetime, timezone
  880. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  881. spool = result.scalar_one_or_none()
  882. if not spool:
  883. raise HTTPException(404, "Spool not found")
  884. spool.archived_at = datetime.now(timezone.utc)
  885. await db.commit()
  886. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  887. await ws_manager.broadcast({"type": "inventory_changed"})
  888. return result.scalar_one()
  889. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  890. async def restore_spool(
  891. spool_id: int,
  892. db: AsyncSession = Depends(get_db),
  893. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  894. ):
  895. """Restore an archived spool."""
  896. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  897. spool = result.scalar_one_or_none()
  898. if not spool:
  899. raise HTTPException(404, "Spool not found")
  900. spool.archived_at = None
  901. await db.commit()
  902. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  903. await ws_manager.broadcast({"type": "inventory_changed"})
  904. return result.scalar_one()
  905. # โ”€โ”€ K-Profiles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  906. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  907. async def list_k_profiles(
  908. spool_id: int,
  909. db: AsyncSession = Depends(get_db),
  910. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  911. ):
  912. """List K-profiles for a spool."""
  913. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  914. return list(result.scalars().all())
  915. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  916. async def replace_k_profiles(
  917. spool_id: int,
  918. profiles: list[SpoolKProfileBase],
  919. db: AsyncSession = Depends(get_db),
  920. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  921. ):
  922. """Replace all K-profiles for a spool (batch save)."""
  923. # Verify spool exists
  924. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  925. if not result.scalar_one_or_none():
  926. raise HTTPException(404, "Spool not found")
  927. # Delete existing
  928. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  929. for old in existing.scalars().all():
  930. await db.delete(old)
  931. # Create new
  932. new_profiles = []
  933. for p in profiles:
  934. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  935. db.add(kp)
  936. new_profiles.append(kp)
  937. await db.commit()
  938. for kp in new_profiles:
  939. await db.refresh(kp)
  940. return new_profiles
  941. # โ”€โ”€ Spool Assignments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  942. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  943. async def list_assignments(
  944. printer_id: int | None = None,
  945. db: AsyncSession = Depends(get_db),
  946. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  947. ):
  948. """List spool assignments, optionally filtered by printer."""
  949. from backend.app.services.printer_manager import printer_manager
  950. query = select(SpoolAssignment).options(
  951. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  952. selectinload(SpoolAssignment.printer),
  953. )
  954. if printer_id is not None:
  955. query = query.where(SpoolAssignment.printer_id == printer_id)
  956. result = await db.execute(query)
  957. assignments = list(result.scalars().all())
  958. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  959. # Fetch all statuses in one call rather than one get_status() call per printer.
  960. serial_map: dict[tuple[int, int], str] = {}
  961. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  962. all_statuses = printer_manager.get_all_statuses()
  963. for pid in seen_printer_ids:
  964. state = all_statuses.get(pid)
  965. if state and state.raw_data:
  966. for ams_unit in state.raw_data.get("ams", []):
  967. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  968. if sn:
  969. try:
  970. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  971. except (ValueError, TypeError):
  972. continue
  973. # Fetch all relevant AMS labels keyed by serial number
  974. all_serials = set(serial_map.values())
  975. # Also include synthetic fallback keys for assignments without a known serial
  976. synthetic_keys: dict[str, tuple[int, int]] = {}
  977. for a in assignments:
  978. if (a.printer_id, a.ams_id) not in serial_map:
  979. synthetic = f"p{a.printer_id}a{a.ams_id}"
  980. synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
  981. all_serials.add(synthetic)
  982. label_by_serial: dict[str, str] = {}
  983. if all_serials:
  984. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  985. for lbl in lbl_result.scalars().all():
  986. label_by_serial[lbl.ams_serial_number] = lbl.label
  987. # Build response objects, attaching ams_label where available
  988. responses: list[SpoolAssignmentResponse] = []
  989. for a in assignments:
  990. resp = SpoolAssignmentResponse.model_validate(a)
  991. sn = serial_map.get((a.printer_id, a.ams_id))
  992. if sn and sn in label_by_serial:
  993. resp.ams_label = label_by_serial[sn]
  994. elif not sn:
  995. synthetic = f"p{a.printer_id}a{a.ams_id}"
  996. resp.ams_label = label_by_serial.get(synthetic)
  997. responses.append(resp)
  998. return responses
  999. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  1000. async def assign_spool(
  1001. data: SpoolAssignmentCreate,
  1002. db: AsyncSession = Depends(get_db),
  1003. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1004. ):
  1005. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  1006. from backend.app.services.printer_manager import printer_manager
  1007. # 1. Validate spool exists and is not archived
  1008. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  1009. spool = result.scalar_one_or_none()
  1010. if not spool:
  1011. raise HTTPException(404, "Spool not found")
  1012. if spool.archived_at:
  1013. raise HTTPException(400, "Cannot assign an archived spool")
  1014. # 2. Get current AMS tray state for fingerprint + existing filament ID.
  1015. # tray_state: Bambu firmware reports 11=loaded, 9=empty, 10=spool present
  1016. # but filament not in feeder. Captured here so the empty-slot heuristic
  1017. # below can prefer it over tray_type โ€” a manual "Reset slot" clears
  1018. # tray_type to "" while leaving state at 11 (filament still physically
  1019. # present), which would otherwise mislead the heuristic into the
  1020. # pending-config branch and skip MQTT forever (#1228 follow-up).
  1021. fingerprint_color = None
  1022. fingerprint_type = None
  1023. current_tray_info_idx = ""
  1024. tray_state: int | None = None
  1025. state = printer_manager.get_status(data.printer_id)
  1026. if state and state.raw_data:
  1027. if data.ams_id == 255:
  1028. # External slot: look up tray from vt_tray by global ID
  1029. vt_tray = state.raw_data.get("vt_tray") or []
  1030. ext_id = data.tray_id + 254 # 0โ†’254, 1โ†’255
  1031. for vt in vt_tray:
  1032. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  1033. fingerprint_color = vt.get("tray_color", "")
  1034. fingerprint_type = vt.get("tray_type", "")
  1035. current_tray_info_idx = vt.get("tray_info_idx", "")
  1036. raw_state = vt.get("state")
  1037. if isinstance(raw_state, int):
  1038. tray_state = raw_state
  1039. break
  1040. else:
  1041. ams_data = state.raw_data.get("ams", {})
  1042. ams_list = (
  1043. ams_data.get("ams", [])
  1044. if isinstance(ams_data, dict)
  1045. else ams_data
  1046. if isinstance(ams_data, list)
  1047. else []
  1048. )
  1049. tray = _find_tray_in_ams_data(
  1050. ams_list,
  1051. data.ams_id,
  1052. data.tray_id,
  1053. )
  1054. if tray:
  1055. fingerprint_color = tray.get("tray_color", "")
  1056. fingerprint_type = tray.get("tray_type", "")
  1057. current_tray_info_idx = tray.get("tray_info_idx", "")
  1058. raw_state = tray.get("state")
  1059. if isinstance(raw_state, int):
  1060. tray_state = raw_state
  1061. # 3. Upsert assignment (replace if same printer+ams+tray)
  1062. existing = await db.execute(
  1063. select(SpoolAssignment).where(
  1064. SpoolAssignment.printer_id == data.printer_id,
  1065. SpoolAssignment.ams_id == data.ams_id,
  1066. SpoolAssignment.tray_id == data.tray_id,
  1067. )
  1068. )
  1069. old = existing.scalar_one_or_none()
  1070. if old:
  1071. await db.delete(old)
  1072. await db.flush()
  1073. assignment = SpoolAssignment(
  1074. spool_id=data.spool_id,
  1075. printer_id=data.printer_id,
  1076. ams_id=data.ams_id,
  1077. tray_id=data.tray_id,
  1078. fingerprint_color=fingerprint_color,
  1079. fingerprint_type=fingerprint_type,
  1080. )
  1081. db.add(assignment)
  1082. await db.commit()
  1083. await db.refresh(assignment)
  1084. # 4. Auto-configure AMS slot via MQTT.
  1085. #
  1086. # Skip the publish entirely when the target slot is empty: Bambu firmware
  1087. # silently drops ams_filament_setting / extrusion_cali_sel for unloaded
  1088. # slots (there is no filament context for the cali_idx to attach to). The
  1089. # SpoolAssignment row is preserved with an empty fingerprint_type, which
  1090. # acts as the "pending config" marker โ€” when the spool is physically
  1091. # inserted later, on_ams_change re-fires the full configuration. This is
  1092. # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
  1093. #
  1094. # Empty-detection: priority order is the firmware's explicit signals
  1095. # first, then a tray_type fallback for firmwares that don't use the full
  1096. # state enum meaningfully.
  1097. # - state == 9 โ†’ empty (firmware says "no spool")
  1098. # - state == 10 โ†’ empty (firmware says "spool present but no feed")
  1099. # - state == 11 โ†’ loaded (firmware says "filament in extruder")
  1100. # - any other state value, including 3 (A1 Mini BMCU / P1S Standard AMS
  1101. # always report 3) or None (older firmwares omit the field), falls
  1102. # back to tray_type: configured โ‡’ loaded, empty โ‡’ empty (#1322).
  1103. # The tray_type fallback for state==3 fixes the reported bug. The 9/10
  1104. # branch keeps existing behavior intact even if the MQTT relay's
  1105. # auto-clearing of tray_type ever fails to fire โ€” the firmware's
  1106. # explicit "empty" signal stays authoritative over stale metadata.
  1107. if tray_state == 9 or tray_state == 10:
  1108. slot_is_empty = True
  1109. elif tray_state == 11:
  1110. slot_is_empty = False
  1111. else:
  1112. slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
  1113. configured = False
  1114. pending_config = slot_is_empty
  1115. if not slot_is_empty:
  1116. try:
  1117. configured = await apply_spool_to_slot_via_mqtt(
  1118. db=db,
  1119. current_user=current_user,
  1120. spool=spool,
  1121. printer_id=data.printer_id,
  1122. ams_id=data.ams_id,
  1123. tray_id=data.tray_id,
  1124. current_tray_info_idx=current_tray_info_idx,
  1125. current_tray_type=fingerprint_type or "",
  1126. )
  1127. except Exception as e:
  1128. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  1129. # Return assignment with spool data
  1130. result = await db.execute(
  1131. select(SpoolAssignment)
  1132. .options(
  1133. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1134. selectinload(SpoolAssignment.printer),
  1135. )
  1136. .where(SpoolAssignment.id == assignment.id)
  1137. )
  1138. resp = result.scalar_one()
  1139. response = SpoolAssignmentResponse.model_validate(resp)
  1140. response.configured = configured
  1141. response.pending_config = pending_config
  1142. if pending_config:
  1143. logger.info(
  1144. "Pre-configured assignment: spool %d โ†’ printer %d AMS%d-T%d (slot empty, will configure on insert)",
  1145. spool.id,
  1146. data.printer_id,
  1147. data.ams_id,
  1148. data.tray_id,
  1149. )
  1150. await ws_manager.broadcast(
  1151. {
  1152. "type": "spool_assignment_changed",
  1153. "printer_id": data.printer_id,
  1154. "ams_id": data.ams_id,
  1155. "tray_id": data.tray_id,
  1156. }
  1157. )
  1158. return response
  1159. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  1160. async def unassign_spool(
  1161. printer_id: int,
  1162. ams_id: int,
  1163. tray_id: int,
  1164. db: AsyncSession = Depends(get_db),
  1165. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1166. ):
  1167. """Unassign a spool from an AMS slot."""
  1168. result = await db.execute(
  1169. select(SpoolAssignment).where(
  1170. SpoolAssignment.printer_id == printer_id,
  1171. SpoolAssignment.ams_id == ams_id,
  1172. SpoolAssignment.tray_id == tray_id,
  1173. )
  1174. )
  1175. assignment = result.scalar_one_or_none()
  1176. if not assignment:
  1177. raise HTTPException(404, "Assignment not found")
  1178. await db.delete(assignment)
  1179. await db.commit()
  1180. await ws_manager.broadcast(
  1181. {
  1182. "type": "spool_assignment_changed",
  1183. "printer_id": printer_id,
  1184. "ams_id": ams_id,
  1185. "tray_id": tray_id,
  1186. }
  1187. )
  1188. return {"status": "deleted"}
  1189. # โ”€โ”€ Tag Linking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  1190. class LinkTagRequest(BaseModel):
  1191. tag_uid: str | None = None
  1192. tray_uuid: str | None = None
  1193. tag_type: str | None = None
  1194. data_origin: str | None = "nfc_link"
  1195. def _validate_tag_input(
  1196. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  1197. ) -> None:
  1198. if raw_value is None:
  1199. return
  1200. raw = str(raw_value).strip()
  1201. if not raw:
  1202. return
  1203. if normalized_value is None:
  1204. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  1205. if len(normalized_value) % 2 != 0:
  1206. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  1207. if exact_len is not None and len(normalized_value) != exact_len:
  1208. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  1209. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  1210. async def link_tag_to_spool(
  1211. spool_id: int,
  1212. data: LinkTagRequest,
  1213. db: AsyncSession = Depends(get_db),
  1214. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1215. ):
  1216. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  1217. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1218. spool = result.scalar_one_or_none()
  1219. if not spool:
  1220. raise HTTPException(404, "Spool not found")
  1221. if spool.archived_at:
  1222. raise HTTPException(400, "Cannot link tag to archived spool")
  1223. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  1224. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  1225. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  1226. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  1227. # Check for conflicts: tag already linked to another active spool
  1228. if normalized_tag_uid:
  1229. conflict = await db.execute(
  1230. select(Spool).where(
  1231. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1232. Spool.id != spool_id,
  1233. Spool.archived_at.is_(None),
  1234. )
  1235. )
  1236. if conflict.scalar_one_or_none():
  1237. raise HTTPException(409, "Tag UID already linked to another active spool")
  1238. # Auto-clear from archived spools (tag recycling)
  1239. archived_with_tag = await db.execute(
  1240. select(Spool).where(
  1241. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1242. Spool.id != spool_id,
  1243. Spool.archived_at.is_not(None),
  1244. )
  1245. )
  1246. for old_spool in archived_with_tag.scalars().all():
  1247. old_spool.tag_uid = None
  1248. if normalized_tray_uuid:
  1249. conflict = await db.execute(
  1250. select(Spool).where(
  1251. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1252. Spool.id != spool_id,
  1253. Spool.archived_at.is_(None),
  1254. )
  1255. )
  1256. if conflict.scalar_one_or_none():
  1257. raise HTTPException(409, "Tray UUID already linked to another active spool")
  1258. archived_with_uuid = await db.execute(
  1259. select(Spool).where(
  1260. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1261. Spool.id != spool_id,
  1262. Spool.archived_at.is_not(None),
  1263. )
  1264. )
  1265. for old_spool in archived_with_uuid.scalars().all():
  1266. old_spool.tray_uuid = None
  1267. if data.tag_uid is not None:
  1268. spool.tag_uid = normalized_tag_uid
  1269. if data.tray_uuid is not None:
  1270. spool.tray_uuid = normalized_tray_uuid
  1271. if data.tag_type is not None:
  1272. spool.tag_type = data.tag_type
  1273. if data.data_origin is not None:
  1274. spool.data_origin = data.data_origin
  1275. await db.commit()
  1276. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1277. return result.scalar_one()
  1278. # โ”€โ”€ Usage History โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  1279. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  1280. async def get_spool_usage_history(
  1281. spool_id: int,
  1282. limit: int = 50,
  1283. db: AsyncSession = Depends(get_db),
  1284. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1285. ):
  1286. """Get usage history for a specific spool."""
  1287. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1288. # Verify spool exists
  1289. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1290. if not spool_result.scalar_one_or_none():
  1291. raise HTTPException(404, "Spool not found")
  1292. result = await db.execute(
  1293. select(SpoolUsageHistory)
  1294. .where(SpoolUsageHistory.spool_id == spool_id)
  1295. .order_by(SpoolUsageHistory.created_at.desc())
  1296. .limit(limit)
  1297. )
  1298. return list(result.scalars().all())
  1299. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  1300. async def get_all_usage_history(
  1301. limit: int = 100,
  1302. printer_id: int | None = None,
  1303. db: AsyncSession = Depends(get_db),
  1304. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1305. ):
  1306. """Get global usage history, optionally filtered by printer."""
  1307. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1308. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  1309. if printer_id is not None:
  1310. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  1311. result = await db.execute(query)
  1312. return list(result.scalars().all())
  1313. @router.delete("/spools/{spool_id}/usage")
  1314. async def clear_spool_usage_history(
  1315. spool_id: int,
  1316. db: AsyncSession = Depends(get_db),
  1317. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1318. ):
  1319. """Clear usage history for a spool."""
  1320. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1321. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  1322. for row in result.scalars().all():
  1323. await db.delete(row)
  1324. await db.commit()
  1325. return {"status": "cleared"}
  1326. # โ”€โ”€ AMS Weight Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  1327. @router.post("/sync-ams-weights")
  1328. async def sync_weights_from_ams(
  1329. db: AsyncSession = Depends(get_db),
  1330. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1331. ):
  1332. """Force-sync spool weight_used from live AMS remain% data.
  1333. Overwrites the database weight_used for every assigned spool using the
  1334. current AMS remain% from connected printers. This is a manual recovery
  1335. tool โ€” it bypasses the normal "only increase" guard.
  1336. """
  1337. from backend.app.services.printer_manager import printer_manager
  1338. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  1339. assignments = list(result.scalars().all())
  1340. logger.info("AMS weight sync: found %d assignments", len(assignments))
  1341. synced = 0
  1342. skipped = 0
  1343. for assignment in assignments:
  1344. spool = assignment.spool
  1345. if not spool:
  1346. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  1347. skipped += 1
  1348. continue
  1349. if spool.weight_locked:
  1350. logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
  1351. skipped += 1
  1352. continue
  1353. state = printer_manager.get_status(assignment.printer_id)
  1354. if not state or not state.raw_data:
  1355. logger.info(
  1356. "AMS weight sync: printer %d not connected, skipping spool %d",
  1357. assignment.printer_id,
  1358. spool.id,
  1359. )
  1360. skipped += 1
  1361. continue
  1362. ams_raw = state.raw_data.get("ams", [])
  1363. if isinstance(ams_raw, dict):
  1364. ams_raw = ams_raw.get("ams", [])
  1365. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  1366. if not tray:
  1367. logger.info(
  1368. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1369. spool.id,
  1370. assignment.printer_id,
  1371. assignment.ams_id,
  1372. assignment.tray_id,
  1373. )
  1374. skipped += 1
  1375. continue
  1376. remain_raw = tray.get("remain")
  1377. if remain_raw is None:
  1378. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  1379. skipped += 1
  1380. continue
  1381. try:
  1382. remain_val = int(remain_raw)
  1383. except (TypeError, ValueError):
  1384. skipped += 1
  1385. continue
  1386. if remain_val < 0 or remain_val > 100:
  1387. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  1388. skipped += 1
  1389. continue
  1390. lw = spool.label_weight or 1000
  1391. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  1392. old_used = spool.weight_used or 0
  1393. if round(old_used, 1) != new_used:
  1394. logger.info(
  1395. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  1396. spool.id,
  1397. old_used,
  1398. new_used,
  1399. remain_val,
  1400. )
  1401. spool.weight_used = new_used
  1402. synced += 1
  1403. else:
  1404. skipped += 1
  1405. await db.commit()
  1406. return {"synced": synced, "skipped": skipped}
  1407. # โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  1408. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  1409. """Find a specific tray in the AMS data structure."""
  1410. if not ams_data:
  1411. return None
  1412. for ams_unit in ams_data:
  1413. if int(ams_unit.get("id", -1)) != ams_id:
  1414. continue
  1415. for tray in ams_unit.get("tray", []):
  1416. if int(tray.get("id", -1)) == tray_id:
  1417. return tray
  1418. return None
  1419. # โ”€โ”€ Filament SKU Settings (reorder forecasting) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  1420. class FilamentSkuSettingsResponse(BaseModel):
  1421. id: int
  1422. material: str
  1423. subtype: str | None
  1424. brand: str | None
  1425. lead_time_days: int
  1426. safety_margin_value: int
  1427. safety_margin_unit: str
  1428. alerts_snoozed: bool = False
  1429. class Config:
  1430. from_attributes = True
  1431. class FilamentSkuSettingsUpsert(BaseModel):
  1432. material: str
  1433. subtype: str | None = None
  1434. brand: str | None = None
  1435. lead_time_days: int = 0
  1436. safety_margin_value: int = 14
  1437. safety_margin_unit: str = "days"
  1438. alerts_snoozed: bool = False
  1439. @router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
  1440. async def list_sku_settings(
  1441. db: AsyncSession = Depends(get_db),
  1442. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  1443. ):
  1444. """List all filament SKU reorder settings."""
  1445. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  1446. result = await db.execute(
  1447. select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
  1448. )
  1449. return list(result.scalars().all())
  1450. @router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
  1451. async def upsert_sku_settings(
  1452. data: FilamentSkuSettingsUpsert,
  1453. db: AsyncSession = Depends(get_db),
  1454. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1455. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1456. ),
  1457. ):
  1458. """Create or update reorder settings for a filament SKU (material/subtype/brand)."""
  1459. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  1460. result = await db.execute(
  1461. select(FilamentSkuSettings).where(
  1462. FilamentSkuSettings.material == data.material,
  1463. FilamentSkuSettings.subtype == data.subtype,
  1464. FilamentSkuSettings.brand == data.brand,
  1465. )
  1466. )
  1467. row = result.scalar_one_or_none()
  1468. if row:
  1469. row.lead_time_days = data.lead_time_days
  1470. row.safety_margin_value = data.safety_margin_value
  1471. row.safety_margin_unit = data.safety_margin_unit
  1472. row.alerts_snoozed = data.alerts_snoozed
  1473. else:
  1474. row = FilamentSkuSettings(
  1475. material=data.material,
  1476. subtype=data.subtype,
  1477. brand=data.brand,
  1478. lead_time_days=data.lead_time_days,
  1479. safety_margin_value=data.safety_margin_value,
  1480. safety_margin_unit=data.safety_margin_unit,
  1481. alerts_snoozed=data.alerts_snoozed,
  1482. )
  1483. db.add(row)
  1484. await db.commit()
  1485. await db.refresh(row)
  1486. return row
  1487. # โ”€โ”€ Shopping List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  1488. class ShoppingListItemResponse(BaseModel):
  1489. id: int
  1490. material: str
  1491. subtype: str | None
  1492. brand: str | None
  1493. quantity_spools: int
  1494. note: str | None
  1495. status: str
  1496. purchased_at: str | None
  1497. added_at: str
  1498. class Config:
  1499. from_attributes = True
  1500. class ShoppingListItemCreate(BaseModel):
  1501. material: str
  1502. subtype: str | None = None
  1503. brand: str | None = None
  1504. quantity_spools: int = 1
  1505. note: str | None = None
  1506. class ShoppingListItemStatusUpdate(BaseModel):
  1507. status: str # pending | purchased | received
  1508. @router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
  1509. async def get_shopping_list(
  1510. db: AsyncSession = Depends(get_db),
  1511. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  1512. ):
  1513. """Get the filament shopping list."""
  1514. from backend.app.models.shopping_list import ShoppingListItem
  1515. result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
  1516. items = result.scalars().all()
  1517. return [
  1518. ShoppingListItemResponse(
  1519. id=i.id,
  1520. material=i.material,
  1521. subtype=i.subtype,
  1522. brand=i.brand,
  1523. quantity_spools=i.quantity_spools,
  1524. note=i.note,
  1525. status=i.status or "pending",
  1526. purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
  1527. added_at=i.added_at.isoformat() if i.added_at else "",
  1528. )
  1529. for i in items
  1530. ]
  1531. @router.post("/shopping-list", response_model=ShoppingListItemResponse)
  1532. async def add_to_shopping_list(
  1533. data: ShoppingListItemCreate,
  1534. db: AsyncSession = Depends(get_db),
  1535. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1536. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1537. ),
  1538. ):
  1539. """Add a filament SKU to the shopping list."""
  1540. from backend.app.models.shopping_list import ShoppingListItem
  1541. item = ShoppingListItem(
  1542. material=data.material,
  1543. subtype=data.subtype,
  1544. brand=data.brand,
  1545. quantity_spools=data.quantity_spools,
  1546. note=data.note,
  1547. )
  1548. db.add(item)
  1549. await db.commit()
  1550. await db.refresh(item)
  1551. return ShoppingListItemResponse(
  1552. id=item.id,
  1553. material=item.material,
  1554. subtype=item.subtype,
  1555. brand=item.brand,
  1556. quantity_spools=item.quantity_spools,
  1557. note=item.note,
  1558. status=item.status or "pending",
  1559. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  1560. added_at=item.added_at.isoformat() if item.added_at else "",
  1561. )
  1562. @router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
  1563. async def update_shopping_list_status(
  1564. item_id: int,
  1565. data: ShoppingListItemStatusUpdate,
  1566. db: AsyncSession = Depends(get_db),
  1567. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1568. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1569. ),
  1570. ):
  1571. """Update the purchase status of a shopping list item."""
  1572. from datetime import datetime, timezone
  1573. from backend.app.models.shopping_list import ShoppingListItem
  1574. if data.status not in ("pending", "purchased", "received"):
  1575. raise HTTPException(400, "Invalid status")
  1576. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  1577. item = result.scalar_one_or_none()
  1578. if not item:
  1579. raise HTTPException(404, "Item not found")
  1580. item.status = data.status
  1581. if data.status in ("purchased", "received") and item.purchased_at is None:
  1582. item.purchased_at = datetime.now(timezone.utc)
  1583. elif data.status == "pending":
  1584. item.purchased_at = None
  1585. await db.commit()
  1586. await db.refresh(item)
  1587. return ShoppingListItemResponse(
  1588. id=item.id,
  1589. material=item.material,
  1590. subtype=item.subtype,
  1591. brand=item.brand,
  1592. quantity_spools=item.quantity_spools,
  1593. note=item.note,
  1594. status=item.status or "pending",
  1595. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  1596. added_at=item.added_at.isoformat() if item.added_at else "",
  1597. )
  1598. @router.delete("/shopping-list/{item_id}")
  1599. async def remove_from_shopping_list(
  1600. item_id: int,
  1601. db: AsyncSession = Depends(get_db),
  1602. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1603. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1604. ),
  1605. ):
  1606. """Remove a single item from the shopping list."""
  1607. from backend.app.models.shopping_list import ShoppingListItem
  1608. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  1609. item = result.scalar_one_or_none()
  1610. if not item:
  1611. raise HTTPException(404, "Item not found")
  1612. await db.delete(item)
  1613. await db.commit()
  1614. return {"status": "deleted"}
  1615. @router.delete("/shopping-list")
  1616. async def clear_shopping_list(
  1617. db: AsyncSession = Depends(get_db),
  1618. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1619. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1620. ),
  1621. ):
  1622. """Clear all items from the shopping list."""
  1623. from backend.app.models.shopping_list import ShoppingListItem
  1624. result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
  1625. deleted = len(result.fetchall())
  1626. await db.commit()
  1627. return {"deleted": deleted}