inventory.py 147 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605
  1. import json
  2. import logging
  3. from contextlib import AsyncExitStack
  4. from datetime import date, datetime, timezone
  5. import httpx
  6. from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
  7. from fastapi.responses import Response, StreamingResponse
  8. from pydantic import BaseModel, Field, field_validator
  9. from sqlalchemy import case, delete, func, select
  10. from sqlalchemy.exc import IntegrityError
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from sqlalchemy.orm import selectinload
  13. from backend.app.core.auth import (
  14. RequestPrinterScope,
  15. RequireAnyPermissionIfAuthEnabled,
  16. RequirePermissionIfAuthEnabled,
  17. RequirePrinterPermissionIfAuthEnabled,
  18. require_auth_if_enabled,
  19. )
  20. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  21. from backend.app.core.database import get_db
  22. from backend.app.core.permissions import Permission
  23. from backend.app.core.printer_scope import PrinterScope
  24. from backend.app.core.websocket import ws_manager
  25. from backend.app.models.ams_label import AmsLabel
  26. from backend.app.models.color_catalog import ColorCatalogEntry
  27. from backend.app.models.filament_profile_default import FilamentProfileDefault
  28. from backend.app.models.location import Location
  29. from backend.app.models.printer import Printer
  30. from backend.app.models.settings import Settings
  31. from backend.app.models.spool import Spool
  32. from backend.app.models.spool_assignment import SpoolAssignment
  33. from backend.app.models.spool_catalog import SpoolCatalogEntry
  34. from backend.app.models.spool_filament_preset import SpoolFilamentPreset
  35. from backend.app.models.spool_k_profile import SpoolKProfile
  36. from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key
  37. from backend.app.models.user import User
  38. from backend.app.schemas.filament_profile_default import (
  39. FilamentProfileDefaultResponse,
  40. FilamentProfileDefaultWrite,
  41. ProfileDefaultApplyResult,
  42. ResolvedProfileDefault,
  43. )
  44. from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
  45. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  46. from backend.app.schemas.spool import (
  47. MaterialNumberStats,
  48. SpoolAssignmentCreate,
  49. SpoolAssignmentResponse,
  50. SpoolBulkCreate,
  51. SpoolCreate,
  52. SpoolFilamentPresetBase,
  53. SpoolFilamentPresetResponse,
  54. SpoolKProfileBase,
  55. SpoolKProfileResponse,
  56. SpoolResponse,
  57. SpoolUpdate,
  58. normalize_effect_type,
  59. normalize_extra_colors,
  60. )
  61. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  62. from backend.app.schemas.supplier import (
  63. SpoolSupplierLinkInput,
  64. SpoolSupplierResponse,
  65. SupplierCreate,
  66. SupplierResponse,
  67. SupplierStats,
  68. SupplierUpdate,
  69. )
  70. from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
  71. from backend.app.services.ams_assignment_lock import get_ams_assignment_lock, get_spool_move_lock
  72. from backend.app.services.ams_slot_presence import spool_present
  73. from backend.app.services.location_service import (
  74. DUPLICATE_LOCATION_NAME,
  75. assign_location_name,
  76. count_internal_spools_at_location,
  77. get_location_by_id,
  78. get_location_by_name,
  79. location_name_key,
  80. prepare_internal_spool_payload,
  81. rename_location as rename_location_record,
  82. )
  83. from backend.app.services.material_number import apply_material_number_inheritance
  84. from backend.app.services.slicer_filament_resolver import resolve_slicer_filament, reusable_slot_filament_id
  85. from backend.app.services.slot_nozzle import resolve_slot_nozzle
  86. from backend.app.services.spool_csv import (
  87. MAX_CSV_IMPORT_BYTES,
  88. ImportPreview,
  89. ImportResult,
  90. parse_and_validate,
  91. serialize,
  92. )
  93. from backend.app.services.spool_filament_preset import resolve_spool_preset
  94. from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
  95. from backend.app.services.supplier_links import apply_supplier_inheritance, apply_supplier_inheritance_to_batch
  96. from backend.app.services.tag_conflict import tag_already_linked
  97. from backend.app.utils.filament_finish import finish_generic_id
  98. from backend.app.utils.filament_ids import (
  99. GENERIC_FILAMENT_IDS,
  100. filament_id_to_setting_id,
  101. normalize_slicer_filament,
  102. )
  103. from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
  104. from backend.app.utils.local_time import day_zone, local_date_end, local_date_start
  105. from backend.app.utils.natural_sort import natural_sort_key
  106. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  107. logger = logging.getLogger(__name__)
  108. router = APIRouter(prefix="/inventory", tags=["inventory"])
  109. def spool_response_loads():
  110. """Loader options for every query that answers with a ``SpoolResponse``.
  111. Both relationships the schema reads carry the default loader, so the ~50
  112. other ``select(Spool)`` call sites across the usage tracker, AMS sync,
  113. labels and backup pay nothing for them (#2988).
  114. """
  115. return (
  116. selectinload(Spool.k_profiles),
  117. selectinload(Spool.supplier_links).selectinload(SpoolSupplier.supplier),
  118. )
  119. # Bounded read size for the CSV import body so a chunked upload with no
  120. # Content-Length can't stream past the cap into memory before we notice.
  121. _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
  122. # FilamentColors.xyz API
  123. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  124. async def apply_spool_to_slot_via_mqtt(
  125. *,
  126. db: AsyncSession,
  127. current_user: User | None,
  128. spool: Spool,
  129. printer_id: int,
  130. ams_id: int,
  131. tray_id: int,
  132. current_tray_info_idx: str = "",
  133. current_tray_type: str = "",
  134. current_tray_sub_brands: str = "",
  135. ) -> bool:
  136. """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
  137. Shared by `assign_spool` (initial assign for a loaded slot) and
  138. `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
  139. empty → loaded). Returns True when MQTT commands were published, False if
  140. no client was available or setup failed mid-way.
  141. `current_tray_info_idx` / `current_tray_type` describe the live tray state
  142. used as fallback hints when the spool's slicer_filament can't be resolved.
  143. Caller should not pass these for the empty-slot re-fire path (they'll be
  144. the freshly-loaded values, which is the intended fallback).
  145. """
  146. from backend.app.services.printer_manager import printer_manager
  147. client = printer_manager.get_client(printer_id)
  148. if client is None:
  149. return False
  150. state = printer_manager.get_status(printer_id)
  151. # The slot carries the material type; the product line the material column
  152. # may actually hold ("PLA+", "HTPLA") stays in tray_sub_brands below, which
  153. # is where Bambu puts it too (issue #2902).
  154. tray_type = printer_filament_type(spool.material)
  155. # Join only the parts that exist. The previous shape interpolated
  156. # `spool.subtype` into the branded string without checking it, so a spool
  157. # with a brand and no subtype went to the printer as
  158. # "Sunlu PLA Matte None" -- the string "None", on the wire (#2987). The
  159. # unbranded branch guarded subtype; the branded one did not.
  160. tray_sub_brands = " ".join(p for p in (spool.brand, spool.material, spool.subtype) if p) or spool.material
  161. tray_color = spool.rgba or "FFFFFFFF"
  162. # Which nozzle this slot feeds, and how wide it is. One resolution shared
  163. # with every other path that configures a slot (see services.slot_nozzle),
  164. # and used twice below -- for the spool's per-model preset override and for
  165. # its K profile -- so the two lookups cannot answer for different nozzles.
  166. slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
  167. nozzle_diameter = slot_nozzle.diameter
  168. # A cloud or Orca preset is bound to a printer MODEL ("@BBL X1C"), so the
  169. # spool's single slicer_filament stops being right the moment the same
  170. # spool is used on a second model. resolve_spool_preset returns the
  171. # spool's own value unless the user has set an override for this model,
  172. # so a spool nobody has configured behaves exactly as it did before.
  173. slot_slicer_filament, slot_slicer_filament_name = await resolve_spool_preset(
  174. db,
  175. spool_id=spool.id,
  176. printer_model=printer_manager.get_model(printer_id),
  177. nozzle_diameter=nozzle_diameter,
  178. fallback_filament=spool.slicer_filament,
  179. fallback_name=spool.slicer_filament_name,
  180. )
  181. # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
  182. # the Spoolman-mode route via this helper (#1713). The helper handles
  183. # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
  184. # the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
  185. # sanitization. When it returns an empty tray_info_idx the local
  186. # current-tray-state + generic-material fallback below rescues the slot.
  187. tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
  188. db=db,
  189. current_user=current_user,
  190. slicer_filament=slot_slicer_filament,
  191. slicer_filament_name=slot_slicer_filament_name,
  192. material=spool.material,
  193. )
  194. if sub_brand_override:
  195. tray_sub_brands = sub_brand_override
  196. # A preset says what its material is; the reduction above only infers it
  197. # from whatever wording the spool's material column happens to carry. When
  198. # the spool has a preset, its answer wins (issue #2902, @doncaruana).
  199. if type_override:
  200. tray_type = printer_filament_type(type_override)
  201. if not tray_info_idx:
  202. reused = reusable_slot_filament_id(
  203. current_tray_info_idx,
  204. current_tray_type,
  205. current_tray_sub_brands,
  206. tray_type,
  207. f"{spool.material or ''} {spool.subtype or ''}",
  208. )
  209. if reused:
  210. tray_info_idx = reused
  211. elif tray_type:
  212. # The spool's own wording is tried first and the reduced type only
  213. # as a further fallback, so a material that already resolves keeps
  214. # resolving to the same id: "PETG HF" has its own generic preset
  215. # (GFG96) that reducing it to "PETG" would trade away for GFG99.
  216. material = (spool.material or "").upper().strip()
  217. generic = (
  218. finish_generic_id(tray_type, f"{spool.material or ''} {spool.subtype or ''}")
  219. or GENERIC_FILAMENT_IDS.get(material)
  220. or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  221. or GENERIC_FILAMENT_IDS.get(tray_type.upper())
  222. or ""
  223. )
  224. if generic:
  225. tray_info_idx = generic
  226. # Ensure setting_id is always derivable from tray_info_idx. The local-preset
  227. # path above sets tray_info_idx to a generic ID (e.g. "GFL99") but leaves
  228. # setting_id empty — without this fallback the slicer gets a half-configured
  229. # slot (filament id without setting id) and shows empty fields in the slot
  230. # detail modal.
  231. if tray_info_idx and not setting_id:
  232. setting_id = filament_id_to_setting_id(tray_info_idx)
  233. # Same order as the generic-id lookup above: the spool's own wording wins,
  234. # the reduced type rescues what it does not cover. Without the second
  235. # lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
  236. temp_min, temp_max = nozzle_temp_range(spool.material, tray_type)
  237. if spool.nozzle_temp_min is not None:
  238. temp_min = spool.nozzle_temp_min
  239. if spool.nozzle_temp_max is not None:
  240. temp_max = spool.nozzle_temp_max
  241. slot_extruder = slot_nozzle.extruder
  242. # Prefer exact extruder match, fall back to extruder-agnostic kp for the
  243. # same nozzle. Hard-skipping on mismatch silently drops valid stored
  244. # profiles when the AMS-extruder mapping has shifted.
  245. exact_kp = None
  246. fallback_kp = None
  247. for kp in spool.k_profiles:
  248. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
  249. continue
  250. # A profile measured on a high-flow nozzle is not a fact about a
  251. # standard one. Rows with no stored flow -- everything saved before
  252. # this, and everything from a printer whose table declares none --
  253. # still match, see SlotNozzle.flow_matches.
  254. if not slot_nozzle.flow_matches(kp.nozzle_type):
  255. continue
  256. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  257. exact_kp = kp
  258. break
  259. if fallback_kp is None:
  260. fallback_kp = kp
  261. matching_kp = exact_kp or fallback_kp
  262. # Resolve the printer-side calibration entry by looking up the cali_idx
  263. # in state.kprofiles. The printer keys its calibration table by
  264. # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
  265. # filament_id must match the kp's. PFUS-prefix cloud user presets are
  266. # rejected by the slicer in tray_info_idx; the printer-reported
  267. # filament_id is typically a P-prefix local preset which is valid.
  268. printer_kp = None
  269. if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
  270. for pkp in state.kprofiles:
  271. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  272. printer_kp = pkp
  273. break
  274. effective_tray_info_idx = tray_info_idx
  275. effective_setting_id = setting_id
  276. if printer_kp and printer_kp.filament_id:
  277. effective_tray_info_idx = printer_kp.filament_id
  278. target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
  279. matching_kp.setting_id if matching_kp else None
  280. )
  281. if target_setting_id:
  282. effective_setting_id = target_setting_id
  283. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  284. logger.info(
  285. "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
  286. tray_info_idx,
  287. effective_tray_info_idx,
  288. setting_id,
  289. effective_setting_id,
  290. "printer" if printer_kp else "stored",
  291. )
  292. client.ams_set_filament_setting(
  293. ams_id=ams_id,
  294. tray_id=tray_id,
  295. tray_info_idx=effective_tray_info_idx,
  296. tray_type=tray_type,
  297. tray_sub_brands=tray_sub_brands,
  298. tray_color=tray_color,
  299. nozzle_temp_min=temp_min,
  300. nozzle_temp_max=temp_max,
  301. setting_id=effective_setting_id,
  302. )
  303. if matching_kp and matching_kp.cali_idx is not None:
  304. # filament_id for cali_sel must match the preset under which the kp
  305. # was registered. Priority: live printer kp > stored kp.setting_id >
  306. # spool.slicer_filament > realigned tray_info_idx.
  307. if printer_kp and printer_kp.filament_id:
  308. cali_filament_id = printer_kp.filament_id
  309. elif matching_kp.setting_id:
  310. cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
  311. else:
  312. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  313. client.extrusion_cali_sel(
  314. ams_id=ams_id,
  315. tray_id=tray_id,
  316. cali_idx=matching_kp.cali_idx,
  317. filament_id=cali_filament_id,
  318. nozzle_diameter=nozzle_diameter,
  319. )
  320. else:
  321. # No stored K-profile for this spool — always reset the slot to Default
  322. # K (cali_idx=-1). The live cali_idx on the slot belongs to whatever
  323. # filament was there before, so preserving it would apply the wrong
  324. # filament's calibration to the new spool. Default K is the firmware's
  325. # documented "no specific profile" value (see BambuClient.extrusion_cali_sel
  326. # docstring).
  327. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  328. client.extrusion_cali_sel(
  329. ams_id=ams_id,
  330. tray_id=tray_id,
  331. cali_idx=-1,
  332. filament_id=cali_filament_id,
  333. nozzle_diameter=nozzle_diameter,
  334. )
  335. logger.info(
  336. "No stored K-profile for spool %d — reset slot to Default K (cali_idx=-1)",
  337. spool.id,
  338. )
  339. # Register a read-back verification so the next AMS pushes can confirm the
  340. # tray actually accepted this assignment (#2582). We record the same
  341. # effective filament id we pushed plus the cali_idx we selected (or -1 for
  342. # the Default-K reset above), and the client fires on_assignment_verified
  343. # on match/timeout. Colour is informational only — the match keys on the
  344. # filament id the slicer echoes back.
  345. verify_cali_idx = matching_kp.cali_idx if (matching_kp and matching_kp.cali_idx is not None) else -1
  346. client.register_assignment_verification(
  347. ams_id=ams_id,
  348. tray_id=tray_id,
  349. tray_info_idx=effective_tray_info_idx,
  350. tray_color=tray_color,
  351. cali_idx=verify_cali_idx,
  352. )
  353. # Persist slot preset mapping for UI display (preset_name on hover card).
  354. # Shared with the RFID auto-assign path — both must keep this row in sync
  355. # with the currently-assigned spool, otherwise the slot card surfaces the
  356. # previous spool's preset name (the PrintersPage display chain consults
  357. # slot_preset_mappings.preset_name first).
  358. from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
  359. await upsert_slot_preset_for_spool(
  360. db=db,
  361. spool=spool,
  362. printer_id=printer_id,
  363. ams_id=ams_id,
  364. tray_id=tray_id,
  365. tray_info_idx=tray_info_idx,
  366. tray_sub_brands=tray_sub_brands,
  367. tray_type=tray_type,
  368. setting_id=setting_id,
  369. configured_tray_info_idx=effective_tray_info_idx,
  370. )
  371. logger.info(
  372. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  373. ams_id,
  374. tray_id,
  375. spool.id,
  376. printer_id,
  377. )
  378. return True
  379. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  380. class CatalogEntryResponse(BaseModel):
  381. id: int
  382. name: str
  383. weight: int
  384. is_default: bool
  385. class Config:
  386. from_attributes = True
  387. class CatalogEntryCreate(BaseModel):
  388. name: str
  389. weight: int
  390. class CatalogEntryUpdate(IgnoresNullForRequiredColumns):
  391. __orm_table__ = "spool_catalog"
  392. name: str
  393. weight: int
  394. class BulkDeleteIdsRequest(BaseModel):
  395. ids: list[int]
  396. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  397. class ColorEntryResponse(BaseModel):
  398. id: int
  399. manufacturer: str
  400. color_name: str
  401. hex_color: str
  402. material: str | None
  403. is_default: bool
  404. extra_colors: str | None = None
  405. effect_type: str | None = None
  406. class Config:
  407. from_attributes = True
  408. _HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
  409. class ColorEntryCreate(BaseModel):
  410. manufacturer: str
  411. color_name: str
  412. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  413. material: str | None = None
  414. extra_colors: str | None = None
  415. effect_type: str | None = None
  416. @field_validator("extra_colors")
  417. @classmethod
  418. def _validate_extra_colors(cls, v: str | None) -> str | None:
  419. return normalize_extra_colors(v)
  420. @field_validator("effect_type")
  421. @classmethod
  422. def _validate_effect_type(cls, v: str | None) -> str | None:
  423. return normalize_effect_type(v)
  424. class ColorEntryUpdate(IgnoresNullForRequiredColumns):
  425. __orm_table__ = "color_catalog"
  426. manufacturer: str
  427. color_name: str
  428. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  429. material: str | None = None
  430. extra_colors: str | None = None
  431. effect_type: str | None = None
  432. @field_validator("extra_colors")
  433. @classmethod
  434. def _validate_extra_colors(cls, v: str | None) -> str | None:
  435. return normalize_extra_colors(v)
  436. @field_validator("effect_type")
  437. @classmethod
  438. def _validate_effect_type(cls, v: str | None) -> str | None:
  439. return normalize_effect_type(v)
  440. class ColorLookupResult(BaseModel):
  441. found: bool
  442. hex_color: str | None = None
  443. material: str | None = None
  444. class ColorByMaterialResult(BaseModel):
  445. color_name: str | None = None
  446. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  447. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  448. async def get_spool_catalog(
  449. db: AsyncSession = Depends(get_db, scope="function"),
  450. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  451. ):
  452. """Get all spool catalog entries."""
  453. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  454. return list(result.scalars().all())
  455. @router.post("/catalog", response_model=CatalogEntryResponse)
  456. async def add_catalog_entry(
  457. entry: CatalogEntryCreate,
  458. db: AsyncSession = Depends(get_db, scope="function"),
  459. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  460. ):
  461. """Add a new spool catalog entry."""
  462. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  463. db.add(row)
  464. await db.commit()
  465. await db.refresh(row)
  466. return row
  467. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  468. async def update_catalog_entry(
  469. entry_id: int,
  470. entry: CatalogEntryUpdate,
  471. db: AsyncSession = Depends(get_db, scope="function"),
  472. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  473. ):
  474. """Update a spool catalog entry."""
  475. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  476. row = result.scalar_one_or_none()
  477. if not row:
  478. raise HTTPException(404, "Entry not found")
  479. row.name = entry.name
  480. row.weight = entry.weight
  481. await db.commit()
  482. await db.refresh(row)
  483. return row
  484. @router.delete("/catalog/{entry_id}")
  485. async def delete_catalog_entry(
  486. entry_id: int,
  487. db: AsyncSession = Depends(get_db, scope="function"),
  488. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  489. ):
  490. """Delete a spool catalog entry."""
  491. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  492. row = result.scalar_one_or_none()
  493. if not row:
  494. raise HTTPException(404, "Entry not found")
  495. await db.delete(row)
  496. await db.commit()
  497. return {"status": "deleted"}
  498. @router.post("/catalog/bulk-delete")
  499. async def bulk_delete_catalog_entries(
  500. data: BulkDeleteIdsRequest,
  501. db: AsyncSession = Depends(get_db, scope="function"),
  502. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  503. ):
  504. """Delete multiple spool catalog entries by ID."""
  505. if not data.ids:
  506. return {"deleted": 0}
  507. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  508. rows = result.scalars().all()
  509. for row in rows:
  510. await db.delete(row)
  511. await db.commit()
  512. return {"deleted": len(rows)}
  513. @router.post("/catalog/reset")
  514. async def reset_spool_catalog(
  515. db: AsyncSession = Depends(get_db, scope="function"),
  516. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  517. ):
  518. """Reset spool catalog to defaults."""
  519. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  520. # Delete all
  521. result = await db.execute(select(SpoolCatalogEntry))
  522. for row in result.scalars().all():
  523. await db.delete(row)
  524. # Re-seed defaults
  525. for name, weight in DEFAULT_SPOOL_CATALOG:
  526. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  527. await db.commit()
  528. return {"status": "reset"}
  529. # ── Storage Locations (#1004) ───────────────────────────────────────────────
  530. async def _load_settings_map(db: AsyncSession) -> dict[str, str]:
  531. result = await db.execute(select(Settings))
  532. return {s.key: s.value for s in result.scalars().all()}
  533. def _spoolman_is_enabled(settings: dict[str, str]) -> bool:
  534. return settings.get("spoolman_enabled", "false").lower() == "true"
  535. async def _ensure_spoolman_client(settings: dict[str, str]) -> SpoolmanClient | None:
  536. if not _spoolman_is_enabled(settings):
  537. return None
  538. url = settings.get("spoolman_url", "").strip()
  539. if not url:
  540. return None
  541. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  542. try:
  543. assert_safe_spoolman_url(url)
  544. except ValueError:
  545. return None
  546. client = await get_spoolman_client()
  547. if not client or client.base_url != url.rstrip("/"):
  548. client = await init_spoolman_client(url)
  549. return client
  550. async def _spool_counts_for_locations(
  551. db: AsyncSession,
  552. locations: list[Location],
  553. settings: dict[str, str],
  554. *,
  555. strict: bool = False,
  556. ) -> dict[int, int]:
  557. """Spools at each location. In Spoolman mode they are Spoolman's; when it
  558. can't be read the built-in counts are shown instead, which ``strict``
  559. refuses with a 503 (SM-C13): the built-in table holds no Spoolman spools,
  560. so a location still in use would count as empty and could be deleted."""
  561. if _spoolman_is_enabled(settings):
  562. client = await _ensure_spoolman_client(settings)
  563. if not client and strict:
  564. raise HTTPException(status_code=503, detail="Spoolman is unavailable")
  565. if client:
  566. try:
  567. spools = await client.get_all_spools(allow_archived=False)
  568. except Exception as exc:
  569. logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
  570. if strict:
  571. raise HTTPException(status_code=503, detail="Spoolman is unavailable") from exc
  572. else:
  573. # Use the canonical key helper so this matches what the
  574. # migration backfill, Location.name_key, and every other
  575. # codepath store as the case-insensitive lookup key. Plain
  576. # str.lower() drifts for non-ASCII (Turkish ı/İ, German ß)
  577. # and caused mismatched delete-block counts in Spoolman mode.
  578. by_key: dict[str, int] = {}
  579. for spool in spools:
  580. raw = spool.get("location")
  581. if not raw or not isinstance(raw, str) or not raw.strip():
  582. continue
  583. try:
  584. key = location_name_key(raw)
  585. except ValueError:
  586. continue
  587. by_key[key] = by_key.get(key, 0) + 1
  588. return {loc.id: by_key.get(loc.name_key, 0) for loc in locations}
  589. counts: dict[int, int] = {}
  590. for loc in locations:
  591. counts[loc.id] = await count_internal_spools_at_location(db, loc.id)
  592. return counts
  593. def _location_to_response(location: Location, spool_count: int) -> LocationResponse:
  594. return LocationResponse(
  595. id=location.id,
  596. name=location.name,
  597. identifier=location.identifier,
  598. spool_count=spool_count,
  599. created_at=location.created_at,
  600. updated_at=location.updated_at,
  601. )
  602. @router.get("/locations", response_model=list[LocationResponse])
  603. async def list_locations(
  604. db: AsyncSession = Depends(get_db, scope="function"),
  605. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  606. ):
  607. """List all storage locations with spool counts."""
  608. settings = await _load_settings_map(db)
  609. result = await db.execute(select(Location))
  610. # Sorted in Python, not SQL: "Drybox 2" belongs before "Drybox 10", and
  611. # ORDER BY name gives the opposite (plain lexicographic) order.
  612. locations = sorted(result.scalars().all(), key=lambda loc: natural_sort_key(loc.name))
  613. counts = await _spool_counts_for_locations(db, locations, settings)
  614. return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
  615. @router.post("/locations", response_model=LocationResponse, status_code=201)
  616. async def create_location(
  617. data: LocationCreate,
  618. db: AsyncSession = Depends(get_db, scope="function"),
  619. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  620. ):
  621. """Create a storage location."""
  622. existing = await get_location_by_name(db, data.name)
  623. if existing:
  624. raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME)
  625. location = Location(identifier=data.identifier)
  626. assign_location_name(location, data.name)
  627. db.add(location)
  628. try:
  629. await db.commit()
  630. except IntegrityError as exc:
  631. await db.rollback()
  632. raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
  633. await db.refresh(location)
  634. await ws_manager.broadcast({"type": "inventory_changed"})
  635. return _location_to_response(location, 0)
  636. @router.patch("/locations/{location_id}", response_model=LocationResponse)
  637. async def update_location(
  638. location_id: int,
  639. data: LocationUpdate,
  640. db: AsyncSession = Depends(get_db, scope="function"),
  641. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  642. ):
  643. """Update a storage location (rename propagates to assigned spools)."""
  644. location = await get_location_by_id(db, location_id)
  645. if not location:
  646. raise HTTPException(status_code=404, detail="Location not found")
  647. old_name = location.name
  648. if data.identifier is not None:
  649. location.identifier = data.identifier or None
  650. if data.name is not None and data.name != old_name:
  651. try:
  652. await rename_location_record(db, location, data.name)
  653. except ValueError as exc:
  654. raise HTTPException(status_code=409, detail=str(exc)) from exc
  655. # Cascade to Spoolman BEFORE the local commit so a Spoolman failure
  656. # rolls back the local rename instead of leaving the catalog and
  657. # Spoolman's per-spool `location` field permanently diverged. Without
  658. # this ordering, a partial failure makes the next location-sync recreate
  659. # the old name as a duplicate catalog row (#1505 review blocker).
  660. settings = await _load_settings_map(db)
  661. client = await _ensure_spoolman_client(settings)
  662. if client:
  663. try:
  664. await client.rename_location(old_name, location.name)
  665. except Exception as exc:
  666. logger.warning(
  667. "Spoolman location rename failed for %s -> %s: %s",
  668. old_name,
  669. location.name,
  670. exc,
  671. )
  672. await db.rollback()
  673. raise HTTPException(
  674. status_code=502,
  675. detail="Spoolman rename failed; local rename rolled back",
  676. ) from exc
  677. try:
  678. await db.commit()
  679. except IntegrityError as exc:
  680. await db.rollback()
  681. raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
  682. await db.refresh(location)
  683. settings = await _load_settings_map(db)
  684. counts = await _spool_counts_for_locations(db, [location], settings)
  685. await ws_manager.broadcast({"type": "inventory_changed"})
  686. return _location_to_response(location, counts.get(location.id, 0))
  687. @router.delete("/locations/{location_id}")
  688. async def delete_location(
  689. location_id: int,
  690. db: AsyncSession = Depends(get_db, scope="function"),
  691. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  692. ):
  693. """Delete a storage location when no spools are assigned."""
  694. location = await get_location_by_id(db, location_id)
  695. if not location:
  696. raise HTTPException(status_code=404, detail="Location not found")
  697. settings = await _load_settings_map(db)
  698. counts = await _spool_counts_for_locations(db, [location], settings, strict=True)
  699. if counts.get(location.id, 0) > 0:
  700. raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
  701. await db.delete(location)
  702. await db.commit()
  703. await ws_manager.broadcast({"type": "inventory_changed"})
  704. return {"status": "deleted"}
  705. # ── Supplier master list (#2988) ───────────────────────────────────────────
  706. #
  707. # Inventory master data that spools reference, exactly like Locations — so it
  708. # lives here, gated by the inventory permissions, not behind its own
  709. # permission set or a Settings page. Suppliers are *where filament is bought*,
  710. # distinct from ``Spool.brand`` (who made it).
  711. DUPLICATE_SUPPLIER_NAME = "A supplier with this name already exists"
  712. async def _supplier_by_name(db: AsyncSession, name: str, *, exclude_id: int | None = None) -> Supplier | None:
  713. """Case-insensitive name lookup behind the duplicate guard (#2988).
  714. Matches on the stored ``name_key``, so the comparison is the Python fold
  715. the CSV import also uses — ``func.lower()`` would have folded ASCII only
  716. on SQLite and let an umlaut'd case variant past the check.
  717. """
  718. query = select(Supplier).where(Supplier.name_key == supplier_name_key(name))
  719. if exclude_id is not None:
  720. query = query.where(Supplier.id != exclude_id)
  721. return (await db.execute(query)).scalars().first()
  722. async def _supplier_reference_counts(db: AsyncSession) -> dict[int, int]:
  723. """Spools referencing each supplier, across BOTH inventories.
  724. The delete guard has to see Spoolman-mode assignments too — a supplier
  725. used only by Spoolman spools must not be deletable just because the
  726. built-in inventory has no reference to it.
  727. """
  728. counts: dict[int, int] = {}
  729. for model in (SpoolSupplier, SpoolmanSpoolSupplier):
  730. result = await db.execute(select(model.supplier_id, func.count(model.id)).group_by(model.supplier_id))
  731. for supplier_id, count in result.all():
  732. counts[supplier_id] = counts.get(supplier_id, 0) + count
  733. return counts
  734. async def _prune_orphaned_spoolman_supplier_rows(db: AsyncSession) -> int:
  735. """Drop twin rows whose Spoolman spool no longer exists (#2988).
  736. ``_purge_local_rows_for_spool`` covers the deletes Bambuddy performs, but
  737. Spoolman is a separate application with its own UI: a spool deleted there
  738. — or a Spoolman instance that was rebuilt or replaced — leaves
  739. ``spoolman_spool_suppliers`` rows behind that keep the supplier's
  740. reference count non-zero, and nothing in Bambuddy can show or remove the
  741. phantom reference. Without this, that 409 is permanent.
  742. Reconciled on the delete attempt rather than on every listing: it costs
  743. one Spoolman call, and only the route that is about to refuse needs the
  744. answer. Archived spools count as live — archiving is a soft delete and the
  745. assignment has to survive it. Returns the number of rows removed; 0 when
  746. Spoolman is off or unreachable, which leaves the 409 standing rather than
  747. dropping rows on the strength of a failed lookup.
  748. """
  749. settings = await _load_settings_map(db)
  750. if not _spoolman_is_enabled(settings):
  751. return 0
  752. local_ids = set((await db.execute(select(SpoolmanSpoolSupplier.spoolman_spool_id).distinct())).scalars().all())
  753. if not local_ids:
  754. return 0
  755. client = await _ensure_spoolman_client(settings)
  756. if not client:
  757. return 0
  758. try:
  759. spools = await client.get_all_spools(allow_archived=True)
  760. except Exception:
  761. logger.warning("Failed to fetch Spoolman spools to reconcile supplier assignments", exc_info=True)
  762. return 0
  763. stale = local_ids - {s.get("id") for s in spools if isinstance(s, dict)}
  764. if not stale:
  765. return 0
  766. await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(stale)))
  767. await db.commit()
  768. logger.info("Dropped supplier assignments for %d Spoolman spool(s) that no longer exist", len(stale))
  769. return len(stale)
  770. @router.get("/suppliers", response_model=list[SupplierResponse])
  771. async def list_suppliers(
  772. db: AsyncSession = Depends(get_db, scope="function"),
  773. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  774. ):
  775. """List all suppliers with their spool-usage counts."""
  776. # name_key, not name: the list should read the way it looks, so "extrudr"
  777. # sorts next to "Extrudr" rather than after "Zultrat".
  778. result = await db.execute(select(Supplier).order_by(Supplier.name_key))
  779. suppliers = result.scalars().all()
  780. counts = await _supplier_reference_counts(db)
  781. responses = []
  782. for supplier in suppliers:
  783. response = SupplierResponse.model_validate(supplier)
  784. response.spool_count = counts.get(supplier.id, 0)
  785. responses.append(response)
  786. return responses
  787. @router.post("/suppliers", response_model=SupplierResponse, status_code=201)
  788. async def create_supplier(
  789. data: SupplierCreate,
  790. db: AsyncSession = Depends(get_db, scope="function"),
  791. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  792. ):
  793. """Create a supplier (mirrors create_location, duplicate name included)."""
  794. if await _supplier_by_name(db, data.name):
  795. raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME)
  796. supplier = Supplier(**data.model_dump())
  797. db.add(supplier)
  798. try:
  799. await db.commit()
  800. except IntegrityError as exc:
  801. # The unique index behind the check above, for the concurrent case.
  802. await db.rollback()
  803. raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) from exc
  804. await db.refresh(supplier)
  805. await ws_manager.broadcast({"type": "inventory_changed"})
  806. return SupplierResponse.model_validate(supplier)
  807. @router.patch("/suppliers/{supplier_id}", response_model=SupplierResponse)
  808. async def update_supplier(
  809. supplier_id: int,
  810. data: SupplierUpdate,
  811. db: AsyncSession = Depends(get_db, scope="function"),
  812. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  813. ):
  814. """Update a supplier."""
  815. result = await db.execute(select(Supplier).where(Supplier.id == supplier_id))
  816. supplier = result.scalar_one_or_none()
  817. if not supplier:
  818. raise HTTPException(status_code=404, detail="Supplier not found")
  819. fields = data.model_dump(exclude_unset=True)
  820. if "name" in fields and await _supplier_by_name(db, fields["name"], exclude_id=supplier_id):
  821. raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME)
  822. for field, value in fields.items():
  823. setattr(supplier, field, value)
  824. try:
  825. await db.commit()
  826. except IntegrityError as exc:
  827. await db.rollback()
  828. raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) from exc
  829. await db.refresh(supplier)
  830. await ws_manager.broadcast({"type": "inventory_changed"})
  831. response = SupplierResponse.model_validate(supplier)
  832. response.spool_count = (await _supplier_reference_counts(db)).get(supplier.id, 0)
  833. return response
  834. @router.delete("/suppliers/{supplier_id}")
  835. async def delete_supplier(
  836. supplier_id: int,
  837. db: AsyncSession = Depends(get_db, scope="function"),
  838. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  839. ):
  840. """Delete a supplier when no spools reference it (mirrors delete_location)."""
  841. result = await db.execute(select(Supplier).where(Supplier.id == supplier_id))
  842. supplier = result.scalar_one_or_none()
  843. if not supplier:
  844. raise HTTPException(status_code=404, detail="Supplier not found")
  845. if (await _supplier_reference_counts(db)).get(supplier_id, 0) > 0:
  846. # Last chance before refusing: the reference may be a Spoolman spool
  847. # that was deleted in Spoolman itself, which Bambuddy never hears about.
  848. await _prune_orphaned_spoolman_supplier_rows(db)
  849. if (await _supplier_reference_counts(db)).get(supplier_id, 0) > 0:
  850. raise HTTPException(status_code=409, detail="Supplier has spools assigned and cannot be deleted")
  851. await db.delete(supplier)
  852. await db.commit()
  853. await ws_manager.broadcast({"type": "inventory_changed"})
  854. return {"status": "deleted"}
  855. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  856. @router.get("/colors", response_model=list[ColorEntryResponse])
  857. async def get_color_catalog(
  858. db: AsyncSession = Depends(get_db, scope="function"),
  859. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  860. ):
  861. """Get all color catalog entries."""
  862. result = await db.execute(
  863. select(ColorCatalogEntry).order_by(
  864. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  865. )
  866. )
  867. return list(result.scalars().all())
  868. @router.get("/colors/map")
  869. async def get_color_name_map(
  870. db: AsyncSession = Depends(get_db, scope="function"),
  871. _: User | None = Depends(require_auth_if_enabled),
  872. ):
  873. """Compact {hex: name} map for frontend color-name resolution.
  874. Not gated on INVENTORY_READ — every page that renders a spool color needs
  875. this, including read-only views available to users without inventory access.
  876. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  877. share the same hex (different materials or manufacturers), Bambu Lab wins,
  878. then default entries, then the first encountered.
  879. ``by_material`` carries the names that collapsing loses. A hex is not one
  880. colour in Bambu's range: #FFFFFF is Jade White in PLA Basic, Ivory White in
  881. PLA Matte and plain White in six more, and #000000 is Black except in PLA
  882. Matte where it is Charcoal. A caller that knows the material — an AMS slot
  883. knows it as ``tray_sub_brands`` — looks up ``"<material>|<hex>"`` there
  884. first and falls back to ``colors`` (#2875).
  885. An entry is included only when it recovers a name the *same manufacturer's*
  886. own range lost. Two conditions, both load-bearing: a name equal to the
  887. collapsed one is pure weight, and a name from a different manufacturer is
  888. not a recovery at all — it would put Prusament's "Pristine White" on every
  889. generic white PLA slot in place of Bambu's "Jade White", trading one
  890. arbitrary answer for another. What survives is the handful of cases this
  891. exists for.
  892. ``bambu`` lists every Bambu Lab row as ``{hex, name, material}`` for naming
  893. a colour by its nearest Bambu entry (#3279). A colour picked on the
  894. printer's touchscreen is reported a step or two off the catalogue's hex
  895. (5A657B for Blue Grey 5B6579), which no exact lookup finds. Only Bambu's
  896. rows: that is the printer's palette, and another brand's name for a
  897. neighbouring hex would be a guess.
  898. ``preset_materials`` maps each Bambu preset id to its product line
  899. (``GFA00`` -> ``PLA Basic``). A slot without RFID reports the preset picked
  900. on the printer's screen but an empty ``tray_sub_brands``, and the product
  901. line is what the nearest search looks in first.
  902. """
  903. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  904. result = await db.execute(
  905. select(
  906. ColorCatalogEntry.hex_color,
  907. ColorCatalogEntry.color_name,
  908. ColorCatalogEntry.manufacturer,
  909. ColorCatalogEntry.is_default,
  910. ColorCatalogEntry.material,
  911. )
  912. )
  913. # hex → (name, priority, manufacturer); higher priority wins, first on a tie
  914. mapping: dict[str, tuple[str, int, str]] = {}
  915. by_material: dict[str, tuple[str, int, str]] = {} # "material|hex" → same
  916. bambu: list[dict[str, str]] = []
  917. for hex_color, color_name, manufacturer, is_default, material in result.all():
  918. if not hex_color or not color_name:
  919. continue
  920. key = hex_color.lstrip("#").lower()[:6]
  921. if len(key) != 6:
  922. continue
  923. brand = (manufacturer or "").strip().lower()
  924. if brand == "bambu lab":
  925. bambu.append({"hex": key, "name": color_name, "material": (material or "").strip()})
  926. priority = 0
  927. if brand == "bambu lab":
  928. priority += 2
  929. if is_default:
  930. priority += 1
  931. existing = mapping.get(key)
  932. if existing is None or priority > existing[1]:
  933. mapping[key] = (color_name, priority, brand)
  934. material_key = (material or "").strip().lower()
  935. if material_key:
  936. # Split on the LAST separator when reading these back: a material is
  937. # free text and may itself contain a '|'.
  938. qualified = f"{material_key}|{key}"
  939. existing = by_material.get(qualified)
  940. if existing is None or priority > existing[1]:
  941. by_material[qualified] = (color_name, priority, brand)
  942. colors = {k: v[0] for k, v in mapping.items()}
  943. qualified_colors = {}
  944. for qualified, (name, _, brand) in by_material.items():
  945. flat = mapping.get(qualified.rsplit("|", 1)[1])
  946. if flat and flat[0] != name and flat[2] == brand:
  947. qualified_colors[qualified] = name
  948. preset_materials = {
  949. preset: name.removeprefix("Bambu ").strip()
  950. for preset, name in _BUILTIN_FILAMENT_NAMES.items()
  951. if name.startswith("Bambu ")
  952. }
  953. return {
  954. "colors": colors,
  955. "by_material": qualified_colors,
  956. "bambu": bambu,
  957. "preset_materials": preset_materials,
  958. }
  959. @router.post("/colors", response_model=ColorEntryResponse)
  960. async def add_color_entry(
  961. entry: ColorEntryCreate,
  962. db: AsyncSession = Depends(get_db, scope="function"),
  963. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  964. ):
  965. """Add a new color catalog entry."""
  966. row = ColorCatalogEntry(
  967. manufacturer=entry.manufacturer,
  968. color_name=entry.color_name,
  969. hex_color=entry.hex_color,
  970. material=entry.material,
  971. is_default=False,
  972. extra_colors=entry.extra_colors,
  973. effect_type=entry.effect_type,
  974. )
  975. db.add(row)
  976. await db.commit()
  977. await db.refresh(row)
  978. return row
  979. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  980. async def update_color_entry(
  981. entry_id: int,
  982. entry: ColorEntryUpdate,
  983. db: AsyncSession = Depends(get_db, scope="function"),
  984. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  985. ):
  986. """Update a color catalog entry."""
  987. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  988. row = result.scalar_one_or_none()
  989. if not row:
  990. raise HTTPException(404, "Entry not found")
  991. row.manufacturer = entry.manufacturer
  992. row.color_name = entry.color_name
  993. row.hex_color = entry.hex_color
  994. row.material = entry.material
  995. row.extra_colors = entry.extra_colors
  996. row.effect_type = entry.effect_type
  997. await db.commit()
  998. await db.refresh(row)
  999. return row
  1000. @router.delete("/colors/{entry_id}")
  1001. async def delete_color_entry(
  1002. entry_id: int,
  1003. db: AsyncSession = Depends(get_db, scope="function"),
  1004. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1005. ):
  1006. """Delete a color catalog entry."""
  1007. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  1008. row = result.scalar_one_or_none()
  1009. if not row:
  1010. raise HTTPException(404, "Entry not found")
  1011. await db.delete(row)
  1012. await db.commit()
  1013. return {"status": "deleted"}
  1014. @router.post("/colors/bulk-delete")
  1015. async def bulk_delete_color_entries(
  1016. data: BulkDeleteIdsRequest,
  1017. db: AsyncSession = Depends(get_db, scope="function"),
  1018. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1019. ):
  1020. """Delete multiple color catalog entries by ID."""
  1021. if not data.ids:
  1022. return {"deleted": 0}
  1023. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  1024. rows = result.scalars().all()
  1025. for row in rows:
  1026. await db.delete(row)
  1027. await db.commit()
  1028. return {"deleted": len(rows)}
  1029. @router.post("/colors/reset")
  1030. async def reset_color_catalog(
  1031. db: AsyncSession = Depends(get_db, scope="function"),
  1032. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1033. ):
  1034. """Reset color catalog to defaults."""
  1035. result = await db.execute(select(ColorCatalogEntry))
  1036. for row in result.scalars().all():
  1037. await db.delete(row)
  1038. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  1039. db.add(
  1040. ColorCatalogEntry(
  1041. manufacturer=manufacturer,
  1042. color_name=color_name,
  1043. hex_color=hex_color,
  1044. material=material,
  1045. is_default=True,
  1046. )
  1047. )
  1048. await db.commit()
  1049. return {"status": "reset"}
  1050. @router.get("/colors/lookup", response_model=ColorLookupResult)
  1051. async def lookup_color(
  1052. manufacturer: str,
  1053. color_name: str,
  1054. material: str | None = None,
  1055. db: AsyncSession = Depends(get_db, scope="function"),
  1056. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1057. ):
  1058. """Look up a color by manufacturer and color name."""
  1059. query = select(ColorCatalogEntry).where(
  1060. ColorCatalogEntry.manufacturer == manufacturer,
  1061. ColorCatalogEntry.color_name == color_name,
  1062. )
  1063. if material:
  1064. query = query.where(ColorCatalogEntry.material == material)
  1065. query = query.limit(1)
  1066. result = await db.execute(query)
  1067. row = result.scalar_one_or_none()
  1068. if row:
  1069. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  1070. return ColorLookupResult(found=False)
  1071. @router.get("/colors/by-material", response_model=ColorByMaterialResult)
  1072. async def get_color_by_material(
  1073. hex: str,
  1074. material: str | None = None,
  1075. db: AsyncSession = Depends(get_db, scope="function"),
  1076. _: User | None = Depends(require_auth_if_enabled),
  1077. ):
  1078. """Disambiguated hex→name lookup that respects material context.
  1079. ``/colors/map`` collapses every catalog entry sharing a hex to a single
  1080. name with "Bambu Lab > is_default > first" priority — that loses, e.g.,
  1081. "PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000).
  1082. This endpoint preserves the material context so the queue scheduler's
  1083. Filament Override label can show the actually-sliced sub-brand colour
  1084. instead of the generic bucket. #1718.
  1085. Returns ``color_name=None`` when the hex isn't in the catalog at all.
  1086. When the hex IS in the catalog but no entry matches the requested
  1087. material (or none was supplied), falls back to the same priority order
  1088. as ``/colors/map`` so callers without a material hint don't regress.
  1089. Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't —
  1090. every queue / archive view that renders a sliced filament colour needs
  1091. this, including read-only roles.
  1092. """
  1093. key = hex.lstrip("#").lower()[:6]
  1094. if len(key) != 6:
  1095. return ColorByMaterialResult(color_name=None)
  1096. material_norm = (material or "").strip().lower()
  1097. # Catalog rows are stored as ``#RRGGBB`` (verified at write time and
  1098. # against production); lookup uses lower-cased hex equality so mixed-case
  1099. # writes from older imports still match.
  1100. result = await db.execute(
  1101. select(
  1102. ColorCatalogEntry.color_name,
  1103. ColorCatalogEntry.manufacturer,
  1104. ColorCatalogEntry.material,
  1105. ColorCatalogEntry.is_default,
  1106. ).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}")
  1107. )
  1108. candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name]
  1109. if not candidates:
  1110. return ColorByMaterialResult(color_name=None)
  1111. if material_norm:
  1112. for name, _mfg, mat, _is_default in candidates:
  1113. if mat and mat.strip().lower() == material_norm:
  1114. return ColorByMaterialResult(color_name=name)
  1115. # Same priority order as ``/colors/map`` so a caller passing no (or an
  1116. # unrecognised) material gets the existing answer, not a degraded one.
  1117. best_name: str | None = None
  1118. best_priority = -1
  1119. for name, mfg, _mat, is_default in candidates:
  1120. priority = 0
  1121. if mfg and mfg.strip().lower() == "bambu lab":
  1122. priority += 2
  1123. if is_default:
  1124. priority += 1
  1125. if priority > best_priority:
  1126. best_name = name
  1127. best_priority = priority
  1128. return ColorByMaterialResult(color_name=best_name)
  1129. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  1130. async def search_colors(
  1131. manufacturer: str | None = None,
  1132. material: str | None = None,
  1133. db: AsyncSession = Depends(get_db, scope="function"),
  1134. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1135. ):
  1136. """Search colors by manufacturer and/or material."""
  1137. query = select(ColorCatalogEntry)
  1138. if manufacturer:
  1139. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  1140. if material:
  1141. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  1142. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  1143. result = await db.execute(query)
  1144. return list(result.scalars().all())
  1145. @router.post("/colors/sync")
  1146. async def sync_from_filamentcolors(
  1147. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1148. ):
  1149. """Sync colors from FilamentColors.xyz API with progress streaming."""
  1150. async def generate():
  1151. from backend.app.core.database import async_session
  1152. added = 0
  1153. skipped = 0
  1154. total_fetched = 0
  1155. total_available = 0
  1156. try:
  1157. # Identify honestly as Bambuddy rather than leaking httpx's
  1158. # default "python-httpx/x.y" UA — consistent with every other
  1159. # outbound client (bambu_cloud, makerworld, firmware_check).
  1160. async with httpx.AsyncClient(
  1161. timeout=120.0,
  1162. headers={"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"},
  1163. ) as client:
  1164. page = 1
  1165. while True:
  1166. response = await client.get(
  1167. f"{FILAMENT_COLORS_API}/swatch/",
  1168. params={"page": page},
  1169. )
  1170. response.raise_for_status()
  1171. data = response.json()
  1172. total_available = data.get("count", total_available)
  1173. results = data.get("results", [])
  1174. if not results:
  1175. break
  1176. async with async_session() as db:
  1177. for swatch in results:
  1178. total_fetched += 1
  1179. manufacturer_data = swatch.get("manufacturer")
  1180. manufacturer_name = (
  1181. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  1182. )
  1183. filament_type_data = swatch.get("filament_type")
  1184. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  1185. color_name_val = swatch.get("color_name", "")
  1186. hex_color_val = swatch.get("hex_color", "")
  1187. if not manufacturer_name or not color_name_val or not hex_color_val:
  1188. skipped += 1
  1189. continue
  1190. if not hex_color_val.startswith("#"):
  1191. hex_color_val = f"#{hex_color_val}"
  1192. # Check if entry already exists
  1193. existing = await db.execute(
  1194. select(ColorCatalogEntry)
  1195. .where(
  1196. ColorCatalogEntry.manufacturer == manufacturer_name,
  1197. ColorCatalogEntry.color_name == color_name_val,
  1198. ColorCatalogEntry.material == mat,
  1199. )
  1200. .limit(1)
  1201. )
  1202. if existing.scalar_one_or_none():
  1203. skipped += 1
  1204. else:
  1205. db.add(
  1206. ColorCatalogEntry(
  1207. manufacturer=manufacturer_name,
  1208. color_name=color_name_val,
  1209. hex_color=hex_color_val.upper(),
  1210. material=mat,
  1211. is_default=False,
  1212. )
  1213. )
  1214. added += 1
  1215. await db.commit()
  1216. progress = {
  1217. "type": "progress",
  1218. "added": added,
  1219. "skipped": skipped,
  1220. "total_fetched": total_fetched,
  1221. "total_available": total_available,
  1222. }
  1223. yield f"data: {json.dumps(progress)}\n\n"
  1224. if not data.get("next") or total_fetched >= total_available:
  1225. break
  1226. page += 1
  1227. result = {
  1228. "type": "complete",
  1229. "added": added,
  1230. "skipped": skipped,
  1231. "total_fetched": total_fetched,
  1232. "total_available": total_available,
  1233. }
  1234. yield f"data: {json.dumps(result)}\n\n"
  1235. except httpx.HTTPError as e:
  1236. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  1237. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  1238. except Exception as e:
  1239. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  1240. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  1241. return StreamingResponse(generate(), media_type="text/event-stream")
  1242. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  1243. @router.get("/spools", response_model=list[SpoolResponse])
  1244. async def list_spools(
  1245. include_archived: bool = False,
  1246. db: AsyncSession = Depends(get_db, scope="function"),
  1247. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1248. ):
  1249. """List all spools, excluding archived by default."""
  1250. query = select(Spool).options(*spool_response_loads())
  1251. if not include_archived:
  1252. query = query.where(Spool.archived_at.is_(None))
  1253. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  1254. result = await db.execute(query)
  1255. return list(result.scalars().all())
  1256. # ── CSV import / export (#1576) ──────────────────────────────────────────────
  1257. # Declared before the dynamic `/spools/{spool_id}` route below so the literal
  1258. # `export` / `import` segments match here instead of being parsed as an int id.
  1259. @router.get("/spools/export")
  1260. async def export_spools_csv(
  1261. db: AsyncSession = Depends(get_db, scope="function"),
  1262. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1263. ):
  1264. """Export the active inventory as CSV (same schema the importer accepts)."""
  1265. query = (
  1266. select(Spool)
  1267. # The supplier columns (#2988) read the assignments off each row.
  1268. .options(selectinload(Spool.supplier_links).selectinload(SpoolSupplier.supplier))
  1269. .where(Spool.archived_at.is_(None))
  1270. .order_by(Spool.material, Spool.brand, Spool.color_name)
  1271. )
  1272. result = await db.execute(query)
  1273. spools = list(result.scalars().all())
  1274. content = serialize(spools)
  1275. # Date-stamp the filename so repeat exports don't overwrite each other in
  1276. # the browser's default download folder.
  1277. filename = f"bambuddy_inventory_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
  1278. return Response(
  1279. content=content,
  1280. media_type="text/csv",
  1281. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  1282. )
  1283. @router.post("/spools/import", response_model=ImportPreview | ImportResult)
  1284. async def import_spools_csv(
  1285. file: UploadFile = File(...),
  1286. dry_run: bool = Query(False),
  1287. db: AsyncSession = Depends(get_db, scope="function"),
  1288. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1289. ):
  1290. """Import spools from a CSV file.
  1291. With ``dry_run=true`` returns an ImportPreview (per-row valid/error/skipped,
  1292. colours resolved) and writes nothing — the UI shows this before the user
  1293. confirms. With ``dry_run=false`` it validates the same way and then persists
  1294. only the valid rows in a single transaction (invalid rows are skipped, the
  1295. user fixes the CSV and re-uploads), returning an ImportResult summary.
  1296. """
  1297. def _too_large() -> HTTPException:
  1298. return HTTPException(
  1299. status_code=413,
  1300. detail={
  1301. "code": "csv_import_too_large",
  1302. "message": f"CSV file exceeds the {MAX_CSV_IMPORT_BYTES // (1024 * 1024)} MB limit.",
  1303. },
  1304. )
  1305. # Reject by declared size first (fast path when Content-Length is set), then
  1306. # read in bounded chunks and bail the moment the accumulated body crosses the
  1307. # cap — file.size is None for chunked uploads, so the loop is what actually
  1308. # keeps an oversized stream from filling memory.
  1309. if file.size is not None and file.size > MAX_CSV_IMPORT_BYTES:
  1310. raise _too_large()
  1311. raw = bytearray()
  1312. while chunk := await file.read(_CSV_UPLOAD_CHUNK_BYTES):
  1313. raw.extend(chunk)
  1314. if len(raw) > MAX_CSV_IMPORT_BYTES:
  1315. raise _too_large()
  1316. preview = await parse_and_validate(bytes(raw), db)
  1317. if dry_run:
  1318. return preview
  1319. created = 0
  1320. for row in preview.rows:
  1321. if row.status == "valid" and row.spool is not None:
  1322. # Deliberately no material-number inheritance here (#2870), unlike
  1323. # the other create paths: the file is authoritative. A CSV that
  1324. # leaves the column blank is stating "no number", not asking for
  1325. # one to be guessed from whatever else is in the inventory.
  1326. # The file has no lock column: a used weight in it is locked.
  1327. spool = Spool(**_lock_entered_weight(dict(row.spool), set()))
  1328. db.add(spool)
  1329. # Supplier assignments resolved by name during parsing (#2988).
  1330. # Flush first so the spool has an id to hang the links on.
  1331. if row.supplier_ids:
  1332. await db.flush()
  1333. for supplier_id in row.supplier_ids:
  1334. db.add(
  1335. SpoolSupplier(
  1336. spool_id=spool.id,
  1337. supplier_id=supplier_id,
  1338. is_purchase_source=supplier_id == row.purchase_supplier_id,
  1339. )
  1340. )
  1341. created += 1
  1342. if created:
  1343. await db.commit()
  1344. await ws_manager.broadcast({"type": "inventory_changed"})
  1345. return ImportResult(
  1346. created=created,
  1347. skipped=preview.skipped_count,
  1348. errors=preview.error_count,
  1349. error_rows=[r for r in preview.rows if r.status == "error"],
  1350. )
  1351. @router.get("/spools/by-tag", response_model=SpoolResponse)
  1352. async def get_spool_by_tag(
  1353. tray_uuid: str | None = None,
  1354. tag_uid: str | None = None,
  1355. include_archived: bool = False,
  1356. db: AsyncSession = Depends(get_db, scope="function"),
  1357. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
  1358. ):
  1359. """Find a single spool by its NFC ``tray_uuid`` and/or ``tag_uid``.
  1360. Lets NFC inventory integrations dedupe a scan without listing the whole
  1361. inventory. ``tray_uuid`` is the primary identifier (it matches the value the
  1362. AMS reports over MQTT), so it is tried first; ``tag_uid`` is the fallback.
  1363. At least one identifier must be supplied. Returns 404 when nothing matches.
  1364. Accepts ``inventory:read`` OR ``inventory:update`` so a Manage-Inventory API
  1365. key (which has ``inventory:update`` via ``can_manage_inventory``) can read a
  1366. spool back without widening the global ``INVENTORY_READ`` scope mapping (#1663).
  1367. """
  1368. normalized_tray_uuid = normalize_tray_uuid(tray_uuid) or None
  1369. normalized_tag_uid = normalize_tag_uid(tag_uid) or None
  1370. if not normalized_tray_uuid and not normalized_tag_uid:
  1371. raise HTTPException(400, "Provide tray_uuid and/or tag_uid")
  1372. base_query = select(Spool).options(*spool_response_loads())
  1373. if not include_archived:
  1374. base_query = base_query.where(Spool.archived_at.is_(None))
  1375. for column, value in (
  1376. (Spool.tray_uuid, normalized_tray_uuid),
  1377. (Spool.tag_uid, normalized_tag_uid),
  1378. ):
  1379. if not value:
  1380. continue
  1381. result = await db.execute(base_query.where(func.upper(column) == value).order_by(Spool.id))
  1382. spool = result.scalars().first()
  1383. if spool:
  1384. return spool
  1385. raise HTTPException(404, "Spool not found")
  1386. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  1387. async def get_spool(
  1388. spool_id: int,
  1389. db: AsyncSession = Depends(get_db, scope="function"),
  1390. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1391. ):
  1392. """Get a single spool with k_profiles."""
  1393. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  1394. spool = result.scalar_one_or_none()
  1395. if not spool:
  1396. raise HTTPException(404, "Spool not found")
  1397. return spool
  1398. def _lock_entered_weight(payload: dict, fields_set: set[str]) -> dict:
  1399. """A used weight entered for a new spool is locked, as an edited one is:
  1400. otherwise the AMS remain% sync can overwrite it on its first pass. An
  1401. explicit ``weight_locked`` from the client wins."""
  1402. if (payload.get("weight_used") or 0) > 0 and "weight_locked" not in fields_set:
  1403. payload["weight_locked"] = True
  1404. return payload
  1405. @router.post("/spools", response_model=SpoolResponse)
  1406. async def create_spool(
  1407. spool_data: SpoolCreate,
  1408. db: AsyncSession = Depends(get_db, scope="function"),
  1409. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1410. ):
  1411. """Create a new spool."""
  1412. try:
  1413. payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
  1414. except ValueError as exc:
  1415. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1416. payload = _lock_entered_weight(payload, set(spool_data.model_fields_set))
  1417. # A new spool of an already-numbered product inherits its material number (#2870).
  1418. payload = await apply_material_number_inheritance(db, payload)
  1419. spool = Spool(**payload)
  1420. db.add(spool)
  1421. await db.flush()
  1422. # A new spool of a product that already carries supplier assignments
  1423. # inherits the source list (#2988).
  1424. await apply_supplier_inheritance(db, spool)
  1425. # ...and the product's profile default, where the request brought none (#1326).
  1426. await profile_defaults.fill_internal_spool(db, spool)
  1427. await db.commit()
  1428. await db.refresh(spool)
  1429. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool.id))
  1430. await ws_manager.broadcast({"type": "inventory_changed"})
  1431. return result.scalar_one()
  1432. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  1433. async def bulk_create_spools(
  1434. data: SpoolBulkCreate,
  1435. db: AsyncSession = Depends(get_db, scope="function"),
  1436. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1437. ):
  1438. """Create multiple identical spools."""
  1439. spools = []
  1440. fields_set = set(data.spool.model_fields_set)
  1441. try:
  1442. payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
  1443. except ValueError as exc:
  1444. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1445. payload = _lock_entered_weight(payload, fields_set)
  1446. # A new spool of an already-numbered product inherits its material number (#2870).
  1447. payload = await apply_material_number_inheritance(db, payload)
  1448. for _ in range(data.quantity):
  1449. spool = Spool(**payload)
  1450. db.add(spool)
  1451. spools.append(spool)
  1452. await db.flush()
  1453. # Every copy gets its own link rows, from one donor lookup (#2988).
  1454. await apply_supplier_inheritance_to_batch(db, spools)
  1455. # Every copy is the same product, so one profile default for all (#1326).
  1456. if spools:
  1457. first = spools[0]
  1458. resolved = await profile_defaults.resolve_profile_default(
  1459. db, brand=first.brand, material=first.material, subtype=first.subtype, color_name=first.color_name
  1460. )
  1461. for spool in spools:
  1462. await profile_defaults.fill_internal_spool(db, spool, resolved=resolved)
  1463. await db.commit()
  1464. ids = [s.id for s in spools]
  1465. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id.in_(ids)))
  1466. await ws_manager.broadcast({"type": "inventory_changed"})
  1467. return list(result.scalars().all())
  1468. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  1469. async def update_spool(
  1470. spool_id: int,
  1471. spool_data: SpoolUpdate,
  1472. db: AsyncSession = Depends(get_db, scope="function"),
  1473. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1474. ):
  1475. """Update a spool."""
  1476. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1477. spool = result.scalar_one_or_none()
  1478. if not spool:
  1479. raise HTTPException(404, "Spool not found")
  1480. update_data = spool_data.model_dump(exclude_unset=True)
  1481. try:
  1482. update_data = await prepare_internal_spool_payload(db, update_data, set(spool_data.model_fields_set))
  1483. except ValueError as exc:
  1484. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1485. # Auto-lock weight when user explicitly sets weight_used
  1486. if "weight_used" in update_data and "weight_locked" not in update_data:
  1487. update_data["weight_locked"] = True
  1488. # A drying date set by hand is not the AMS cycle the temperature and
  1489. # hours describe, so they go with it (#2863).
  1490. if "last_dried_at" in update_data:
  1491. update_data["last_dried_temp"] = None
  1492. update_data["last_dried_hours"] = None
  1493. for field, value in update_data.items():
  1494. setattr(spool, field, value)
  1495. await db.commit()
  1496. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  1497. await ws_manager.broadcast({"type": "inventory_changed"})
  1498. return result.scalar_one()
  1499. @router.delete("/spools/{spool_id}")
  1500. async def delete_spool(
  1501. spool_id: int,
  1502. db: AsyncSession = Depends(get_db, scope="function"),
  1503. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1504. ):
  1505. """Hard delete a spool."""
  1506. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1507. spool = result.scalar_one_or_none()
  1508. if not spool:
  1509. raise HTTPException(404, "Spool not found")
  1510. freed = await _release_slots(db, [spool.id])
  1511. await db.delete(spool)
  1512. await db.commit()
  1513. await ws_manager.broadcast({"type": "inventory_changed"})
  1514. await _announce_freed_slots(freed)
  1515. return {"status": "deleted"}
  1516. async def _release_slots(db: AsyncSession, spool_ids: list[int]) -> list[tuple[int, int, int]]:
  1517. """Unassign archived or deleted spools from their AMS slots; returns the
  1518. freed slots.
  1519. Assigning refuses an archived spool and nothing downstream checks the flag,
  1520. so a slot left pointing at one would keep showing it and charge it the next
  1521. print's usage. A deleted spool's slots go with it, but other tabs refresh
  1522. their slot views only when the slots are announced. The caller commits,
  1523. then announces the slots.
  1524. """
  1525. if not spool_ids:
  1526. return []
  1527. rows = (await db.execute(select(SpoolAssignment).where(SpoolAssignment.spool_id.in_(spool_ids)))).scalars().all()
  1528. freed = [(a.printer_id, a.ams_id, a.tray_id) for a in rows]
  1529. for assignment in rows:
  1530. await db.delete(assignment)
  1531. return freed
  1532. async def _announce_freed_slots(freed: list[tuple[int, int, int]]) -> None:
  1533. for printer_id, ams_id, tray_id in freed:
  1534. await ws_manager.broadcast(
  1535. {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
  1536. )
  1537. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  1538. async def archive_spool(
  1539. spool_id: int,
  1540. db: AsyncSession = Depends(get_db, scope="function"),
  1541. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1542. ):
  1543. """Soft-delete a spool by setting archived_at."""
  1544. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1545. spool = result.scalar_one_or_none()
  1546. if not spool:
  1547. raise HTTPException(404, "Spool not found")
  1548. spool.archived_at = datetime.now(timezone.utc)
  1549. freed = await _release_slots(db, [spool.id])
  1550. await db.commit()
  1551. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  1552. await ws_manager.broadcast({"type": "inventory_changed"})
  1553. await _announce_freed_slots(freed)
  1554. return result.scalar_one()
  1555. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  1556. async def restore_spool(
  1557. spool_id: int,
  1558. db: AsyncSession = Depends(get_db, scope="function"),
  1559. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1560. ):
  1561. """Restore an archived spool."""
  1562. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1563. spool = result.scalar_one_or_none()
  1564. if not spool:
  1565. raise HTTPException(404, "Spool not found")
  1566. spool.archived_at = None
  1567. await db.commit()
  1568. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  1569. await ws_manager.broadcast({"type": "inventory_changed"})
  1570. return result.scalar_one()
  1571. @router.post("/spools/{spool_id}/reset-consumed-counter", response_model=SpoolResponse)
  1572. async def reset_spool_consumed_counter(
  1573. spool_id: int,
  1574. db: AsyncSession = Depends(get_db, scope="function"),
  1575. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1576. ):
  1577. """Zero the displayed "Total Consumed" counter without touching remaining.
  1578. Stamps `weight_used_baseline = weight_used` so the Inventory page's
  1579. `weight_used - baseline` display reads 0, while `label_weight -
  1580. weight_used` (remaining) is unchanged. weight_locked is also left
  1581. alone — the spool keeps receiving AMS auto-sync updates. Matches
  1582. Spoolman's split between used_weight and remaining_weight (#1390).
  1583. The earlier name `/reset-usage` was misleading: callers reasonably
  1584. expected `weight_used` itself to drop to 0 and were surprised when
  1585. the response showed it unchanged. The current name describes what
  1586. the endpoint actually does — reset the "Total Consumed" counter
  1587. widget, not the lifetime weight_used field.
  1588. """
  1589. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1590. spool = result.scalar_one_or_none()
  1591. if not spool:
  1592. raise HTTPException(404, "Spool not found")
  1593. spool.weight_used_baseline = spool.weight_used or 0
  1594. await db.commit()
  1595. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  1596. await ws_manager.broadcast({"type": "inventory_changed"})
  1597. return result.scalar_one()
  1598. @router.post("/spools/reset-consumed-counter-bulk")
  1599. async def bulk_reset_spool_consumed_counter(
  1600. payload: dict,
  1601. db: AsyncSession = Depends(get_db, scope="function"),
  1602. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1603. ):
  1604. """Bulk-stamp baseline = weight_used across the given spool IDs.
  1605. Caller passes an explicit list of IDs — no "reset all" shortcut, since
  1606. a typo on a wildcard would wipe the entire inventory's tracking.
  1607. Same semantics as the per-spool endpoint: remaining is preserved,
  1608. weight_locked is left alone.
  1609. """
  1610. spool_ids = payload.get("spool_ids")
  1611. if not isinstance(spool_ids, list) or not spool_ids:
  1612. raise HTTPException(400, "spool_ids must be a non-empty list")
  1613. if not all(isinstance(sid, int) for sid in spool_ids):
  1614. raise HTTPException(400, "spool_ids must contain integers")
  1615. result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
  1616. spools = list(result.scalars().all())
  1617. for spool in spools:
  1618. spool.weight_used_baseline = spool.weight_used or 0
  1619. await db.commit()
  1620. await ws_manager.broadcast({"type": "inventory_changed"})
  1621. found = {spool.id for spool in spools}
  1622. return {"reset": len(spools), "not_found": [sid for sid in spool_ids if sid not in found]}
  1623. class BulkUpdateRequest(BaseModel):
  1624. ids: list[int] = Field(..., min_length=1, max_length=500)
  1625. update: SpoolUpdate
  1626. class BulkIdsRequest(BaseModel):
  1627. ids: list[int] = Field(..., min_length=1, max_length=500)
  1628. @router.post("/spools/bulk-update")
  1629. async def bulk_update_spools(
  1630. payload: BulkUpdateRequest,
  1631. db: AsyncSession = Depends(get_db, scope="function"),
  1632. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1633. ):
  1634. """Apply the same partial update to every listed spool.
  1635. Per-spool errors are collected and returned alongside the success count so
  1636. a single bad ID doesn't abort the whole batch. Unknown IDs are reported
  1637. in the ``not_found`` list.
  1638. """
  1639. update_data = payload.update.model_dump(exclude_unset=True)
  1640. fields_set = set(payload.update.model_fields_set)
  1641. if not update_data:
  1642. raise HTTPException(status_code=400, detail="update must include at least one field")
  1643. try:
  1644. prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
  1645. except ValueError as exc:
  1646. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1647. # Auto-lock weight when the user explicitly sets weight_used — mirrors the
  1648. # per-spool PATCH behaviour so bulk edits don't desync the lock state.
  1649. if "weight_used" in prepared and "weight_locked" not in prepared:
  1650. prepared["weight_locked"] = True
  1651. # Same rule as the per-spool PATCH: a hand-set drying date drops the
  1652. # temperature and hours of the AMS cycle it replaces (#2863).
  1653. if "last_dried_at" in prepared:
  1654. prepared["last_dried_temp"] = None
  1655. prepared["last_dried_hours"] = None
  1656. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1657. spools = {s.id: s for s in result.scalars().all()}
  1658. not_found = [sid for sid in payload.ids if sid not in spools]
  1659. updated_ids: list[int] = []
  1660. for sid, spool in spools.items():
  1661. for field, value in prepared.items():
  1662. setattr(spool, field, value)
  1663. updated_ids.append(sid)
  1664. await db.commit()
  1665. if updated_ids:
  1666. await ws_manager.broadcast({"type": "inventory_changed"})
  1667. return {"updated": len(updated_ids), "not_found": not_found}
  1668. @router.post("/spools/bulk-delete")
  1669. async def bulk_delete_spools(
  1670. payload: BulkIdsRequest,
  1671. db: AsyncSession = Depends(get_db, scope="function"),
  1672. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1673. ):
  1674. """Hard-delete every listed spool. Unknown IDs are returned in not_found."""
  1675. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1676. spools = list(result.scalars().all())
  1677. found_ids = {s.id for s in spools}
  1678. not_found = [sid for sid in payload.ids if sid not in found_ids]
  1679. freed = await _release_slots(db, list(found_ids))
  1680. for spool in spools:
  1681. await db.delete(spool)
  1682. await db.commit()
  1683. if spools:
  1684. await ws_manager.broadcast({"type": "inventory_changed"})
  1685. await _announce_freed_slots(freed)
  1686. return {"deleted": len(spools), "not_found": not_found}
  1687. @router.post("/spools/bulk-archive")
  1688. async def bulk_archive_spools(
  1689. payload: BulkIdsRequest,
  1690. db: AsyncSession = Depends(get_db, scope="function"),
  1691. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1692. ):
  1693. """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
  1694. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1695. spools = list(result.scalars().all())
  1696. found_ids = {s.id for s in spools}
  1697. not_found = [sid for sid in payload.ids if sid not in found_ids]
  1698. archived: list[int] = []
  1699. already: list[int] = []
  1700. now = datetime.now(timezone.utc)
  1701. for spool in spools:
  1702. if spool.archived_at is not None:
  1703. already.append(spool.id)
  1704. continue
  1705. spool.archived_at = now
  1706. archived.append(spool.id)
  1707. # Already-archived ones too: archived before archiving released slots.
  1708. freed = await _release_slots(db, archived + already)
  1709. await db.commit()
  1710. if archived:
  1711. await ws_manager.broadcast({"type": "inventory_changed"})
  1712. await _announce_freed_slots(freed)
  1713. return {"archived": len(archived), "already_archived": already, "not_found": not_found}
  1714. @router.post("/spools/bulk-restore")
  1715. async def bulk_restore_spools(
  1716. payload: BulkIdsRequest,
  1717. db: AsyncSession = Depends(get_db, scope="function"),
  1718. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1719. ):
  1720. """Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
  1721. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1722. spools = list(result.scalars().all())
  1723. found_ids = {s.id for s in spools}
  1724. not_found = [sid for sid in payload.ids if sid not in found_ids]
  1725. restored: list[int] = []
  1726. already: list[int] = []
  1727. for spool in spools:
  1728. if spool.archived_at is None:
  1729. already.append(spool.id)
  1730. continue
  1731. spool.archived_at = None
  1732. restored.append(spool.id)
  1733. await db.commit()
  1734. if restored:
  1735. await ws_manager.broadcast({"type": "inventory_changed"})
  1736. return {"restored": len(restored), "already_active": already, "not_found": not_found}
  1737. # ── K-Profiles ───────────────────────────────────────────────────────────────
  1738. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  1739. async def list_k_profiles(
  1740. spool_id: int,
  1741. db: AsyncSession = Depends(get_db, scope="function"),
  1742. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1743. ):
  1744. """List K-profiles for a spool."""
  1745. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1746. return list(result.scalars().all())
  1747. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  1748. async def replace_k_profiles(
  1749. spool_id: int,
  1750. profiles: list[SpoolKProfileBase],
  1751. db: AsyncSession = Depends(get_db, scope="function"),
  1752. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1753. printer_scope: PrinterScope = RequestPrinterScope,
  1754. ):
  1755. """Replace all K-profiles for a spool (batch save).
  1756. A caller limited to some printers (#1727) replaces only theirs: the
  1757. profiles of printers it cannot see -- which its form never showed, a new
  1758. spool's from its product's profile default (#1326) among them -- stay.
  1759. """
  1760. # Verify spool exists
  1761. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1762. if not result.scalar_one_or_none():
  1763. raise HTTPException(404, "Spool not found")
  1764. # Delete existing
  1765. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1766. for old in existing.scalars().all():
  1767. if printer_scope.allows(old.printer_id):
  1768. await db.delete(old)
  1769. # Create new
  1770. new_profiles = []
  1771. for p in profiles:
  1772. if not printer_scope.allows(p.printer_id):
  1773. continue
  1774. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  1775. db.add(kp)
  1776. new_profiles.append(kp)
  1777. await db.commit()
  1778. for kp in new_profiles:
  1779. await db.refresh(kp)
  1780. return new_profiles
  1781. @router.put("/spools/{spool_id}/suppliers", response_model=list[SpoolSupplierResponse])
  1782. async def replace_spool_suppliers(
  1783. spool_id: int,
  1784. links: list[SpoolSupplierLinkInput],
  1785. db: AsyncSession = Depends(get_db, scope="function"),
  1786. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1787. ):
  1788. """Replace a spool's supplier assignments (#2988, batch save).
  1789. Same replace-all shape as the k-profiles endpoint. At most one assignment
  1790. may be the purchase source — the record of where this concrete spool was
  1791. actually bought; the rest read as alternative sources.
  1792. """
  1793. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1794. if not result.scalar_one_or_none():
  1795. raise HTTPException(404, "Spool not found")
  1796. supplier_ids = [link.supplier_id for link in links]
  1797. if len(set(supplier_ids)) != len(supplier_ids):
  1798. raise HTTPException(400, "Duplicate supplier in assignment list")
  1799. if sum(1 for link in links if link.is_purchase_source) > 1:
  1800. raise HTTPException(400, "Only one assignment can be the purchase source")
  1801. if supplier_ids:
  1802. found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids)))
  1803. missing = set(supplier_ids) - {row[0] for row in found.all()}
  1804. if missing:
  1805. raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}")
  1806. existing = await db.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == spool_id))
  1807. for old in existing.scalars().all():
  1808. await db.delete(old)
  1809. await db.flush()
  1810. new_links = []
  1811. for link in links:
  1812. row = SpoolSupplier(spool_id=spool_id, **link.model_dump())
  1813. db.add(row)
  1814. new_links.append(row)
  1815. await db.commit()
  1816. refreshed = await db.execute(
  1817. select(SpoolSupplier)
  1818. .options(selectinload(SpoolSupplier.supplier))
  1819. .where(SpoolSupplier.id.in_([row.id for row in new_links]))
  1820. .order_by(SpoolSupplier.id)
  1821. )
  1822. await ws_manager.broadcast({"type": "inventory_changed"})
  1823. return list(refreshed.scalars().all())
  1824. @router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
  1825. async def list_filament_presets(
  1826. spool_id: int,
  1827. db: AsyncSession = Depends(get_db, scope="function"),
  1828. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1829. ):
  1830. """List per-printer-model preset overrides for a spool.
  1831. A dedicated endpoint rather than a field on ``SpoolResponse``: the
  1832. inventory list returns every spool the user owns, and only the spool form
  1833. and the assign path ever need this list, one spool at a time.
  1834. """
  1835. result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
  1836. return list(result.scalars().all())
  1837. @router.put("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
  1838. async def replace_filament_presets(
  1839. spool_id: int,
  1840. presets: list[SpoolFilamentPresetBase],
  1841. db: AsyncSession = Depends(get_db, scope="function"),
  1842. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1843. ):
  1844. """Replace all per-printer-model preset overrides for a spool.
  1845. Replace rather than merge, matching the K-profile endpoint next door: the
  1846. spool form always holds the complete set, and an empty list is how the
  1847. user clears every override back to the spool's own preset.
  1848. """
  1849. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1850. if not result.scalar_one_or_none():
  1851. raise HTTPException(404, "Spool not found")
  1852. # (model, diameter) is UNIQUE, so a payload that names one twice would
  1853. # fail on flush with an IntegrityError the client cannot act on. Reject it
  1854. # by name instead -- and reject it BEFORE deleting the existing rows, so a
  1855. # bad request cannot wipe overrides it then fails to replace.
  1856. seen: set[tuple[str, str]] = set()
  1857. for p in presets:
  1858. key = (p.printer_model, p.nozzle_diameter)
  1859. if key in seen:
  1860. raise HTTPException(
  1861. 422,
  1862. f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}",
  1863. )
  1864. seen.add(key)
  1865. existing = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
  1866. for old in existing.scalars().all():
  1867. await db.delete(old)
  1868. # Land the deletes before the inserts: within one transaction SQLAlchemy is
  1869. # free to order the INSERTs first, which trips the UNIQUE constraint
  1870. # against rows this call is about to remove.
  1871. await db.flush()
  1872. new_presets = []
  1873. for p in presets:
  1874. row = SpoolFilamentPreset(spool_id=spool_id, **p.model_dump())
  1875. db.add(row)
  1876. new_presets.append(row)
  1877. await db.commit()
  1878. for row in new_presets:
  1879. await db.refresh(row)
  1880. return new_presets
  1881. # ── Profile defaults (#1326) ─────────────────────────────────────────────────
  1882. #
  1883. # Shared by both inventory modes: a default is keyed on the product, not on a
  1884. # spool. Applying one to existing spools is the only mode-specific part, so
  1885. # the Spoolman twin of the apply route lives in spoolman_inventory.py.
  1886. def _profile_default_response(row: FilamentProfileDefault, scope: PrinterScope) -> FilamentProfileDefaultResponse:
  1887. return FilamentProfileDefaultResponse(
  1888. id=row.id,
  1889. brand=row.brand,
  1890. material=row.material,
  1891. subtype=row.subtype,
  1892. color_name=row.color_name,
  1893. slicer_filament=row.slicer_filament,
  1894. slicer_filament_name=row.slicer_filament_name,
  1895. presets=[SpoolFilamentPresetBase.model_validate(p, from_attributes=True) for p in row.presets],
  1896. # A caller limited to some printers (#1727) sees only theirs.
  1897. k_profiles=[
  1898. SpoolKProfileBase.model_validate(k, from_attributes=True)
  1899. for k in row.k_profiles
  1900. if scope.allows(k.printer_id)
  1901. ],
  1902. updated_at=row.updated_at,
  1903. )
  1904. @router.get("/profile-defaults", response_model=list[FilamentProfileDefaultResponse])
  1905. async def list_profile_defaults(
  1906. db: AsyncSession = Depends(get_db, scope="function"),
  1907. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1908. printer_scope: PrinterScope = RequestPrinterScope,
  1909. ):
  1910. """Every profile default, ordered by product."""
  1911. result = await db.execute(
  1912. select(FilamentProfileDefault)
  1913. .options(selectinload(FilamentProfileDefault.presets), selectinload(FilamentProfileDefault.k_profiles))
  1914. .order_by(
  1915. func.lower(FilamentProfileDefault.brand),
  1916. func.lower(FilamentProfileDefault.material),
  1917. func.lower(FilamentProfileDefault.subtype),
  1918. func.lower(FilamentProfileDefault.color_name),
  1919. )
  1920. )
  1921. return [_profile_default_response(row, printer_scope) for row in result.scalars().all()]
  1922. @router.get("/profile-defaults/resolve", response_model=ResolvedProfileDefault | None)
  1923. async def resolve_profile_default(
  1924. material: str,
  1925. brand: str = "",
  1926. subtype: str = "",
  1927. color_name: str = "",
  1928. db: AsyncSession = Depends(get_db, scope="function"),
  1929. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1930. printer_scope: PrinterScope = RequestPrinterScope,
  1931. ):
  1932. """What a new spool of this product starts with; null when it has no default.
  1933. The spool form fills its Printers tab from this, so the user sees what the
  1934. create will apply before saving.
  1935. """
  1936. resolved = await profile_defaults.resolve_profile_default(
  1937. db, brand=brand, material=material, subtype=subtype, color_name=color_name
  1938. )
  1939. if resolved is None:
  1940. return None
  1941. return ResolvedProfileDefault(
  1942. slicer_filament=resolved.slicer_filament,
  1943. slicer_filament_name=resolved.slicer_filament_name,
  1944. presets=[SpoolFilamentPresetBase(**p) for p in resolved.presets],
  1945. k_profiles=[SpoolKProfileBase(**k) for k in resolved.k_profiles if printer_scope.allows(k["printer_id"])],
  1946. default_ids=resolved.default_ids,
  1947. )
  1948. @router.put("/profile-defaults", response_model=FilamentProfileDefaultResponse)
  1949. async def save_profile_default(
  1950. data: FilamentProfileDefaultWrite,
  1951. db: AsyncSession = Depends(get_db, scope="function"),
  1952. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1953. printer_scope: PrinterScope = RequestPrinterScope,
  1954. ):
  1955. """Create or replace the default for the product in the body.
  1956. A full replacement, like the spool's own K-profile and preset endpoints:
  1957. the body is everything the default holds. K profiles of printers the
  1958. caller cannot see are kept rather than cleared, and the body's rows for
  1959. them are ignored.
  1960. """
  1961. # Duplicates rejected by name, before anything is written.
  1962. seen_presets: set[tuple[str, str]] = set()
  1963. for p in data.presets:
  1964. key = (p.printer_model, p.nozzle_diameter)
  1965. if key in seen_presets:
  1966. raise HTTPException(
  1967. 422, f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}"
  1968. )
  1969. seen_presets.add(key)
  1970. seen_hotends: set[tuple[int, int, str]] = set()
  1971. for k in data.k_profiles:
  1972. key = profile_defaults.hotend_key(k.printer_id, k.extruder, k.nozzle_diameter)
  1973. if key in seen_hotends:
  1974. raise HTTPException(422, f"Duplicate K profile for printer {k.printer_id} extruder {k.extruder}")
  1975. seen_hotends.add(key)
  1976. # Rows for printers the caller cannot see are ignored, as the spool's own
  1977. # K-profile save does: a form opened on a spool shows them all, and saving
  1978. # it as the default must not fail over rows the caller cannot change.
  1979. k_profiles = [k for k in data.k_profiles if printer_scope.allows(k.printer_id)]
  1980. printer_ids = {k.printer_id for k in k_profiles}
  1981. if printer_ids:
  1982. found = set((await db.execute(select(Printer.id).where(Printer.id.in_(printer_ids)))).scalars().all())
  1983. if printer_ids - found:
  1984. raise HTTPException(404, "Printer not found")
  1985. key = profile_defaults.product_key(
  1986. brand=data.brand, material=data.material, subtype=data.subtype, color_name=data.color_name
  1987. )
  1988. existing = await profile_defaults.find_default(db, key)
  1989. keep = (
  1990. {k.printer_id for k in existing.k_profiles if not printer_scope.allows(k.printer_id)}
  1991. if existing is not None
  1992. else set()
  1993. )
  1994. row = await profile_defaults.save_default(
  1995. db,
  1996. key,
  1997. slicer_filament=data.slicer_filament,
  1998. slicer_filament_name=data.slicer_filament_name,
  1999. presets=[p.model_dump() for p in data.presets],
  2000. k_profiles=[k.model_dump() for k in k_profiles],
  2001. keep_printer_ids=keep,
  2002. )
  2003. await db.commit()
  2004. row = await profile_defaults.load_default(db, row.id)
  2005. assert row is not None
  2006. return _profile_default_response(row, printer_scope)
  2007. @router.delete("/profile-defaults/{default_id}")
  2008. async def delete_profile_default(
  2009. default_id: int,
  2010. db: AsyncSession = Depends(get_db, scope="function"),
  2011. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2012. printer_scope: PrinterScope = RequestPrinterScope,
  2013. ):
  2014. """Delete a default. Spools that already got its profiles keep them."""
  2015. row = await profile_defaults.load_default(db, default_id)
  2016. if row is None:
  2017. raise HTTPException(404, "Profile default not found")
  2018. if any(not printer_scope.allows(k.printer_id) for k in row.k_profiles):
  2019. raise HTTPException(403, "This default holds K profiles for printers you cannot access")
  2020. await db.delete(row)
  2021. await db.commit()
  2022. return {"status": "deleted"}
  2023. @router.post("/profile-defaults/{default_id}/apply", response_model=ProfileDefaultApplyResult)
  2024. async def apply_profile_default(
  2025. default_id: int,
  2026. db: AsyncSession = Depends(get_db, scope="function"),
  2027. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2028. ):
  2029. """Fill in what active spools of this default's product do not have yet.
  2030. Never overwrites: a spool keeps its own preset, its preset per model and
  2031. nozzle, and its K profile per hotend.
  2032. """
  2033. row = await profile_defaults.load_default(db, default_id)
  2034. if row is None:
  2035. raise HTTPException(404, "Profile default not found")
  2036. updated = await profile_defaults.apply_to_internal_spools(db, row)
  2037. await db.commit()
  2038. if updated:
  2039. await ws_manager.broadcast({"type": "inventory_changed"})
  2040. return ProfileDefaultApplyResult(updated=updated)
  2041. # ── Spool Assignments ────────────────────────────────────────────────────────
  2042. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  2043. async def list_assignments(
  2044. printer_id: int | None = None,
  2045. db: AsyncSession = Depends(get_db, scope="function"),
  2046. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  2047. printer_scope: PrinterScope = RequestPrinterScope,
  2048. ):
  2049. """List spool assignments, optionally filtered by printer."""
  2050. from backend.app.services.printer_manager import printer_manager
  2051. query = select(SpoolAssignment).options(
  2052. selectinload(SpoolAssignment.spool).options(*spool_response_loads()),
  2053. selectinload(SpoolAssignment.printer),
  2054. )
  2055. if printer_id is not None:
  2056. query = query.where(SpoolAssignment.printer_id == printer_id)
  2057. if (clause := printer_scope.where_strict(SpoolAssignment.printer_id)) is not None:
  2058. query = query.where(clause)
  2059. result = await db.execute(query)
  2060. assignments = list(result.scalars().all())
  2061. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  2062. # Fetch all statuses in one call rather than one get_status() call per printer.
  2063. serial_map: dict[tuple[int, int], str] = {}
  2064. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  2065. all_statuses = printer_manager.get_all_statuses()
  2066. for pid in seen_printer_ids:
  2067. state = all_statuses.get(pid)
  2068. if state and state.raw_data:
  2069. for ams_unit in state.raw_data.get("ams", []):
  2070. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  2071. if sn:
  2072. try:
  2073. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  2074. except (ValueError, TypeError):
  2075. continue
  2076. # Fetch all relevant AMS labels keyed by serial number
  2077. all_serials = set(serial_map.values())
  2078. # Also include the synthetic position keys: a label saved before the unit
  2079. # reported its serial (the AMS Lite before #3348) is still under one, and
  2080. # is shown until it is saved again, as on the printer card.
  2081. for a in assignments:
  2082. all_serials.add(f"p{a.printer_id}a{a.ams_id}")
  2083. label_by_serial: dict[str, str] = {}
  2084. if all_serials:
  2085. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  2086. for lbl in lbl_result.scalars().all():
  2087. label_by_serial[lbl.ams_serial_number] = lbl.label
  2088. # Build response objects, attaching ams_label where available
  2089. responses: list[SpoolAssignmentResponse] = []
  2090. for a in assignments:
  2091. resp = SpoolAssignmentResponse.model_validate(a)
  2092. sn = serial_map.get((a.printer_id, a.ams_id))
  2093. if sn and sn in label_by_serial:
  2094. resp.ams_label = label_by_serial[sn]
  2095. else:
  2096. resp.ams_label = label_by_serial.get(f"p{a.printer_id}a{a.ams_id}")
  2097. responses.append(resp)
  2098. return responses
  2099. async def _printers_holding_spool(db: AsyncSession, spool_id: int, printer_scope: PrinterScope) -> set[int]:
  2100. """The printers in the caller's scope with a slot assigned to the spool."""
  2101. held = await db.execute(select(SpoolAssignment.printer_id).where(SpoolAssignment.spool_id == spool_id).distinct())
  2102. return {pid for pid in held.scalars() if printer_scope.allows(pid)}
  2103. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  2104. async def assign_spool(
  2105. data: SpoolAssignmentCreate,
  2106. db: AsyncSession = Depends(get_db, scope="function"),
  2107. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2108. printer_scope: PrinterScope = RequestPrinterScope,
  2109. ):
  2110. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  2111. from backend.app.services.printer_manager import printer_manager
  2112. printer_scope.ensure(data.printer_id)
  2113. # 1. Validate spool exists and is not archived
  2114. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == data.spool_id))
  2115. spool = result.scalar_one_or_none()
  2116. if not spool:
  2117. raise HTTPException(404, "Spool not found")
  2118. if spool.archived_at:
  2119. raise HTTPException(400, "Cannot assign an archived spool")
  2120. # 2. Get current AMS tray state for fingerprint + existing filament ID.
  2121. # tray_state: Bambu firmware reports 11=loaded, 9=empty, 10=spool present
  2122. # but filament not in feeder. Captured here so the empty-slot heuristic
  2123. # below can prefer it over tray_type — a manual "Reset slot" clears
  2124. # tray_type to "" while leaving state at 11 (filament still physically
  2125. # present), which would otherwise mislead the heuristic into the
  2126. # pending-config branch and skip MQTT forever (#1228 follow-up).
  2127. fingerprint_color = None
  2128. fingerprint_type = None
  2129. current_tray_info_idx = ""
  2130. current_tray_sub_brands = ""
  2131. tray_state: int | None = None
  2132. # Firmware's tray_exist_bits answer for this slot, when the payload carries
  2133. # one. Outranks tray_state below — see services/ams_slot_presence.py.
  2134. tray_has_spool: bool | None = None
  2135. state = printer_manager.get_status(data.printer_id)
  2136. if state and state.raw_data:
  2137. if data.ams_id == 255:
  2138. # External slot: look up tray from vt_tray by global ID
  2139. vt_tray = state.raw_data.get("vt_tray") or []
  2140. ext_id = data.tray_id + 254 # 0→254, 1→255
  2141. for vt in vt_tray:
  2142. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  2143. fingerprint_color = vt.get("tray_color", "")
  2144. fingerprint_type = vt.get("tray_type", "")
  2145. current_tray_info_idx = vt.get("tray_info_idx", "")
  2146. current_tray_sub_brands = vt.get("tray_sub_brands", "")
  2147. raw_state = vt.get("state")
  2148. if isinstance(raw_state, int):
  2149. tray_state = raw_state
  2150. tray_has_spool = spool_present(vt)
  2151. break
  2152. else:
  2153. ams_data = state.raw_data.get("ams", {})
  2154. ams_list = (
  2155. ams_data.get("ams", [])
  2156. if isinstance(ams_data, dict)
  2157. else ams_data
  2158. if isinstance(ams_data, list)
  2159. else []
  2160. )
  2161. tray = _find_tray_in_ams_data(
  2162. ams_list,
  2163. data.ams_id,
  2164. data.tray_id,
  2165. )
  2166. if tray:
  2167. fingerprint_color = tray.get("tray_color", "")
  2168. fingerprint_type = tray.get("tray_type", "")
  2169. current_tray_info_idx = tray.get("tray_info_idx", "")
  2170. current_tray_sub_brands = tray.get("tray_sub_brands", "")
  2171. raw_state = tray.get("state")
  2172. if isinstance(raw_state, int):
  2173. tray_state = raw_state
  2174. tray_has_spool = spool_present(tray)
  2175. # 3. Upsert assignment (replace if same printer+ams+tray), under the lock
  2176. # the AMS change handler writes the printer's slots with.
  2177. #
  2178. # A move (#1574) also takes the spool out of every other slot it holds, in
  2179. # the same commit, so a failed move leaves it where it was instead of in no
  2180. # slot. Moves of one spool take turns (get_spool_move_lock): two at once
  2181. # would each read where the spool is before the other committed, and both
  2182. # assign it. The slots' printers are locked as well, in id order so two
  2183. # moves can't take the same two locks the other way round. Which printers
  2184. # those are is read again once the locks are held: a move served first, a
  2185. # plain assign or an AMS change may have put the spool on one more, and
  2186. # then this one lets go and locks the larger set. A slot on a printer
  2187. # outside the caller's scope stays assigned, as unassigning leaves it
  2188. # (#1727).
  2189. locked_printer_ids = {data.printer_id}
  2190. if data.move:
  2191. locked_printer_ids |= await _printers_holding_spool(db, data.spool_id, printer_scope)
  2192. vacated: list[tuple[int, int, int]] = []
  2193. while True:
  2194. async with AsyncExitStack() as locks:
  2195. if data.move:
  2196. await locks.enter_async_context(get_spool_move_lock("inventory", data.spool_id))
  2197. for pid in sorted(locked_printer_ids):
  2198. await locks.enter_async_context(get_ams_assignment_lock(pid))
  2199. if data.move:
  2200. holding = await _printers_holding_spool(db, data.spool_id, printer_scope)
  2201. if not holding <= locked_printer_ids:
  2202. locked_printer_ids |= holding
  2203. continue
  2204. others = await db.execute(
  2205. select(SpoolAssignment).where(
  2206. SpoolAssignment.spool_id == data.spool_id,
  2207. SpoolAssignment.printer_id.in_(locked_printer_ids),
  2208. )
  2209. )
  2210. for row in others.scalars().all():
  2211. if (row.printer_id, row.ams_id, row.tray_id) != (data.printer_id, data.ams_id, data.tray_id):
  2212. vacated.append((row.printer_id, row.ams_id, row.tray_id))
  2213. await db.delete(row)
  2214. existing = await db.execute(
  2215. select(SpoolAssignment).where(
  2216. SpoolAssignment.printer_id == data.printer_id,
  2217. SpoolAssignment.ams_id == data.ams_id,
  2218. SpoolAssignment.tray_id == data.tray_id,
  2219. )
  2220. )
  2221. old = existing.scalar_one_or_none()
  2222. if old:
  2223. await db.delete(old)
  2224. await db.flush()
  2225. assignment = SpoolAssignment(
  2226. spool_id=data.spool_id,
  2227. printer_id=data.printer_id,
  2228. ams_id=data.ams_id,
  2229. tray_id=data.tray_id,
  2230. fingerprint_color=fingerprint_color,
  2231. fingerprint_type=fingerprint_type,
  2232. )
  2233. db.add(assignment)
  2234. await spool_swaps.record_assignment(
  2235. db, data.printer_id, data.ams_id, data.tray_id, data.spool_id, spool_swaps.INVENTORY
  2236. )
  2237. await db.commit()
  2238. break
  2239. await db.refresh(assignment)
  2240. slot_unlink_grace.forget_slot(data.printer_id, data.ams_id, data.tray_id)
  2241. if vacated:
  2242. logger.info(
  2243. "Moved spool %d to printer %d AMS%d-T%d from %s",
  2244. spool.id,
  2245. data.printer_id,
  2246. data.ams_id,
  2247. data.tray_id,
  2248. ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
  2249. )
  2250. # 4. Auto-configure AMS slot via MQTT.
  2251. #
  2252. # Only suppress the publish when the firmware's *explicit* empty signal
  2253. # (state ∈ {9, 10}) is set — "no spool" / "spool present but no feed".
  2254. # Every other state, including state=3 (the default idle on A1 Mini BMCU /
  2255. # P1S Standard AMS for both loaded and unconfigured slots) and missing
  2256. # state (older firmwares), is treated as the user's assertion that a
  2257. # spool is in the slot and we attempt the MQTT push.
  2258. #
  2259. # The pre-existing "skip when slot looks empty" guard read state=3 +
  2260. # tray_type="" as "empty" and skipped MQTT. On these firmwares that
  2261. # combination is the post-"Reset Slot" state with the spool still
  2262. # physically inserted — there is NO AMS signal that distinguishes it
  2263. # from a truly-empty slot, so the guard created a deadlock: MQTT never
  2264. # fired, the AMS never reported any change (because nothing changed
  2265. # physically), and on_ams_change replay therefore never re-fired the
  2266. # config either. Reporter (#1322 follow-up by @RosdasHH) verified
  2267. # empirically that removing the guard makes the slot configure
  2268. # correctly because Bambu firmware DOES accept the push for a
  2269. # physically-loaded slot, even when tray_type is "" and state is 3.
  2270. #
  2271. # Trade-off for the truly-empty slot case: firmware drops the push
  2272. # silently (per Bambu's documented behavior), the SpoolAssignment row
  2273. # still has empty fingerprint_type because nothing in the assign path
  2274. # updates that column, and on_ams_change at main.py:1031-1054 still
  2275. # fires the deferred config when a spool eventually appears. So the
  2276. # SpoolBuddy weigh-then-assign-before-insert workflow continues to
  2277. # work — just without the optimization of skipping a no-op MQTT call.
  2278. #
  2279. # state ∈ {9, 10} stays as an explicit short-circuit so we don't churn
  2280. # a doomed MQTT push when the firmware has positively confirmed "no
  2281. # spool" — and to keep the on_ams_change replay path as the single
  2282. # source of truth for those slots.
  2283. #
  2284. # ...except that `state` cannot carry that meaning. Two independent ways
  2285. # a loaded slot reads 9 here:
  2286. #
  2287. # - an AMS-HT reports its LOADED tray as 9, not 11, because it does not
  2288. # feed into a shared buffer the way a 4-slot AMS does (#2594, and the
  2289. # merge above skips its own state heuristic for HT units for exactly
  2290. # this reason). So this branch called every HT slot empty on sight.
  2291. # - apply_tray_exist_bits stamps state=9 on any slot whose tray_exist_bits
  2292. # bit is 0 and never takes it back when the bit returns, so a slot that
  2293. # was briefly emptied keeps the 9 until something configures it.
  2294. #
  2295. # Either way the slot sits at exists=True, state=9, this branch took the
  2296. # pending path, nothing was published, and the printer kept showing "?"
  2297. # (#3084 — reported against an H2C's AMS-HT, where both apply). Firmware's
  2298. # presence bit is what actually answers "is a spool in this slot", and the
  2299. # printer card has read it ahead of `state` since #2527.
  2300. #
  2301. # It is allowed to overrule the 9 and nothing else. A bit reading *empty*
  2302. # deliberately does NOT start suppressing pushes that go out today: the
  2303. # cost of being wrong there is a slot that silently stops configuring, on
  2304. # whichever AMS variant we compute the bit position wrong for, against a
  2305. # saving of one MQTT message the firmware would have dropped anyway.
  2306. slot_is_definitely_empty = tray_has_spool is not True and (tray_state == 9 or tray_state == 10)
  2307. configured = False
  2308. if not slot_is_definitely_empty:
  2309. try:
  2310. configured = await apply_spool_to_slot_via_mqtt(
  2311. db=db,
  2312. current_user=current_user,
  2313. spool=spool,
  2314. printer_id=data.printer_id,
  2315. ams_id=data.ams_id,
  2316. tray_id=data.tray_id,
  2317. current_tray_info_idx=current_tray_info_idx,
  2318. current_tray_type=fingerprint_type or "",
  2319. current_tray_sub_brands=current_tray_sub_brands,
  2320. )
  2321. except Exception as e:
  2322. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  2323. else:
  2324. # Nudge a fresh pushall so the read-back verification registered in
  2325. # apply_spool_to_slot_via_mqtt (#2582) has current tray telemetry to
  2326. # compare against within its window, instead of waiting for the next
  2327. # idle push. Best-effort — the periodic push is the fallback.
  2328. if configured:
  2329. try:
  2330. client = printer_manager.get_client(data.printer_id)
  2331. if client:
  2332. client.request_status_update()
  2333. except Exception:
  2334. pass
  2335. # pending_config is the "config not landed yet" UI marker. True when the
  2336. # firmware said empty, OR when MQTT couldn't actually publish (printer
  2337. # offline, no client, transient failure). on_ams_change replay re-fires
  2338. # the config in either case once the AMS reports a non-empty fingerprint.
  2339. pending_config = slot_is_definitely_empty or not configured
  2340. # Return assignment with spool data
  2341. result = await db.execute(
  2342. select(SpoolAssignment)
  2343. .options(
  2344. selectinload(SpoolAssignment.spool).options(*spool_response_loads()),
  2345. selectinload(SpoolAssignment.printer),
  2346. )
  2347. .where(SpoolAssignment.id == assignment.id)
  2348. )
  2349. resp = result.scalar_one()
  2350. response = SpoolAssignmentResponse.model_validate(resp)
  2351. response.configured = configured
  2352. response.pending_config = pending_config
  2353. if pending_config:
  2354. logger.info(
  2355. "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
  2356. spool.id,
  2357. data.printer_id,
  2358. data.ams_id,
  2359. data.tray_id,
  2360. )
  2361. for printer_id, ams_id, tray_id in [*vacated, (data.printer_id, data.ams_id, data.tray_id)]:
  2362. await ws_manager.broadcast(
  2363. {
  2364. "type": "spool_assignment_changed",
  2365. "printer_id": printer_id,
  2366. "ams_id": ams_id,
  2367. "tray_id": tray_id,
  2368. }
  2369. )
  2370. return response
  2371. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  2372. async def unassign_spool(
  2373. printer_id: int,
  2374. ams_id: int,
  2375. tray_id: int,
  2376. db: AsyncSession = Depends(get_db, scope="function"),
  2377. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2378. ):
  2379. """Unassign a spool from an AMS slot."""
  2380. async with get_ams_assignment_lock(printer_id):
  2381. result = await db.execute(
  2382. select(SpoolAssignment).where(
  2383. SpoolAssignment.printer_id == printer_id,
  2384. SpoolAssignment.ams_id == ams_id,
  2385. SpoolAssignment.tray_id == tray_id,
  2386. )
  2387. )
  2388. assignment = result.scalar_one_or_none()
  2389. if not assignment:
  2390. raise HTTPException(404, "Assignment not found")
  2391. await db.delete(assignment)
  2392. await db.commit()
  2393. await ws_manager.broadcast(
  2394. {
  2395. "type": "spool_assignment_changed",
  2396. "printer_id": printer_id,
  2397. "ams_id": ams_id,
  2398. "tray_id": tray_id,
  2399. }
  2400. )
  2401. return {"status": "deleted"}
  2402. # ── Tag Linking ───────────────────────────────────────────────────────────────
  2403. class LinkTagRequest(BaseModel):
  2404. tag_uid: str | None = None
  2405. tray_uuid: str | None = None
  2406. tag_type: str | None = None
  2407. data_origin: str | None = "nfc_link"
  2408. def _validate_tag_input(
  2409. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  2410. ) -> None:
  2411. if raw_value is None:
  2412. return
  2413. raw = str(raw_value).strip()
  2414. if not raw:
  2415. return
  2416. if normalized_value is None:
  2417. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  2418. if len(normalized_value) % 2 != 0:
  2419. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  2420. if exact_len is not None and len(normalized_value) != exact_len:
  2421. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  2422. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  2423. async def link_tag_to_spool(
  2424. spool_id: int,
  2425. data: LinkTagRequest,
  2426. db: AsyncSession = Depends(get_db, scope="function"),
  2427. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2428. ):
  2429. """Link an RFID tag_uid/tray_uuid to an existing spool.
  2430. A tag another active spool already carries is refused with the shared
  2431. ``tag_already_linked`` 409, which names that spool so a caller can offer
  2432. to move the tag instead of only reporting that it is taken (#3110).
  2433. """
  2434. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  2435. spool = result.scalar_one_or_none()
  2436. if not spool:
  2437. raise HTTPException(404, "Spool not found")
  2438. if spool.archived_at:
  2439. raise HTTPException(400, "Cannot link tag to archived spool")
  2440. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  2441. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  2442. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  2443. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  2444. # Check for conflicts: tag already linked to another active spool.
  2445. #
  2446. # Ordered, and read with first() rather than scalar_one_or_none(), because
  2447. # two active spools really can carry one tag: neither column has a unique
  2448. # index, PATCH /spools/{id} writes them with no conflict check, and
  2449. # POST /spools/bulk copies a single payload -- tag included -- into every
  2450. # row it creates. scalar_one_or_none() answered that with MultipleResultsFound,
  2451. # which escapes into the auth middleware's fail-closed handler and reaches
  2452. # the caller as 503 "Authentication service temporarily unavailable" -- a
  2453. # wrong answer pointing at the wrong subsystem, where a 409 was owed
  2454. # (#3110). get_spool_by_tag above already resolves duplicates this way.
  2455. if normalized_tag_uid:
  2456. conflict = await db.execute(
  2457. select(Spool)
  2458. .where(
  2459. func.upper(Spool.tag_uid) == normalized_tag_uid,
  2460. Spool.id != spool_id,
  2461. Spool.archived_at.is_(None),
  2462. )
  2463. .order_by(Spool.id)
  2464. )
  2465. holder = conflict.scalars().first()
  2466. if holder:
  2467. raise tag_already_linked("tag_uid", holder.id)
  2468. # Auto-clear from archived spools (tag recycling)
  2469. archived_with_tag = await db.execute(
  2470. select(Spool).where(
  2471. func.upper(Spool.tag_uid) == normalized_tag_uid,
  2472. Spool.id != spool_id,
  2473. Spool.archived_at.is_not(None),
  2474. )
  2475. )
  2476. for old_spool in archived_with_tag.scalars().all():
  2477. old_spool.tag_uid = None
  2478. if normalized_tray_uuid:
  2479. conflict = await db.execute(
  2480. select(Spool)
  2481. .where(
  2482. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  2483. Spool.id != spool_id,
  2484. Spool.archived_at.is_(None),
  2485. )
  2486. .order_by(Spool.id)
  2487. )
  2488. holder = conflict.scalars().first()
  2489. if holder:
  2490. raise tag_already_linked("tray_uuid", holder.id)
  2491. archived_with_uuid = await db.execute(
  2492. select(Spool).where(
  2493. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  2494. Spool.id != spool_id,
  2495. Spool.archived_at.is_not(None),
  2496. )
  2497. )
  2498. for old_spool in archived_with_uuid.scalars().all():
  2499. old_spool.tray_uuid = None
  2500. if data.tag_uid is not None:
  2501. spool.tag_uid = normalized_tag_uid
  2502. if data.tray_uuid is not None:
  2503. spool.tray_uuid = normalized_tray_uuid
  2504. if data.tag_type is not None:
  2505. spool.tag_type = data.tag_type
  2506. if data.data_origin is not None:
  2507. spool.data_origin = data.data_origin
  2508. await db.commit()
  2509. result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
  2510. return result.scalar_one()
  2511. # ── Usage History ─────────────────────────────────────────────────────────────
  2512. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  2513. async def get_spool_usage_history(
  2514. spool_id: int,
  2515. limit: int = 50,
  2516. db: AsyncSession = Depends(get_db, scope="function"),
  2517. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2518. ):
  2519. """Get usage history for a specific spool."""
  2520. from backend.app.models.spool_usage_history import SpoolUsageHistory
  2521. # Verify spool exists
  2522. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  2523. if not spool_result.scalar_one_or_none():
  2524. raise HTTPException(404, "Spool not found")
  2525. result = await db.execute(
  2526. select(SpoolUsageHistory)
  2527. .where(SpoolUsageHistory.spool_id == spool_id)
  2528. .order_by(SpoolUsageHistory.created_at.desc())
  2529. .limit(limit)
  2530. )
  2531. return list(result.scalars().all())
  2532. @router.get("/stats/suppliers", response_model=list[SupplierStats])
  2533. async def get_supplier_stats(
  2534. date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
  2535. date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
  2536. tz: str | None = Query(
  2537. None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
  2538. ),
  2539. db: AsyncSession = Depends(get_db, scope="function"),
  2540. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2541. ):
  2542. """Aggregate the inventory by purchase-source supplier (#2988).
  2543. Groups by the supplier a spool was actually bought from (the
  2544. ``is_purchase_source`` assignment), so "how much did we run through
  2545. supplier X" reads directly. Stock comes from active spools; consumption
  2546. and cost from the recorded usage history, archived spools included —
  2547. their consumption happened. Sorted by consumption, heaviest first, then by name.
  2548. ``date_from`` / ``date_to`` scope the usage half only, so the widget can
  2549. honour the dashboard timeframe like every other one on that page. Stock is
  2550. point-in-time by nature and is never windowed.
  2551. """
  2552. from backend.app.models.spool_usage_history import SpoolUsageHistory
  2553. purchase_link = (SpoolSupplier.spool_id == Spool.id) & SpoolSupplier.is_purchase_source.is_(True)
  2554. usage_window = []
  2555. if date_from:
  2556. usage_window.append(SpoolUsageHistory.created_at >= local_date_start(date_from, day_zone(tz)))
  2557. if date_to:
  2558. usage_window.append(SpoolUsageHistory.created_at <= local_date_end(date_to, day_zone(tz)))
  2559. remaining = Spool.label_weight - Spool.weight_used
  2560. inventory_rows = await db.execute(
  2561. select(
  2562. SpoolSupplier.supplier_id,
  2563. func.count(Spool.id),
  2564. # Clamp each spool before summing: an over-consumed spool counts as
  2565. # empty, not as negative stock that eats into its neighbours.
  2566. func.sum(case((remaining > 0, remaining), else_=0.0)),
  2567. )
  2568. .select_from(Spool)
  2569. .join(SpoolSupplier, purchase_link)
  2570. .where(Spool.archived_at.is_(None))
  2571. .group_by(SpoolSupplier.supplier_id)
  2572. )
  2573. usage_rows = await db.execute(
  2574. select(
  2575. SpoolSupplier.supplier_id,
  2576. func.sum(SpoolUsageHistory.weight_used),
  2577. func.sum(SpoolUsageHistory.cost),
  2578. )
  2579. .select_from(SpoolUsageHistory)
  2580. .join(Spool, SpoolUsageHistory.spool_id == Spool.id)
  2581. .join(SpoolSupplier, purchase_link)
  2582. .where(*usage_window)
  2583. .group_by(SpoolSupplier.supplier_id)
  2584. )
  2585. names = dict((await db.execute(select(Supplier.id, Supplier.name))).all())
  2586. stats: dict[int, SupplierStats] = {}
  2587. for supplier_id, count, remaining_g in inventory_rows.all():
  2588. stats[supplier_id] = SupplierStats(
  2589. supplier_id=supplier_id,
  2590. supplier_name=names.get(supplier_id, f"#{supplier_id}"),
  2591. spool_count=count,
  2592. remaining_g=float(remaining_g or 0),
  2593. consumed_g=0.0,
  2594. cost=0.0,
  2595. )
  2596. for supplier_id, consumed, cost in usage_rows.all():
  2597. entry = stats.get(supplier_id)
  2598. if entry is None:
  2599. entry = SupplierStats(
  2600. supplier_id=supplier_id,
  2601. supplier_name=names.get(supplier_id, f"#{supplier_id}"),
  2602. spool_count=0,
  2603. remaining_g=0.0,
  2604. consumed_g=0.0,
  2605. cost=0.0,
  2606. )
  2607. stats[supplier_id] = entry
  2608. entry.consumed_g = float(consumed or 0)
  2609. entry.cost = float(cost or 0)
  2610. # Name breaks ties: a narrow date range leaves most rows at 0 g consumed,
  2611. # and those would otherwise come back in GROUP BY order.
  2612. return sorted(stats.values(), key=lambda s: (-s.consumed_g, supplier_name_key(s.supplier_name)))
  2613. @router.get("/stats/material-numbers", response_model=list[MaterialNumberStats])
  2614. async def get_material_number_stats(
  2615. date_from: date | None = Query(None),
  2616. date_to: date | None = Query(None),
  2617. tz: str | None = Query(
  2618. None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
  2619. ),
  2620. db: AsyncSession = Depends(get_db, scope="function"),
  2621. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2622. ):
  2623. """Aggregate the inventory by material number (#2870).
  2624. The material number is the internal purchasing identifier shared by all
  2625. spools of a product, so this is the grouping the business actually costs
  2626. by — unlike brand+material+colour. Two queries: active-spool counts and
  2627. remaining weight from the spool table, consumption and cost from the
  2628. recorded usage history (archived spools included — their consumption
  2629. happened).
  2630. ``date_from``/``date_to`` narrow the usage half only, so the widget can
  2631. follow the dashboard timeframe the rest of the stats page uses. Stock is
  2632. point-in-time by nature and stays unfiltered — "how much do I hold" has
  2633. no date range. Sorted by consumption, heaviest first, then by number so
  2634. a range where nothing was consumed still lists in a stable order.
  2635. """
  2636. from backend.app.models.spool_usage_history import SpoolUsageHistory
  2637. # material_number is normalised to NULL-or-non-empty by the schema
  2638. # validator, so NULL is the only "unset" state to exclude here.
  2639. has_number = Spool.material_number.is_not(None)
  2640. from sqlalchemy import ColumnElement
  2641. usage_filters: list[ColumnElement[bool]] = [has_number]
  2642. if date_from:
  2643. usage_filters.append(SpoolUsageHistory.created_at >= local_date_start(date_from, day_zone(tz)))
  2644. if date_to:
  2645. usage_filters.append(SpoolUsageHistory.created_at <= local_date_end(date_to, day_zone(tz)))
  2646. # Clamped PER SPOOL, like every other remaining-weight computation in the
  2647. # codebase: a spool whose weight_used overshot its label_weight holds 0 g,
  2648. # it does not subtract from the other spools sharing the number.
  2649. per_spool_remaining = func.coalesce(Spool.label_weight, 0) - func.coalesce(Spool.weight_used, 0)
  2650. inventory_rows = await db.execute(
  2651. select(
  2652. Spool.material_number,
  2653. func.count(Spool.id),
  2654. func.sum(case((per_spool_remaining > 0, per_spool_remaining), else_=0.0)),
  2655. )
  2656. .where(has_number, Spool.archived_at.is_(None))
  2657. .group_by(Spool.material_number)
  2658. )
  2659. usage_rows = await db.execute(
  2660. select(
  2661. Spool.material_number,
  2662. func.sum(SpoolUsageHistory.weight_used),
  2663. func.sum(SpoolUsageHistory.cost),
  2664. )
  2665. .join(Spool, SpoolUsageHistory.spool_id == Spool.id)
  2666. .where(*usage_filters)
  2667. .group_by(Spool.material_number)
  2668. )
  2669. stats: dict[str, MaterialNumberStats] = {}
  2670. for number, count, remaining in inventory_rows.all():
  2671. stats[number] = MaterialNumberStats(
  2672. material_number=number,
  2673. spool_count=count,
  2674. remaining_g=float(remaining or 0),
  2675. consumed_g=0.0,
  2676. cost=0.0,
  2677. )
  2678. for number, consumed, cost in usage_rows.all():
  2679. entry = stats.get(number)
  2680. if entry is None:
  2681. entry = MaterialNumberStats(
  2682. material_number=number, spool_count=0, remaining_g=0.0, consumed_g=0.0, cost=0.0
  2683. )
  2684. stats[number] = entry
  2685. entry.consumed_g = float(consumed or 0)
  2686. entry.cost = float(cost or 0)
  2687. return sorted(stats.values(), key=lambda s: (-s.consumed_g, s.material_number))
  2688. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  2689. async def get_all_usage_history(
  2690. limit: int = 100,
  2691. printer_id: int | None = None,
  2692. spoolman: bool = False,
  2693. db: AsyncSession = Depends(get_db, scope="function"),
  2694. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  2695. printer_scope: PrinterScope = RequestPrinterScope,
  2696. ):
  2697. """Get global usage history, optionally filtered by printer.
  2698. One inventory's at a time: ``spoolman`` for the Spoolman spools'
  2699. (#3407), otherwise the built-in ones'. Each keys its spools by its own ids.
  2700. """
  2701. from backend.app.models.spool_usage_history import SpoolUsageHistory
  2702. query = (
  2703. select(SpoolUsageHistory)
  2704. .where(
  2705. SpoolUsageHistory.spoolman_spool_id.is_not(None)
  2706. if spoolman
  2707. else SpoolUsageHistory.spoolman_spool_id.is_(None)
  2708. )
  2709. .order_by(SpoolUsageHistory.created_at.desc())
  2710. .limit(limit)
  2711. )
  2712. if printer_id is not None:
  2713. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  2714. if (clause := printer_scope.where(SpoolUsageHistory.printer_id)) is not None:
  2715. query = query.where(clause)
  2716. result = await db.execute(query)
  2717. return list(result.scalars().all())
  2718. @router.delete("/spools/{spool_id}/usage")
  2719. async def clear_spool_usage_history(
  2720. spool_id: int,
  2721. db: AsyncSession = Depends(get_db, scope="function"),
  2722. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2723. ):
  2724. """Clear usage history for a spool."""
  2725. from backend.app.models.spool_usage_history import SpoolUsageHistory
  2726. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  2727. for row in result.scalars().all():
  2728. await db.delete(row)
  2729. await db.commit()
  2730. return {"status": "cleared"}
  2731. # ── AMS Weight Sync ──────────────────────────────────────────────────────────
  2732. @router.post("/sync-ams-weights")
  2733. async def sync_weights_from_ams(
  2734. db: AsyncSession = Depends(get_db, scope="function"),
  2735. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2736. printer_scope: PrinterScope = RequestPrinterScope,
  2737. ):
  2738. """Force-sync spool weight_used from live AMS remain% data.
  2739. Overwrites the database weight_used for every assigned spool using the
  2740. current AMS remain% from connected printers. This is a manual recovery
  2741. tool — it bypasses the normal "only increase" guard, and the weight lock
  2742. a hand-set or weighed figure carries, which it releases.
  2743. A printer that is printing is left alone: its print's usage is charged
  2744. when it completes, on top of whatever weight the spool has by then.
  2745. """
  2746. from backend.app.services.printer_manager import PrinterManager, printer_manager
  2747. from backend.app.services.usage_tracker import printer_is_charging
  2748. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  2749. # Only slots on printers the caller may see (#1727)
  2750. assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
  2751. logger.info("AMS weight sync: found %d assignments", len(assignments))
  2752. synced = 0
  2753. skipped = 0
  2754. printing = 0
  2755. for assignment in assignments:
  2756. spool = assignment.spool
  2757. if not spool:
  2758. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  2759. skipped += 1
  2760. continue
  2761. state = printer_manager.get_status(assignment.printer_id)
  2762. # A disconnected printer keeps its last report, which is not a reading.
  2763. if not state or not state.raw_data or getattr(state, "connected", True) is False:
  2764. logger.info(
  2765. "AMS weight sync: printer %d not connected, skipping spool %d",
  2766. assignment.printer_id,
  2767. spool.id,
  2768. )
  2769. skipped += 1
  2770. continue
  2771. # Until completion has charged the print the printer counts as printing,
  2772. # also after it has reported the print finished.
  2773. printer_state = str(getattr(state, "state", "") or "").upper()
  2774. if printer_state in PrinterManager.ACTIVE_PRINT_STATES or printer_is_charging(assignment.printer_id):
  2775. logger.info("AMS weight sync: printer %d is printing, skipping spool %d", assignment.printer_id, spool.id)
  2776. skipped += 1
  2777. printing += 1
  2778. continue
  2779. ams_raw = state.raw_data.get("ams", [])
  2780. if isinstance(ams_raw, dict):
  2781. ams_raw = ams_raw.get("ams", [])
  2782. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  2783. if not tray:
  2784. logger.info(
  2785. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  2786. spool.id,
  2787. assignment.printer_id,
  2788. assignment.ams_id,
  2789. assignment.tray_id,
  2790. )
  2791. skipped += 1
  2792. continue
  2793. remain_raw = tray.get("remain")
  2794. if remain_raw is None:
  2795. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  2796. skipped += 1
  2797. continue
  2798. try:
  2799. remain_val = int(remain_raw)
  2800. except (TypeError, ValueError):
  2801. skipped += 1
  2802. continue
  2803. if remain_val < 0 or remain_val > 100:
  2804. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  2805. skipped += 1
  2806. continue
  2807. lw = spool.label_weight or 1000
  2808. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  2809. old_used = spool.weight_used or 0
  2810. if round(old_used, 1) != new_used:
  2811. logger.info(
  2812. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  2813. spool.id,
  2814. old_used,
  2815. new_used,
  2816. remain_val,
  2817. )
  2818. spool.weight_used = new_used
  2819. spool.weight_locked = False
  2820. synced += 1
  2821. else:
  2822. if spool.weight_locked:
  2823. # The figure already agrees with the AMS; the override still applies.
  2824. spool.weight_locked = False
  2825. skipped += 1
  2826. await db.commit()
  2827. if synced:
  2828. await ws_manager.broadcast({"type": "inventory_changed"})
  2829. return {"synced": synced, "skipped": skipped, "printing": printing}
  2830. # ── Helpers ──────────────────────────────────────────────────────────────────
  2831. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  2832. """Find a specific tray in the AMS data structure."""
  2833. if not ams_data:
  2834. return None
  2835. for ams_unit in ams_data:
  2836. if int(ams_unit.get("id", -1)) != ams_id:
  2837. continue
  2838. for tray in ams_unit.get("tray", []):
  2839. if int(tray.get("id", -1)) == tray_id:
  2840. return tray
  2841. return None
  2842. # ── Filament SKU Settings (reorder forecasting) ───────────────────────────────
  2843. class FilamentSkuSettingsResponse(BaseModel):
  2844. id: int
  2845. material: str
  2846. subtype: str | None
  2847. brand: str | None
  2848. color_name: str | None
  2849. lead_time_days: int
  2850. safety_margin_value: int
  2851. safety_margin_unit: str
  2852. alerts_snoozed: bool = False
  2853. class Config:
  2854. from_attributes = True
  2855. class FilamentSkuSettingsUpsert(BaseModel):
  2856. material: str
  2857. subtype: str | None = None
  2858. brand: str | None = None
  2859. color_name: str | None = None
  2860. lead_time_days: int = 0
  2861. safety_margin_value: int = 14
  2862. safety_margin_unit: str = "days"
  2863. alerts_snoozed: bool = False
  2864. @router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
  2865. async def list_sku_settings(
  2866. db: AsyncSession = Depends(get_db, scope="function"),
  2867. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  2868. ):
  2869. """List all filament SKU reorder settings."""
  2870. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  2871. result = await db.execute(
  2872. select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
  2873. )
  2874. return list(result.scalars().all())
  2875. @router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
  2876. async def upsert_sku_settings(
  2877. data: FilamentSkuSettingsUpsert,
  2878. db: AsyncSession = Depends(get_db, scope="function"),
  2879. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2880. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2881. ),
  2882. ):
  2883. """Create or update reorder settings for a filament SKU (material/subtype/brand)."""
  2884. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  2885. result = await db.execute(
  2886. select(FilamentSkuSettings).where(
  2887. FilamentSkuSettings.material == data.material,
  2888. FilamentSkuSettings.subtype == data.subtype,
  2889. FilamentSkuSettings.brand == data.brand,
  2890. FilamentSkuSettings.color_name == data.color_name,
  2891. )
  2892. )
  2893. row = result.scalar_one_or_none()
  2894. if row:
  2895. row.lead_time_days = data.lead_time_days
  2896. row.safety_margin_value = data.safety_margin_value
  2897. row.safety_margin_unit = data.safety_margin_unit
  2898. row.alerts_snoozed = data.alerts_snoozed
  2899. else:
  2900. row = FilamentSkuSettings(
  2901. material=data.material,
  2902. subtype=data.subtype,
  2903. brand=data.brand,
  2904. color_name=data.color_name,
  2905. lead_time_days=data.lead_time_days,
  2906. safety_margin_value=data.safety_margin_value,
  2907. safety_margin_unit=data.safety_margin_unit,
  2908. alerts_snoozed=data.alerts_snoozed,
  2909. )
  2910. db.add(row)
  2911. await db.commit()
  2912. await db.refresh(row)
  2913. return row
  2914. # ── Shopping List ─────────────────────────────────────────────────────────────
  2915. class ShoppingListItemResponse(BaseModel):
  2916. id: int
  2917. material: str
  2918. subtype: str | None
  2919. brand: str | None
  2920. color_name: str | None
  2921. quantity_spools: int
  2922. note: str | None
  2923. status: str
  2924. purchased_at: str | None
  2925. added_at: str
  2926. class Config:
  2927. from_attributes = True
  2928. class ShoppingListItemCreate(BaseModel):
  2929. material: str
  2930. subtype: str | None = None
  2931. brand: str | None = None
  2932. color_name: str | None = None
  2933. quantity_spools: int = 1
  2934. note: str | None = None
  2935. class ShoppingListItemStatusUpdate(BaseModel):
  2936. status: str # pending | purchased | received
  2937. @router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
  2938. async def get_shopping_list(
  2939. db: AsyncSession = Depends(get_db, scope="function"),
  2940. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  2941. ):
  2942. """Get the filament shopping list."""
  2943. from backend.app.models.shopping_list import ShoppingListItem
  2944. result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
  2945. items = result.scalars().all()
  2946. return [
  2947. ShoppingListItemResponse(
  2948. id=i.id,
  2949. material=i.material,
  2950. subtype=i.subtype,
  2951. brand=i.brand,
  2952. color_name=i.color_name,
  2953. quantity_spools=i.quantity_spools,
  2954. note=i.note,
  2955. status=i.status or "pending",
  2956. purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
  2957. added_at=i.added_at.isoformat() if i.added_at else "",
  2958. )
  2959. for i in items
  2960. ]
  2961. @router.post("/shopping-list", response_model=ShoppingListItemResponse)
  2962. async def add_to_shopping_list(
  2963. data: ShoppingListItemCreate,
  2964. db: AsyncSession = Depends(get_db, scope="function"),
  2965. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2966. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2967. ),
  2968. ):
  2969. """Add a filament SKU to the shopping list."""
  2970. from backend.app.models.shopping_list import ShoppingListItem
  2971. item = ShoppingListItem(
  2972. material=data.material,
  2973. subtype=data.subtype,
  2974. brand=data.brand,
  2975. color_name=data.color_name,
  2976. quantity_spools=data.quantity_spools,
  2977. note=data.note,
  2978. )
  2979. db.add(item)
  2980. await db.commit()
  2981. await db.refresh(item)
  2982. return ShoppingListItemResponse(
  2983. id=item.id,
  2984. material=item.material,
  2985. subtype=item.subtype,
  2986. brand=item.brand,
  2987. color_name=item.color_name,
  2988. quantity_spools=item.quantity_spools,
  2989. note=item.note,
  2990. status=item.status or "pending",
  2991. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  2992. added_at=item.added_at.isoformat() if item.added_at else "",
  2993. )
  2994. @router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
  2995. async def update_shopping_list_status(
  2996. item_id: int,
  2997. data: ShoppingListItemStatusUpdate,
  2998. db: AsyncSession = Depends(get_db, scope="function"),
  2999. _: User | None = RequireAnyPermissionIfAuthEnabled(
  3000. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  3001. ),
  3002. ):
  3003. """Update the purchase status of a shopping list item."""
  3004. from backend.app.models.shopping_list import ShoppingListItem
  3005. if data.status not in ("pending", "purchased", "received"):
  3006. raise HTTPException(400, "Invalid status")
  3007. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  3008. item = result.scalar_one_or_none()
  3009. if not item:
  3010. raise HTTPException(404, "Item not found")
  3011. item.status = data.status
  3012. if data.status in ("purchased", "received") and item.purchased_at is None:
  3013. item.purchased_at = datetime.now(timezone.utc)
  3014. elif data.status == "pending":
  3015. item.purchased_at = None
  3016. await db.commit()
  3017. await db.refresh(item)
  3018. return ShoppingListItemResponse(
  3019. id=item.id,
  3020. material=item.material,
  3021. subtype=item.subtype,
  3022. brand=item.brand,
  3023. color_name=item.color_name,
  3024. quantity_spools=item.quantity_spools,
  3025. note=item.note,
  3026. status=item.status or "pending",
  3027. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  3028. added_at=item.added_at.isoformat() if item.added_at else "",
  3029. )
  3030. @router.delete("/shopping-list/{item_id}")
  3031. async def remove_from_shopping_list(
  3032. item_id: int,
  3033. db: AsyncSession = Depends(get_db, scope="function"),
  3034. _: User | None = RequireAnyPermissionIfAuthEnabled(
  3035. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  3036. ),
  3037. ):
  3038. """Remove a single item from the shopping list."""
  3039. from backend.app.models.shopping_list import ShoppingListItem
  3040. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  3041. item = result.scalar_one_or_none()
  3042. if not item:
  3043. raise HTTPException(404, "Item not found")
  3044. await db.delete(item)
  3045. await db.commit()
  3046. return {"status": "deleted"}
  3047. @router.delete("/shopping-list")
  3048. async def clear_shopping_list(
  3049. db: AsyncSession = Depends(get_db, scope="function"),
  3050. _: User | None = RequireAnyPermissionIfAuthEnabled(
  3051. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  3052. ),
  3053. ):
  3054. """Clear all items from the shopping list."""
  3055. from backend.app.models.shopping_list import ShoppingListItem
  3056. result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
  3057. deleted = len(result.fetchall())
  3058. await db.commit()
  3059. return {"deleted": deleted}
  3060. class CreateSpoolFromSlotRequest(BaseModel):
  3061. printer_id: int
  3062. ams_id: int
  3063. tray_id: int
  3064. @router.post("/spools/from-slot", response_model=SpoolResponse)
  3065. async def create_spool_from_slot(
  3066. req: CreateSpoolFromSlotRequest,
  3067. db: AsyncSession = Depends(get_db, scope="function"),
  3068. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  3069. printer_scope: PrinterScope = RequestPrinterScope,
  3070. ):
  3071. """Explicit user action: create an inventory spool from an AMS slot's current tray data.
  3072. Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
  3073. the user looked at the slot and chose to register it. Also assigns the new spool
  3074. to the slot in the same call.
  3075. """
  3076. from backend.app.services.printer_manager import printer_manager
  3077. from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
  3078. printer_scope.ensure(req.printer_id)
  3079. state = printer_manager.get_status(req.printer_id)
  3080. if not state or not state.raw_data:
  3081. raise HTTPException(status_code=404, detail="Printer not connected or no state available")
  3082. ams_data = state.raw_data.get("ams")
  3083. ams_units: list[dict] = []
  3084. if isinstance(ams_data, list):
  3085. ams_units = ams_data
  3086. elif isinstance(ams_data, dict):
  3087. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  3088. ams_units = ams_data["ams"]
  3089. elif "tray" in ams_data:
  3090. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  3091. tray: dict | None = None
  3092. for unit in ams_units:
  3093. if not isinstance(unit, dict):
  3094. continue
  3095. if int(unit.get("id", -1)) != req.ams_id:
  3096. continue
  3097. for t in unit.get("tray", []):
  3098. if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
  3099. tray = t
  3100. break
  3101. if tray:
  3102. break
  3103. if not tray or not tray.get("tray_type"):
  3104. raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
  3105. # Guard against ghost-spool creation: a slot without any RFID tag has no
  3106. # stable identity, so creating an inventory row would just duplicate on
  3107. # every confirm and never re-link to the physical spool.
  3108. from backend.app.services.spool_tag_matcher import is_valid_tag
  3109. if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
  3110. raise HTTPException(status_code=400, detail="Slot has no RFID tag")
  3111. # Under the slot lock from the first write: on SQLite the spool insert takes
  3112. # the write lock, which an AMS update holding the slot lock would wait for.
  3113. async with get_ams_assignment_lock(req.printer_id):
  3114. spool = await create_spool_from_tray(db, tray)
  3115. await auto_assign_spool(
  3116. req.printer_id,
  3117. req.ams_id,
  3118. req.tray_id,
  3119. spool,
  3120. printer_manager,
  3121. db,
  3122. tray_info_idx=tray.get("tray_info_idx", ""),
  3123. )
  3124. await db.commit()
  3125. await ws_manager.broadcast({"type": "inventory_changed"})
  3126. await ws_manager.broadcast(
  3127. {
  3128. "type": "spool_auto_assigned",
  3129. "printer_id": req.printer_id,
  3130. "ams_id": req.ams_id,
  3131. "tray_id": req.tray_id,
  3132. "spool_id": spool.id,
  3133. }
  3134. )
  3135. # populate_existing because `spool` is the same identity-mapped instance
  3136. # create_spool_from_tray built: it pre-initialises `supplier_links` to []
  3137. # so the flush can't lazy-load it, and SQLAlchemy will not overwrite an
  3138. # already-loaded collection on a plain re-select — the inherited
  3139. # assignments (#2988) would be in the table but missing from the response.
  3140. result = await db.execute(
  3141. select(Spool)
  3142. .options(*spool_response_loads())
  3143. .where(Spool.id == spool.id)
  3144. .execution_options(populate_existing=True)
  3145. )
  3146. return result.scalar_one()