فهرست منبع

fix(inventory): Bambu PLA Silk+ spools get their colour name

- the AMS reports single-colour Silk+ rolls as "PLA Silk+", but the
  seeded catalogue filed the Silk+ colours under "PLA Silk" (the
  Dual/Tri Color line), so the RFID lookup never named them
- seed files them under "PLA Silk+"; one-shot migration moves an
  existing install's rows that are still as seeded
- one-shot repair names RFID-added Bambu spools that have no colour
  name; a name already set is never changed
maziggy 2 روز پیش
والد
کامیت
517439f850
100فایلهای تغییر یافته به همراه3226 افزوده شده و 114 حذف شده
  1. 6 0
      CHANGELOG.md
  2. 208 3
      backend/app/api/routes/inventory.py
  3. 162 31
      backend/app/api/routes/spoolman_inventory.py
  4. 16 14
      backend/app/core/catalog_defaults.py
  5. 112 0
      backend/app/core/database.py
  6. 7 0
      backend/app/core/sqlite_fk.py
  7. 8 0
      backend/app/models/__init__.py
  8. 101 0
      backend/app/models/filament_profile_default.py
  9. 50 0
      backend/app/schemas/filament_profile_default.py
  10. 402 0
      backend/app/services/filament_profile_default.py
  11. 7 0
      backend/app/services/spool_tag_matcher.py
  12. 4 0
      backend/app/services/spoolman.py
  13. 596 0
      backend/tests/integration/test_filament_profile_defaults.py
  14. 16 0
      backend/tests/unit/services/test_spoolman_service.py
  15. 1 0
      backend/tests/unit/test_not_null_update_schemas.py
  16. 174 0
      backend/tests/unit/test_silk_plus_color_names.py
  17. 90 0
      frontend/src/__tests__/components/ProfileDefaultsSettings.test.tsx
  18. 290 0
      frontend/src/__tests__/components/SpoolFormProfileDefaults.test.tsx
  19. 39 0
      frontend/src/__tests__/components/withProductDefaultKProfiles.test.ts
  20. 15 8
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPageWiring.test.tsx
  21. 59 0
      frontend/src/api/client.ts
  22. 158 0
      frontend/src/components/ProfileDefaultsSettings.tsx
  23. 218 42
      frontend/src/components/SpoolFormModal.tsx
  24. 89 0
      frontend/src/components/spool-form/ProfileDefaultOption.tsx
  25. 65 1
      frontend/src/components/spool-form/utils.ts
  26. 21 0
      frontend/src/i18n/locales/de.ts
  27. 21 0
      frontend/src/i18n/locales/en.ts
  28. 21 0
      frontend/src/i18n/locales/es.ts
  29. 21 0
      frontend/src/i18n/locales/fr.ts
  30. 21 0
      frontend/src/i18n/locales/it.ts
  31. 21 0
      frontend/src/i18n/locales/ja.ts
  32. 21 0
      frontend/src/i18n/locales/ko.ts
  33. 21 0
      frontend/src/i18n/locales/nl.ts
  34. 21 0
      frontend/src/i18n/locales/pt-BR.ts
  35. 21 0
      frontend/src/i18n/locales/ru.ts
  36. 21 0
      frontend/src/i18n/locales/sv.ts
  37. 21 0
      frontend/src/i18n/locales/tr.ts
  38. 21 0
      frontend/src/i18n/locales/uk.ts
  39. 21 0
      frontend/src/i18n/locales/zh-CN.ts
  40. 21 0
      frontend/src/i18n/locales/zh-TW.ts
  41. 3 0
      frontend/src/pages/SettingsPage.tsx
  42. 10 9
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  43. 0 0
      static/assets/AdditionalSection-BGtsYm0C.js
  44. 0 0
      static/assets/AmsUnitCard-D51Ij2kp.js
  45. 0 0
      static/assets/ArchivesPage-BEEA2uU6.js
  46. 0 0
      static/assets/AreaChart-B_iFta1W.js
  47. 0 0
      static/assets/AssignToAmsModal-BrRlD3On.js
  48. 0 0
      static/assets/AuthContext-CtlY1WKC.js
  49. 1 1
      static/assets/Button-Bj3LhBE2.js
  50. 0 0
      static/assets/CamWallPage-DzGmZDoh.js
  51. 0 0
      static/assets/CameraDiagnoseModal-wS3Ojtq8.js
  52. 0 0
      static/assets/CameraPage-CXkSzxWy.js
  53. 0 0
      static/assets/CameraWall-DQWH0EjZ.js
  54. 1 1
      static/assets/Card-FgTRnwUz.js
  55. 0 0
      static/assets/CartesianChart-D5b3folV.js
  56. 0 0
      static/assets/ColumnConfigModal-DYsBO8W3.js
  57. 0 0
      static/assets/ConfigureAmsSlotModal-B1n2MyhW.js
  58. 0 0
      static/assets/ConfirmModal-Cswi0gVe.js
  59. 0 0
      static/assets/ConfirmOutcomeDialog-DPsFuoiC.js
  60. 0 0
      static/assets/ConnectAuthorizePage-CE5y-GnU.js
  61. 0 0
      static/assets/ConnectionDiagnostic-DSIiTSOB.js
  62. 0 0
      static/assets/ContextMenu-T2eJpTAg.js
  63. 0 0
      static/assets/CopyButton-BgtLjU7h.js
  64. 0 0
      static/assets/EmbeddedCameraViewer-DcRG4MV9.js
  65. 0 0
      static/assets/ErrorBarContext-CyYEO29m.js
  66. 0 0
      static/assets/ExternalLinkPage-BF6ZqoR1.js
  67. 1 1
      static/assets/FilamentSwatch-CAdwRRk_.js
  68. 0 1
      static/assets/FileManagerPage-VPvItkly.js
  69. 0 0
      static/assets/FileUploadModal-C5EmGbLJ.js
  70. 0 0
      static/assets/FinancePage-CJ5NIKQb.js
  71. 0 0
      static/assets/GCodeViewerPage-b8U_fpv-.js
  72. 0 0
      static/assets/GcodeToolpathViewer-Bem88qte.js
  73. 0 0
      static/assets/GroupEditPage-9qgwBVAv.js
  74. 0 0
      static/assets/IconPicker-CEohXxLZ.js
  75. 0 0
      static/assets/ImagePreviewModal-BLwogUZv.js
  76. 0 0
      static/assets/InventoryPage-CcAMJzgy.js
  77. 0 0
      static/assets/InventoryPage-DYzz0NU6.js
  78. 0 0
      static/assets/Layout-BpeAYG7Z.js
  79. 0 0
      static/assets/LibraryTrashPage-DfsdLcnh.js
  80. 0 0
      static/assets/LoginPage-DlDkNbTh.js
  81. 0 0
      static/assets/MaintenancePage-nb-T6m-M.js
  82. 0 0
      static/assets/ModelSourcesPage-B-_VQh36.js
  83. 0 0
      static/assets/NotificationsPage-C-wvgDo_.js
  84. 1 1
      static/assets/NumberInput-VlZYDJ-L.js
  85. 1 1
      static/assets/OverlayFrame-CshZQHBk.js
  86. 0 0
      static/assets/PdfPreviewModal-DcTJTry4.js
  87. 0 0
      static/assets/PhotoGalleryModal-BJsSxerb.js
  88. 0 0
      static/assets/PrintModal-Bs3aSIR5.js
  89. 0 0
      static/assets/PrinterLocationsPage-DvcxU12H.js
  90. 0 0
      static/assets/PrintersPage-DTfjL5O3.js
  91. 0 0
      static/assets/ProfilesPage-CJK7CLAE.js
  92. 0 0
      static/assets/ProjectDetailPage-BVvYuBA3.js
  93. 0 0
      static/assets/ProjectsPage-BpxjkLGA.js
  94. 0 0
      static/assets/QueryClientProvider-DEK0HFZS.js
  95. 0 0
      static/assets/QueuePage-WURT4IjU.js
  96. 0 0
      static/assets/ReferenceLine-UYgJeF5T.js
  97. 0 0
      static/assets/RichTextEditor-BqK8UORc.js
  98. 0 0
      static/assets/SettingsPage-CptQ63cW.js
  99. 0 0
      static/assets/SettingsPage-DjxLFpCF.js
  100. 0 0
      static/assets/SetupPage-PKyonBaG.js

+ 6 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 ## [1.3.0b1] - Unreleased
 
 
 ### Added
 ### Added
+- **New spools start with their product's presets and K profiles (#1326, requested by @mithkr)** — The slicer preset, the presets per printer model and the K profiles were set per spool, so every replacement for an empty spool had to be set up again by hand. They can now be saved once per product, as a profile default, and every new spool of that product starts with them.
+  - **Saving one:** set up a spool's Printers tab, tick **Save as default for new … spools** below it and save the spool. **All colours** is preselected; **Only <colour>** saves one for a colour calibrated on its own. A colour's default wins over the all-colours one per preset and per hotend, so the first spool of a new colour still gets the product's K profiles. Saving again replaces the default.
+  - **Every way a spool is added gets it:** the spool form, Quick Add, bulk add, RFID auto-add and **Add to Inventory**, SpoolBuddy and a forecast reorder, in both inventory modes, including spools the AMS sync adds to Spoolman. The spool form fills its Printers tab in from the default as soon as the product is filled in, and shows it. Elsewhere only what the new spool doesn't have yet is filled in, so an RFID spool keeps the preset on its tag.
+  - **Existing stock:** **Settings → Filament → Profile defaults** lists the defaults, deletes them, and **Apply to existing spools** fills in what active spools of a product don't have yet, never changing what they have. A default is copied onto a spool, not linked, so changing it later leaves existing spools alone.
+  - **Also fixed on the way:** adding several Spoolman spools at once dropped their slicer preset and colour name; each copy now gets both, as a single add does. Creating a spool through the form or SpoolBuddy without picking a K profile no longer saves an empty list over what the create gave it. A user limited to some printers who saves a spool's K profiles now replaces only those printers' profiles; the other printers' profiles stay, where the save used to delete them. Wiki updated.
 - **Time to the next pause, and a heads-up before it (#2984, requested by @apizz)** — A pause added in the slicer, for an NFC chip, a magnet or threaded inserts, came without warning: the only way to be there for it was to look up its layer in the slicer and keep checking the layer count.
 - **Time to the next pause, and a heads-up before it (#2984, requested by @apizz)** — A pause added in the slicer, for an NFC chip, a magnet or threaded inserts, came without warning: the only way to be there for it was to look up its layer in the slicer and keep checking the layer count.
   - **On the printer card:** next to the ETA, in blue, the time until the next pause and its clock time, e.g. "20m to pause · 20:27". Hover it for the layer. With several pauses it counts down to each in turn. Sizes M and up; the one-line size S card has no room for it. In a speed mode other than Standard it shows the layer only, as the slicer's times are for Standard speed.
   - **On the printer card:** next to the ETA, in blue, the time until the next pause and its clock time, e.g. "20m to pause · 20:27". Hover it for the layer. With several pauses it counts down to each in turn. Sizes M and up; the one-line size S card has no room for it. In a speed mode other than Standard it shows the layer only, as the slicer's times are for Standard speed.
   - **Pause Coming Up notification:** a new event for notification channels, sent once per pause, 5 minutes before by default (at Standard speed). Set the minutes under Settings > Notifications > **Pause Heads-Up** (1-120). Off on every channel until turned on, with its own template.
   - **Pause Coming Up notification:** a new event for notification channels, sent once per pause, 5 minutes before by default (at Standard speed). Set the minutes under Settings > Notifications > **Pause Heads-Up** (1-120). Off on every channel until turned on, with its own template.
@@ -185,6 +190,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Bambu PLA Silk+ spools added by RFID get their colour name** — An RFID roll is named from the colour catalogue by its hex and the product line the AMS reports. A single-colour Silk+ roll reports "PLA Silk+", but the catalogue had the Silk+ colours (Silver, Titan Gray, Gold and the rest) filed under "PLA Silk", the line the AMS reports for the Dual/Tri Color silk, so every Silk+ roll arrived without a name, in both inventory modes. The seeded colours are now filed under "PLA Silk+". On an existing install the update moves the seeded Bambu Lab rows that are still as seeded (rows you edited or added stay as they are), and names the RFID-added Bambu spools that came in without a colour name; a name already set is never changed.
 - **The X2D's auxiliary fans are named the right way round (#3373, reported by @Spegeli)** — On the X2D, the printer card's **Left Auxiliary Fan** drove the fan on the right of the chamber, and **Auxiliary Fan** the one on the left. The names came from the P2S, which numbers these two fans the other way round. On an X2D the card now shows **Left Auxiliary Fan** then **Right Auxiliary Fan**, still left to right, and the confirmation after setting a speed names the same side. The P2S is unchanged. The API keeps its field and fan names (`left_aux_fan_speed`, `aux`, `aux2`): on an X2D, `aux2` and `left_aux_fan_speed` are the right fan. Wiki updated.
 - **The X2D's auxiliary fans are named the right way round (#3373, reported by @Spegeli)** — On the X2D, the printer card's **Left Auxiliary Fan** drove the fan on the right of the chamber, and **Auxiliary Fan** the one on the left. The names came from the P2S, which numbers these two fans the other way round. On an X2D the card now shows **Left Auxiliary Fan** then **Right Auxiliary Fan**, still left to right, and the confirmation after setting a speed names the same side. The P2S is unchanged. The API keeps its field and fan names (`left_aux_fan_speed`, `aux`, `aux2`): on an X2D, `aux2` and `left_aux_fan_speed` are the right fan. Wiki updated.
 - **The full-page G-code preview draws on wide screens (#3371, reported by @Underscan007)** — On a window 1024 px wide or more, with classic (non-overlay) scrollbars, **3D Preview** stayed black while the page scrollbar flickered on and off. The canvas was briefly wider than its pane after each resize and pushed the page into scrolling, which narrowed the pane again, without end. The preview is now clipped to its pane, so it settles at once and draws.
 - **The full-page G-code preview draws on wide screens (#3371, reported by @Underscan007)** — On a window 1024 px wide or more, with classic (non-overlay) scrollbars, **3D Preview** stayed black while the page scrollbar flickered on and off. The canvas was briefly wider than its pane after each resize and pushed the page into scrolling, which narrowed the pane again, without end. The preview is now clipped to its pane, so it settles at once and draws.
 - **A print that finishes while Bambuddy restarts is archived as completed (#3310, reported by @Thomansky)** — When a print ended while Bambuddy was down (a container update, a host reboot), the printer's FINISH arrived as the first status of a new connection, so it never counted as the print ending. The reconnect then closed the archive as aborted with "No status update", logged it with no print time, cancelled its queue job and sent "print stopped". Now, when the printer still reports the archive's own job (the same job ID) as finished, the archive is closed as completed, with the library print count, batch run, finish notification and smart-plug auto-off that go with it. Its end time is the start plus the slicer's estimate for the plate that printed, never later than the reconnect, since the printer doesn't say when it finished. That time counts in the print-time totals but not in time accuracy, on the archive card or in Statistics. A failed job is closed as failed, with the printer's error as the reason when it has one. When the printer is idle, shows another job or reports no job ID, the archive is closed as aborted, as before.
 - **A print that finishes while Bambuddy restarts is archived as completed (#3310, reported by @Thomansky)** — When a print ended while Bambuddy was down (a container update, a host reboot), the printer's FINISH arrived as the first status of a new connection, so it never counted as the print ending. The reconnect then closed the archive as aborted with "No status update", logged it with no print time, cancelled its queue job and sent "print stopped". Now, when the printer still reports the archive's own job (the same job ID) as finished, the archive is closed as completed, with the library print count, batch run, finish notification and smart-plug auto-off that go with it. Its end time is the start plus the slicer's estimate for the plate that printed, never later than the reconnect, since the printer doesn't say when it finished. That time counts in the print-time totals but not in time accuracy, on the archive card or in Statistics. A failed job is closed as failed, with the printer's error as the reason when it has one. When the printer is idle, shows another job or reports no job ID, the archive is closed as aborted, as before.

+ 208 - 3
backend/app/api/routes/inventory.py

@@ -25,7 +25,9 @@ from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.filament_profile_default import FilamentProfileDefault
 from backend.app.models.location import Location
 from backend.app.models.location import Location
+from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -34,6 +36,12 @@ from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key
 from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.schemas.filament_profile_default import (
+    FilamentProfileDefaultResponse,
+    FilamentProfileDefaultWrite,
+    ProfileDefaultApplyResult,
+    ResolvedProfileDefault,
+)
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.spool import (
 from backend.app.schemas.spool import (
@@ -60,7 +68,7 @@ from backend.app.schemas.supplier import (
     SupplierStats,
     SupplierStats,
     SupplierUpdate,
     SupplierUpdate,
 )
 )
-from backend.app.services import slot_unlink_grace, spool_swaps
+from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
 from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
 from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
@@ -1639,6 +1647,8 @@ async def create_spool(
     # A new spool of a product that already carries supplier assignments
     # A new spool of a product that already carries supplier assignments
     # inherits the source list (#2988).
     # inherits the source list (#2988).
     await apply_supplier_inheritance(db, spool)
     await apply_supplier_inheritance(db, spool)
+    # ...and the product's profile default, where the request brought none (#1326).
+    await profile_defaults.fill_internal_spool(db, spool)
     await db.commit()
     await db.commit()
     await db.refresh(spool)
     await db.refresh(spool)
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool.id))
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool.id))
@@ -1669,6 +1679,14 @@ async def bulk_create_spools(
     await db.flush()
     await db.flush()
     # Every copy gets its own link rows, from one donor lookup (#2988).
     # Every copy gets its own link rows, from one donor lookup (#2988).
     await apply_supplier_inheritance_to_batch(db, spools)
     await apply_supplier_inheritance_to_batch(db, spools)
+    # Every copy is the same product, so one profile default for all (#1326).
+    if spools:
+        first = spools[0]
+        resolved = await profile_defaults.resolve_profile_default(
+            db, brand=first.brand, material=first.material, subtype=first.subtype, color_name=first.color_name
+        )
+        for spool in spools:
+            await profile_defaults.fill_internal_spool(db, spool, resolved=resolved)
     await db.commit()
     await db.commit()
     ids = [s.id for s in spools]
     ids = [s.id for s in spools]
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id.in_(ids)))
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id.in_(ids)))
@@ -2009,8 +2027,14 @@ async def replace_k_profiles(
     profiles: list[SpoolKProfileBase],
     profiles: list[SpoolKProfileBase],
     db: AsyncSession = Depends(get_db, scope="function"),
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """Replace all K-profiles for a spool (batch save)."""
+    """Replace all K-profiles for a spool (batch save).
+
+    A caller limited to some printers (#1727) replaces only theirs: the
+    profiles of printers it cannot see -- which its form never showed, a new
+    spool's from its product's profile default (#1326) among them -- stay.
+    """
     # Verify spool exists
     # Verify spool exists
     result = await db.execute(select(Spool).where(Spool.id == spool_id))
     result = await db.execute(select(Spool).where(Spool.id == spool_id))
     if not result.scalar_one_or_none():
     if not result.scalar_one_or_none():
@@ -2019,11 +2043,14 @@ async def replace_k_profiles(
     # Delete existing
     # Delete existing
     existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
     existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
     for old in existing.scalars().all():
     for old in existing.scalars().all():
-        await db.delete(old)
+        if printer_scope.allows(old.printer_id):
+            await db.delete(old)
 
 
     # Create new
     # Create new
     new_profiles = []
     new_profiles = []
     for p in profiles:
     for p in profiles:
+        if not printer_scope.allows(p.printer_id):
+            continue
         kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
         kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
         db.add(kp)
         db.add(kp)
         new_profiles.append(kp)
         new_profiles.append(kp)
@@ -2151,6 +2178,184 @@ async def replace_filament_presets(
     return new_presets
     return new_presets
 
 
 
 
+# ── Profile defaults (#1326) ─────────────────────────────────────────────────
+#
+# Shared by both inventory modes: a default is keyed on the product, not on a
+# spool. Applying one to existing spools is the only mode-specific part, so
+# the Spoolman twin of the apply route lives in spoolman_inventory.py.
+
+
+def _profile_default_response(row: FilamentProfileDefault, scope: PrinterScope) -> FilamentProfileDefaultResponse:
+    return FilamentProfileDefaultResponse(
+        id=row.id,
+        brand=row.brand,
+        material=row.material,
+        subtype=row.subtype,
+        color_name=row.color_name,
+        slicer_filament=row.slicer_filament,
+        slicer_filament_name=row.slicer_filament_name,
+        presets=[SpoolFilamentPresetBase.model_validate(p, from_attributes=True) for p in row.presets],
+        # A caller limited to some printers (#1727) sees only theirs.
+        k_profiles=[
+            SpoolKProfileBase.model_validate(k, from_attributes=True)
+            for k in row.k_profiles
+            if scope.allows(k.printer_id)
+        ],
+        updated_at=row.updated_at,
+    )
+
+
+@router.get("/profile-defaults", response_model=list[FilamentProfileDefaultResponse])
+async def list_profile_defaults(
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Every profile default, ordered by product."""
+    result = await db.execute(
+        select(FilamentProfileDefault)
+        .options(selectinload(FilamentProfileDefault.presets), selectinload(FilamentProfileDefault.k_profiles))
+        .order_by(
+            func.lower(FilamentProfileDefault.brand),
+            func.lower(FilamentProfileDefault.material),
+            func.lower(FilamentProfileDefault.subtype),
+            func.lower(FilamentProfileDefault.color_name),
+        )
+    )
+    return [_profile_default_response(row, printer_scope) for row in result.scalars().all()]
+
+
+@router.get("/profile-defaults/resolve", response_model=ResolvedProfileDefault | None)
+async def resolve_profile_default(
+    material: str,
+    brand: str = "",
+    subtype: str = "",
+    color_name: str = "",
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """What a new spool of this product starts with; null when it has no default.
+
+    The spool form fills its Printers tab from this, so the user sees what the
+    create will apply before saving.
+    """
+    resolved = await profile_defaults.resolve_profile_default(
+        db, brand=brand, material=material, subtype=subtype, color_name=color_name
+    )
+    if resolved is None:
+        return None
+    return ResolvedProfileDefault(
+        slicer_filament=resolved.slicer_filament,
+        slicer_filament_name=resolved.slicer_filament_name,
+        presets=[SpoolFilamentPresetBase(**p) for p in resolved.presets],
+        k_profiles=[SpoolKProfileBase(**k) for k in resolved.k_profiles if printer_scope.allows(k["printer_id"])],
+        default_ids=resolved.default_ids,
+    )
+
+
+@router.put("/profile-defaults", response_model=FilamentProfileDefaultResponse)
+async def save_profile_default(
+    data: FilamentProfileDefaultWrite,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Create or replace the default for the product in the body.
+
+    A full replacement, like the spool's own K-profile and preset endpoints:
+    the body is everything the default holds. K profiles of printers the
+    caller cannot see are kept rather than cleared, and the body's rows for
+    them are ignored.
+    """
+    # Duplicates rejected by name, before anything is written.
+    seen_presets: set[tuple[str, str]] = set()
+    for p in data.presets:
+        key = (p.printer_model, p.nozzle_diameter)
+        if key in seen_presets:
+            raise HTTPException(
+                422, f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}"
+            )
+        seen_presets.add(key)
+    seen_hotends: set[tuple[int, int, str]] = set()
+    for k in data.k_profiles:
+        key = profile_defaults.hotend_key(k.printer_id, k.extruder, k.nozzle_diameter)
+        if key in seen_hotends:
+            raise HTTPException(422, f"Duplicate K profile for printer {k.printer_id} extruder {k.extruder}")
+        seen_hotends.add(key)
+    # Rows for printers the caller cannot see are ignored, as the spool's own
+    # K-profile save does: a form opened on a spool shows them all, and saving
+    # it as the default must not fail over rows the caller cannot change.
+    k_profiles = [k for k in data.k_profiles if printer_scope.allows(k.printer_id)]
+    printer_ids = {k.printer_id for k in k_profiles}
+    if printer_ids:
+        found = set((await db.execute(select(Printer.id).where(Printer.id.in_(printer_ids)))).scalars().all())
+        if printer_ids - found:
+            raise HTTPException(404, "Printer not found")
+
+    key = profile_defaults.product_key(
+        brand=data.brand, material=data.material, subtype=data.subtype, color_name=data.color_name
+    )
+    existing = await profile_defaults.find_default(db, key)
+    keep = (
+        {k.printer_id for k in existing.k_profiles if not printer_scope.allows(k.printer_id)}
+        if existing is not None
+        else set()
+    )
+    row = await profile_defaults.save_default(
+        db,
+        key,
+        slicer_filament=data.slicer_filament,
+        slicer_filament_name=data.slicer_filament_name,
+        presets=[p.model_dump() for p in data.presets],
+        k_profiles=[k.model_dump() for k in k_profiles],
+        keep_printer_ids=keep,
+    )
+    await db.commit()
+    row = await profile_defaults.load_default(db, row.id)
+    assert row is not None
+    return _profile_default_response(row, printer_scope)
+
+
+@router.delete("/profile-defaults/{default_id}")
+async def delete_profile_default(
+    default_id: int,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Delete a default. Spools that already got its profiles keep them."""
+    row = await profile_defaults.load_default(db, default_id)
+    if row is None:
+        raise HTTPException(404, "Profile default not found")
+    if any(not printer_scope.allows(k.printer_id) for k in row.k_profiles):
+        raise HTTPException(403, "This default holds K profiles for printers you cannot access")
+    await db.delete(row)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+@router.post("/profile-defaults/{default_id}/apply", response_model=ProfileDefaultApplyResult)
+async def apply_profile_default(
+    default_id: int,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Fill in what active spools of this default's product do not have yet.
+
+    Never overwrites: a spool keeps its own preset, its preset per model and
+    nozzle, and its K profile per hotend.
+    """
+    row = await profile_defaults.load_default(db, default_id)
+    if row is None:
+        raise HTTPException(404, "Profile default not found")
+    updated = await profile_defaults.apply_to_internal_spools(db, row)
+    await db.commit()
+    if updated:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return ProfileDefaultApplyResult(updated=updated)
+
+
 # ── Spool Assignments ────────────────────────────────────────────────────────
 # ── Spool Assignments ────────────────────────────────────────────────────────
 
 
 
 

+ 162 - 31
backend/app/api/routes/spoolman_inventory.py

@@ -55,7 +55,7 @@ from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
-from backend.app.services import slot_unlink_grace, spool_swaps
+from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
     enrich_spool_dicts_with_location_id,
     maybe_sync_spoolman_locations,
     maybe_sync_spoolman_locations,
@@ -571,6 +571,80 @@ async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanCl
         )
         )
 
 
 
 
+async def _resolve_create_default(
+    db: AsyncSession, data: SpoolmanInventoryCreate, spool: dict
+) -> profile_defaults.ResolvedProfiles | None:
+    """The profile default for a spool this request created (#1326).
+
+    From the request's product fields; a request that only names a catalog
+    filament (``spoolman_filament_id``) carries none, so then from the spool
+    Spoolman made -- its colour name only when it is a real one, the mapper
+    fills a missing one with the subtype.
+    """
+    if data.material:
+        return await profile_defaults.resolve_profile_default(
+            db, brand=data.brand, material=data.material, subtype=data.subtype, color_name=data.color_name
+        )
+    mapped = _map_spoolman_spool(spool)
+    return await profile_defaults.resolve_profile_default(
+        db,
+        brand=mapped["brand"],
+        material=mapped["material"],
+        subtype=mapped["subtype"],
+        color_name=data.color_name or (None if mapped["color_name_is_synthesized"] else mapped["color_name"]),
+    )
+
+
+async def _finish_created_spool(
+    db: AsyncSession,
+    client: SpoolmanClient,
+    spool: dict,
+    data: SpoolmanInventoryCreate,
+    default: profile_defaults.ResolvedProfiles | None,
+) -> dict:
+    """What a create does after Spoolman made the spool: the fields Spoolman
+    has no place for, and the product's profile default (#1326).
+
+    The slicer preset and the colour name go under the spool's extra dict
+    (mirror update_spool) -- Spoolman has no ``color_name`` on a filament
+    (#1357). A preset the request did not bring comes from the default, and
+    its preset overrides and K profiles land in the Bambuddy tables keyed by
+    the new spool's id. The spool exists either way, so a failed write is
+    logged and the create still succeeds.
+    """
+    slicer_filament = data.slicer_filament
+    slicer_filament_name = data.slicer_filament_name
+    if not slicer_filament and default is not None and default.slicer_filament:
+        slicer_filament = default.slicer_filament
+        slicer_filament_name = default.slicer_filament_name
+
+    new_extra: dict = {}
+    if slicer_filament is not None:
+        new_extra["bambu_slicer_filament"] = json.dumps(slicer_filament)
+    if slicer_filament_name is not None:
+        new_extra["bambu_slicer_filament_name"] = json.dumps(slicer_filament_name)
+    if data.color_name is not None:
+        new_extra["bambu_color_name"] = json.dumps(data.color_name)
+    if new_extra:
+        try:
+            async with _translate_spoolman_errors():
+                # Registered before the write, or Spoolman refuses the keys.
+                for name in new_extra:
+                    await client.ensure_extra_field(name)
+                spool = await client.merge_spool_extra(spool["id"], new_extra)
+        except HTTPException:
+            logger.warning("Failed to persist slicer_filament/color_name for spool %s", spool.get("id"))
+
+    if default is not None:
+        try:
+            if await profile_defaults.fill_spoolman_rows(db, int(spool["id"]), default):
+                await db.commit()
+        except Exception as exc:  # noqa: BLE001 — the spool is created; its profiles can be set by hand
+            await db.rollback()
+            logger.warning("Profile default for Spoolman spool %s not applied: %s", spool.get("id"), exc)
+    return spool
+
+
 @router.post("/spools")
 @router.post("/spools")
 async def create_spool(
 async def create_spool(
     data: SpoolmanInventoryCreate,
     data: SpoolmanInventoryCreate,
@@ -617,34 +691,7 @@ async def create_spool(
 
 
     spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
     spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
 
 
-    # Persist slicer_filament AND color_name under the spool's extra dict
-    # (mirror update_spool). Spoolman has no `color_name` field on filament
-    # (#1357) so we own the round-trip ourselves.
-    if data.slicer_filament is not None or data.slicer_filament_name is not None or data.color_name is not None:
-        # Ensure extra fields are registered before write.
-        if data.slicer_filament is not None:
-            await client.ensure_extra_field("bambu_slicer_filament")
-        if data.slicer_filament_name is not None:
-            await client.ensure_extra_field("bambu_slicer_filament_name")
-        if data.color_name is not None:
-            await client.ensure_extra_field("bambu_color_name")
-        new_extra: dict = {}
-        if data.slicer_filament is not None:
-            new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
-        if data.slicer_filament_name is not None:
-            new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
-        if data.color_name is not None:
-            new_extra["bambu_color_name"] = json.dumps(data.color_name)
-        if new_extra:
-            try:
-                async with _translate_spoolman_errors():
-                    spool = await client.merge_spool_extra(spool["id"], new_extra)
-            except HTTPException:
-                # Best-effort — the spool already exists, log and continue.
-                logger.warning(
-                    "Failed to persist slicer_filament/color_name for spool %s",
-                    spool.get("id"),
-                )
+    spool = await _finish_created_spool(db, client, spool, data, await _resolve_create_default(db, data, spool))
 
 
     result = _map_spoolman_spool(spool)
     result = _map_spoolman_spool(spool)
     await ws_manager.broadcast({"type": "inventory_changed"})
     await ws_manager.broadcast({"type": "inventory_changed"})
@@ -688,6 +735,9 @@ async def bulk_create_spools(
     remaining = max(0.0, data.label_weight - data.weight_used)
     remaining = max(0.0, data.label_weight - data.weight_used)
     created: list[dict] = []
     created: list[dict] = []
     failures: list[str] = []
     failures: list[str] = []
+    # Every copy is the same product: one lookup for the batch, on the first.
+    default: profile_defaults.ResolvedProfiles | None = None
+    resolved_default = False
     for _ in range(payload.quantity):
     for _ in range(payload.quantity):
         try:
         try:
             spool = await client.create_spool(
             spool = await client.create_spool(
@@ -717,6 +767,12 @@ async def bulk_create_spools(
             continue
             continue
         if price_warnings:
         if price_warnings:
             logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
             logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
+        # Each copy gets the preset and colour name the single create gives,
+        # and the product's profile default (#1326).
+        if not resolved_default:
+            default = await _resolve_create_default(db, data, spool)
+            resolved_default = True
+        spool = await _finish_created_spool(db, client, spool, data, default)
         created.append(_map_spoolman_spool(spool))
         created.append(_map_spoolman_spool(spool))
 
 
     if not created:
     if not created:
@@ -2454,6 +2510,71 @@ async def patch_spoolman_filament(
     return normalized
     return normalized
 
 
 
 
+@router.post("/profile-defaults/{default_id}/apply")
+async def apply_profile_default_to_spoolman_spools(
+    default_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Spoolman twin of ``POST /inventory/profile-defaults/{id}/apply`` (#1326).
+
+    Fills in what active Spoolman spools of the default's product do not have
+    yet: the spool's own preset (in its extra dict), and its preset overrides
+    and K profiles (Bambuddy tables keyed by the spool id). Never overwrites.
+    """
+    client = await _get_client(db)
+    row = await profile_defaults.load_default(db, default_id)
+    if row is None:
+        raise HTTPException(404, "Profile default not found")
+    async with _translate_spoolman_errors():
+        spools = await client.get_all_spools()
+
+    updated = 0
+    cache: dict[str, profile_defaults.ResolvedProfiles | None] = {}
+    slicer_field_ready = False
+    for raw in spools:
+        try:
+            mapped = _map_spoolman_spool(raw)
+        except ValueError:
+            continue
+        # The mapper fills a missing colour name with the subtype; that is no colour.
+        colour = None if mapped["color_name_is_synthesized"] else mapped["color_name"]
+        if mapped["archived_at"] or not profile_defaults.default_matches(
+            row, brand=mapped["brand"], material=mapped["material"], subtype=mapped["subtype"], color_name=colour
+        ):
+            continue
+        colour_key = (colour or "").strip().lower()
+        if colour_key not in cache:
+            cache[colour_key] = await profile_defaults.resolve_profile_default(
+                db, brand=row.brand, material=row.material, subtype=row.subtype, color_name=colour
+            )
+        resolved = cache[colour_key]
+        if resolved is None:
+            continue
+        changed = await profile_defaults.fill_spoolman_rows(db, mapped["id"], resolved)
+        if resolved.slicer_filament and not mapped["slicer_filament"]:
+            new_extra = {"bambu_slicer_filament": json.dumps(resolved.slicer_filament)}
+            if resolved.slicer_filament_name:
+                new_extra["bambu_slicer_filament_name"] = json.dumps(resolved.slicer_filament_name)
+            try:
+                async with _translate_spoolman_errors():
+                    if not slicer_field_ready:
+                        await client.ensure_extra_field("bambu_slicer_filament")
+                        await client.ensure_extra_field("bambu_slicer_filament_name")
+                        slicer_field_ready = True
+                    await client.merge_spool_extra(mapped["id"], new_extra)
+                forget_spoolman_spool_own_preset(mapped["id"])
+                changed = True
+            except HTTPException:
+                logger.warning("Profile default: preset not written to Spoolman spool %s", mapped["id"])
+        if changed:
+            updated += 1
+    await db.commit()
+    if updated:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": updated}
+
+
 @router.get("/spools/{spool_id}/k-profiles")
 @router.get("/spools/{spool_id}/k-profiles")
 async def get_spoolman_k_profiles(
 async def get_spoolman_k_profiles(
     spool_id: int = Path(..., gt=0),
     spool_id: int = Path(..., gt=0),
@@ -2473,16 +2594,26 @@ async def save_spoolman_k_profiles(
     profiles: list[SpoolKProfileBase] = Body(...),
     profiles: list[SpoolKProfileBase] = Body(...),
     db: AsyncSession = Depends(get_db, scope="function"),
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> list[dict]:
 ) -> list[dict]:
-    """Replace all K-value calibration profiles for a Spoolman spool."""
+    """Replace all K-value calibration profiles for a Spoolman spool.
+
+    As the internal route: a caller limited to some printers (#1727) replaces
+    only theirs, and the other printers' profiles stay.
+    """
     client = await _get_client(db)
     client = await _get_client(db)
     async with _translate_spoolman_errors():
     async with _translate_spoolman_errors():
         await client.get_spool(spool_id)
         await client.get_spool(spool_id)
 
 
     saved: list[SpoolmanKProfile] = []
     saved: list[SpoolmanKProfile] = []
     try:
     try:
-        await db.execute(delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
+        stmt = delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id)
+        if (in_scope := printer_scope.where_strict(SpoolmanKProfile.printer_id)) is not None:
+            stmt = stmt.where(in_scope)
+        await db.execute(stmt)
         for profile in profiles:
         for profile in profiles:
+            if not printer_scope.allows(profile.printer_id):
+                continue
             obj = SpoolmanKProfile(
             obj = SpoolmanKProfile(
                 spoolman_spool_id=spool_id,
                 spoolman_spool_id=spool_id,
                 printer_id=profile.printer_id,
                 printer_id=profile.printer_id,

+ 16 - 14
backend/app/core/catalog_defaults.py

@@ -154,20 +154,22 @@ DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
     ("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
     ("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
     ("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
     ("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
     ("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
     ("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
-    # Bambu Lab PLA Silk+ (from store page)
-    ("Bambu Lab", "Gold", "#F4A925", "PLA Silk"),
-    ("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk"),
-    ("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk"),
-    ("Bambu Lab", "Blue", "#008BDA", "PLA Silk"),
-    ("Bambu Lab", "Purple", "#8671CB", "PLA Silk"),
-    ("Bambu Lab", "Candy Red", "#D02727", "PLA Silk"),
-    ("Bambu Lab", "Candy Green", "#018814", "PLA Silk"),
-    ("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk"),
-    ("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk"),
-    ("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk"),
-    ("Bambu Lab", "Mint", "#96DCB9", "PLA Silk"),
-    ("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk"),
-    ("Bambu Lab", "White", "#FFFFFF", "PLA Silk"),
+    # Bambu Lab PLA Silk+ (from store page). Filed under "PLA Silk+", the line
+    # the AMS reports for these rolls; "PLA Silk" is what it reports for the
+    # Dual/Tri Color silk, which has no rows here.
+    ("Bambu Lab", "Gold", "#F4A925", "PLA Silk+"),
+    ("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk+"),
+    ("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk+"),
+    ("Bambu Lab", "Blue", "#008BDA", "PLA Silk+"),
+    ("Bambu Lab", "Purple", "#8671CB", "PLA Silk+"),
+    ("Bambu Lab", "Candy Red", "#D02727", "PLA Silk+"),
+    ("Bambu Lab", "Candy Green", "#018814", "PLA Silk+"),
+    ("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk+"),
+    ("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk+"),
+    ("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk+"),
+    ("Bambu Lab", "Mint", "#96DCB9", "PLA Silk+"),
+    ("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk+"),
+    ("Bambu Lab", "White", "#FFFFFF", "PLA Silk+"),
     # Bambu Lab PLA Sparkle (from store page)
     # Bambu Lab PLA Sparkle (from store page)
     ("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
     ("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
     ("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),
     ("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),

+ 112 - 0
backend/app/core/database.py

@@ -623,6 +623,7 @@ async def init_db():
         connected_app,
         connected_app,
         external_link,
         external_link,
         filament,
         filament,
+        filament_profile_default,
         filament_sku_settings,
         filament_sku_settings,
         finance,
         finance,
         github_backup,
         github_backup,
@@ -5717,6 +5718,12 @@ async def _run_migrations(conn):
     # whatever this database actually holds.
     # whatever this database actually holds.
     await _migrate_repair_rfid_core_weight(conn)
     await _migrate_repair_rfid_core_weight(conn)
 
 
+    # Migration: the Bambu PLA Silk+ colours were seeded under "PLA Silk", so
+    # an RFID roll reporting "PLA Silk+" never got its colour name. Relabel
+    # the seeded rows, then name the spools that came in without one.
+    await _migrate_relabel_bambu_silk_plus_colors(conn)
+    await _migrate_fill_rfid_color_names(conn)
+
     # Migration: drop the AMS slot markers an older Bambuddy wrote into
     # Migration: drop the AMS slot markers an older Bambuddy wrote into
     # Spoolman and the location sync then imported as storage locations.
     # Spoolman and the location sync then imported as storage locations.
     await _migrate_drop_ams_slot_locations(conn)
     await _migrate_drop_ams_slot_locations(conn)
@@ -7208,6 +7215,111 @@ async def _migrate_repair_rfid_core_weight(conn) -> None:
         )
         )
 
 
 
 
+async def _migrate_relabel_bambu_silk_plus_colors(conn) -> None:
+    """File the seeded Bambu PLA Silk+ colours under "PLA Silk+".
+
+    They were seeded as "PLA Silk", the line the AMS reports for the Dual/Tri
+    Color silk, while a single-colour Silk+ roll reports "PLA Silk+". The RFID
+    lookup compares that string with the catalogue's material, so every Silk+
+    roll arrived without a colour name. The catalogue is only seeded when it is
+    empty, so an existing install keeps the old rows until this relabels them.
+
+    Only rows that are still exactly a seeded entry (Bambu Lab, same name, same
+    hex, "PLA Silk") move; a row the user edited or added stays. One-shot, so a
+    row the user files back under "PLA Silk" afterwards stays there.
+    """
+    from sqlalchemy import text
+
+    from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
+
+    flag = "_migrate_silk_plus_catalog_done"
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+        moved = 0
+        for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
+            if manufacturer != "Bambu Lab" or material != "PLA Silk+":
+                continue
+            result = await conn.execute(
+                text(
+                    "UPDATE color_catalog SET material = 'PLA Silk+' "
+                    "WHERE manufacturer = 'Bambu Lab' AND material = 'PLA Silk' "
+                    "AND color_name = :name AND UPPER(hex_color) = :hex"
+                ),
+                {"name": color_name, "hex": hex_color.upper()},
+            )
+            moved += result.rowcount or 0
+        if moved:
+            logger.info("Filed %d Bambu Lab catalogue colour(s) under PLA Silk+", moved)
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
+
+
+async def _migrate_fill_rfid_color_names(conn) -> None:
+    """Give RFID-added Bambu spools without a colour name the catalogue's name.
+
+    The spools the Silk+ mislabel (``_migrate_relabel_bambu_silk_plus_colors``)
+    left unnamed, and any other RFID roll whose colour the catalogue holds now.
+    The lookup is the RFID path's own: Bambu Lab, the hex, and the product line
+    the AMS reported, which the spool stores as material + subtype ("PLA" +
+    "Silk+"). A spool whose subtype the create path rewrote (Gradient, Dual or
+    Tri Color) does not rebuild its reported line and stays as it is, as does a
+    clear roll -- the create path names those "Clear" without the catalogue.
+
+    Only empty names are filled, never one the user set. One-shot.
+    """
+    from sqlalchemy import text
+
+    flag = "_backfill_rfid_color_names_done"
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+        spools = (
+            await conn.execute(
+                text(
+                    "SELECT id, material, subtype, rgba, extra_colors, effect_type FROM spool "
+                    "WHERE data_origin = 'rfid_auto' AND brand = 'Bambu Lab' "
+                    "AND (color_name IS NULL OR color_name = '') AND rgba IS NOT NULL"
+                )
+            )
+        ).fetchall()
+        named = 0
+        for spool in spools:
+            rgba = spool.rgba or ""
+            if len(rgba) < 6 or (len(rgba) == 8 and rgba[6:8] == "00"):
+                continue
+            line = f"{spool.material} {spool.subtype}" if spool.subtype else (spool.material or "")
+            entry = (
+                await conn.execute(
+                    text(
+                        "SELECT color_name, extra_colors, effect_type FROM color_catalog "
+                        "WHERE UPPER(manufacturer) = 'BAMBU LAB' AND UPPER(hex_color) = :hex "
+                        "AND UPPER(material) = :line ORDER BY id LIMIT 1"
+                    ),
+                    {"hex": f"#{rgba[:6].upper()}", "line": line.upper()},
+                )
+            ).fetchone()
+            if entry is None:
+                continue
+            await conn.execute(
+                text(
+                    "UPDATE spool SET color_name = :name, "
+                    "extra_colors = COALESCE(extra_colors, :extra), effect_type = COALESCE(effect_type, :effect) "
+                    "WHERE id = :id"
+                ),
+                {"name": entry.color_name, "extra": entry.extra_colors, "effect": entry.effect_type, "id": spool.id},
+            )
+            named += 1
+        if named:
+            logger.info("Named %d RFID-added spool(s) from the colour catalogue", named)
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
+
+
 async def _migrate_backfill_variant_groups(conn) -> None:
 async def _migrate_backfill_variant_groups(conn) -> None:
     """Build variant groups from the slice provenance already on disk (#671 / #2570).
     """Build variant groups from the slice provenance already on disk (#671 / #2570).
 
 

+ 7 - 0
backend/app/core/sqlite_fk.py

@@ -92,6 +92,13 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("slicer_pipelines", "target_printer_id"),
         ("slicer_pipelines", "target_printer_id"),
         # A deleted printer's spools would stay "assigned" to it (SA-1).
         # A deleted printer's spools would stay "assigned" to it (SA-1).
         ("spool_assignment", "printer_id"),
         ("spool_assignment", "printer_id"),
+        # A profile default's K profile would select a calibration slot on
+        # the next printer given the id (#1326).
+        ("filament_profile_default_k_profile", "printer_id"),
+        # filament_profile_default: the next default given the id would take
+        # over its presets and K profiles (#1326).
+        ("filament_profile_default_k_profile", "profile_default_id"),
+        ("filament_profile_default_preset", "profile_default_id"),
         # external_links: the next link given the id would show the count an
         # external_links: the next link given the id would show the count an
         # app reported for the deleted one.
         # app reported for the deleted one.
         ("external_link_badges", "link_id"),
         ("external_link_badges", "link_id"),

+ 8 - 0
backend/app/models/__init__.py

@@ -7,6 +7,11 @@ from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitE
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.filament_profile_default import (
+    FilamentProfileDefault,
+    FilamentProfileDefaultKProfile,
+    FilamentProfileDefaultPreset,
+)
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, group_locations, group_printers, user_groups
 from backend.app.models.group import Group, group_locations, group_printers, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
@@ -95,6 +100,9 @@ __all__ = [
     "SlicerPipeline",
     "SlicerPipeline",
     "Spool",
     "Spool",
     "SpoolFilamentPreset",
     "SpoolFilamentPreset",
+    "FilamentProfileDefault",
+    "FilamentProfileDefaultKProfile",
+    "FilamentProfileDefaultPreset",
     "SpoolKProfile",
     "SpoolKProfile",
     "SpoolmanFilamentPreset",
     "SpoolmanFilamentPreset",
     "SpoolAssignment",
     "SpoolAssignment",

+ 101 - 0
backend/app/models/filament_profile_default.py

@@ -0,0 +1,101 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class FilamentProfileDefault(Base):
+    """The profiles a new spool of one product starts with (#1326).
+
+    A spool's slicer preset, per-model preset overrides and K profiles describe
+    the filament, not the roll, so the replacement for an empty spool wants the
+    same ones. A default holds them once per product, keyed on the
+    (brand, material, subtype, color_name) strings the spool carries, and every
+    create path copies them onto the new spool -- a copy, not a link: editing a
+    default later leaves existing spools alone.
+
+    ``color_name`` "" is the default for every colour of the product; a row
+    with a colour wins over it, per preset and per hotend (see
+    ``services.filament_profile_default.resolve_profile_default``), so the
+    first spool of a new colour still gets the product's K profiles and a
+    colour calibrated on its own keeps its values. Empty strings rather than
+    NULL for the same reason as ``spool_filament_preset.nozzle_diameter``:
+    NULLs compare distinct in a UNIQUE constraint on both databases.
+
+    Shared by both inventory modes: the key is the product, which a Spoolman
+    spool carries as well as an internal one.
+    """
+
+    __tablename__ = "filament_profile_default"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    # The four fields folded in Python (services.filament_profile_default.match_key),
+    # not in SQL: SQLite's lower() folds ASCII only, so "Красный" would never
+    # match its own row there -- the same reason Supplier.name_key exists.
+    match_key: Mapped[str] = mapped_column(String(320), unique=True)
+    brand: Mapped[str] = mapped_column(String(100), default="")
+    material: Mapped[str] = mapped_column(String(50))
+    subtype: Mapped[str] = mapped_column(String(50), default="")
+    color_name: Mapped[str] = mapped_column(String(100), default="")
+    # Lengths of the Spoolman write schema, the wider of the two inventories.
+    slicer_filament: Mapped[str | None] = mapped_column(String(128))
+    slicer_filament_name: Mapped[str | None] = mapped_column(String(255))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    presets: Mapped[list["FilamentProfileDefaultPreset"]] = relationship(
+        back_populates="profile_default", cascade="all, delete-orphan"
+    )
+    k_profiles: Mapped[list["FilamentProfileDefaultKProfile"]] = relationship(
+        back_populates="profile_default", cascade="all, delete-orphan"
+    )
+
+
+class FilamentProfileDefaultPreset(Base):
+    """A default's preset for one printer model and nozzle; the same row as
+    ``SpoolFilamentPreset``."""
+
+    __tablename__ = "filament_profile_default_preset"
+
+    __table_args__ = (UniqueConstraint("profile_default_id", "printer_model", "nozzle_diameter"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    profile_default_id: Mapped[int] = mapped_column(
+        ForeignKey("filament_profile_default.id", ondelete="CASCADE"), index=True
+    )
+    printer_model: Mapped[str] = mapped_column(String(50))
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="")
+    slicer_filament: Mapped[str | None] = mapped_column(String(128))
+    slicer_filament_name: Mapped[str | None] = mapped_column(String(255))
+
+    profile_default: Mapped[FilamentProfileDefault] = relationship(back_populates="presets")
+
+
+class FilamentProfileDefaultKProfile(Base):
+    """A default's K profile for one hotend; the same row as ``SpoolKProfile``.
+
+    Unique per hotend like ``spoolman_k_profile``, so a default copies onto a
+    Spoolman spool without tripping that table's constraint. Goes with its
+    printer, as a spool's K profiles do.
+    """
+
+    __tablename__ = "filament_profile_default_k_profile"
+
+    __table_args__ = (UniqueConstraint("profile_default_id", "printer_id", "extruder", "nozzle_diameter"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    profile_default_id: Mapped[int] = mapped_column(
+        ForeignKey("filament_profile_default.id", ondelete="CASCADE"), index=True
+    )
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), index=True)
+    extruder: Mapped[int] = mapped_column(Integer, default=0)
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="0.4")
+    nozzle_type: Mapped[str | None] = mapped_column(String(50))
+    k_value: Mapped[float] = mapped_column(Float)
+    name: Mapped[str | None] = mapped_column(String(100))
+    cali_idx: Mapped[int | None] = mapped_column(Integer)
+    setting_id: Mapped[str | None] = mapped_column(String(50))
+
+    profile_default: Mapped[FilamentProfileDefault] = relationship(back_populates="k_profiles")

+ 50 - 0
backend/app/schemas/filament_profile_default.py

@@ -0,0 +1,50 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field, field_validator
+
+from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
+
+
+class FilamentProfileDefaultKey(BaseModel):
+    """The product a default is for (#1326). ``color_name`` "" = every colour."""
+
+    brand: str = Field(default="", max_length=100)
+    material: str = Field(..., min_length=1, max_length=50)
+    subtype: str = Field(default="", max_length=50)
+    color_name: str = Field(default="", max_length=100)
+
+    @field_validator("brand", "material", "subtype", "color_name", mode="before")
+    @classmethod
+    def _strip(cls, value):
+        return (value or "").strip() if value is None or isinstance(value, str) else value
+
+
+class FilamentProfileDefaultWrite(FilamentProfileDefaultKey):
+    """Everything a spool form's Printers tab holds, plus the spool's own preset."""
+
+    slicer_filament: str | None = Field(default=None, max_length=128)
+    slicer_filament_name: str | None = Field(default=None, max_length=255)
+    presets: list[SpoolFilamentPresetBase] = Field(default_factory=list)
+    k_profiles: list[SpoolKProfileBase] = Field(default_factory=list)
+
+
+class FilamentProfileDefaultResponse(FilamentProfileDefaultWrite):
+    id: int
+    updated_at: datetime
+
+
+class ResolvedProfileDefault(BaseModel):
+    """What a new spool of a product gets: the all-colours default with the
+    colour's own one laid over it."""
+
+    slicer_filament: str | None = None
+    slicer_filament_name: str | None = None
+    presets: list[SpoolFilamentPresetBase] = Field(default_factory=list)
+    k_profiles: list[SpoolKProfileBase] = Field(default_factory=list)
+    default_ids: list[int] = Field(default_factory=list)
+
+
+class ProfileDefaultApplyResult(BaseModel):
+    """How many spools ``Apply to existing spools`` filled something in on."""
+
+    updated: int

+ 402 - 0
backend/app/services/filament_profile_default.py

@@ -0,0 +1,402 @@
+"""Profile defaults per product: what a new spool starts with (#1326).
+
+A default (``models.filament_profile_default``) holds the slicer preset, the
+per-model preset overrides and the K profiles for one product, for all its
+colours (``color_name`` "") or for one. ``resolve_profile_default`` merges the
+two levels, the colour row winning per preset and per hotend, and the
+``fill_*`` helpers copy the result onto a spool -- filling only what the spool
+does not have yet, so they are safe on a create path that was handed some
+profiles already and on spools that have been in stock for months.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+
+from sqlalchemy import delete, inspect as sa_inspect, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.filament_profile_default import (
+    FilamentProfileDefault,
+    FilamentProfileDefaultKProfile,
+    FilamentProfileDefaultPreset,
+)
+from backend.app.models.spool import Spool
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
+from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+
+logger = logging.getLogger(__name__)
+
+ProductKey = tuple[str, str, str, str]  # (brand, material, subtype, color_name)
+
+K_FIELDS = ("printer_id", "extruder", "nozzle_diameter", "nozzle_type", "k_value", "name", "cali_idx", "setting_id")
+PRESET_FIELDS = ("printer_model", "nozzle_diameter", "slicer_filament", "slicer_filament_name")
+
+
+def product_key(*, brand: str | None, material: str | None, subtype: str | None, color_name: str | None) -> ProductKey:
+    """The key a spool's product fields map to: trimmed, "" for missing."""
+    return ((brand or "").strip(), (material or "").strip(), (subtype or "").strip(), (color_name or "").strip())
+
+
+def match_key(key: ProductKey) -> str:
+    """What ``FilamentProfileDefault.match_key`` stores for a product key, and
+    what every comparison of product fields here goes through."""
+    return "\x1f".join(part.lower() for part in key)
+
+
+def hotend_key(printer_id: int, extruder: int | None, nozzle_diameter: str | None) -> tuple[int, int, str]:
+    """One hotend: what a spool holds at most one K profile for. The diameter
+    falls back to "0.4" as the K-profile columns do."""
+    return (printer_id, int(extruder or 0), (nozzle_diameter or "").strip() or "0.4")
+
+
+def k_key(row: dict) -> tuple[int, int, str]:
+    return hotend_key(row["printer_id"], row.get("extruder"), row.get("nozzle_diameter"))
+
+
+def preset_key(row: dict) -> tuple[str, str]:
+    return (row.get("printer_model") or "", row.get("nozzle_diameter") or "")
+
+
+# Spool.slicer_filament / _name are narrower than the default's columns, which
+# follow the Spoolman schema. A preset id is never cut short: one that does not
+# fit is not filled in at all.
+_SPOOL_SLICER_FILAMENT_LEN = 50
+_SPOOL_SLICER_FILAMENT_NAME_LEN = 100
+
+
+@dataclass
+class ResolvedProfiles:
+    """A product's default, both levels merged."""
+
+    slicer_filament: str | None = None
+    slicer_filament_name: str | None = None
+    presets: list[dict] = field(default_factory=list)
+    k_profiles: list[dict] = field(default_factory=list)
+    # The rows it came from, all-colours first.
+    default_ids: list[int] = field(default_factory=list)
+
+    @property
+    def is_empty(self) -> bool:
+        return not (self.slicer_filament or self.presets or self.k_profiles)
+
+
+def _with_children():
+    return (
+        selectinload(FilamentProfileDefault.presets),
+        selectinload(FilamentProfileDefault.k_profiles),
+    )
+
+
+async def find_default(db: AsyncSession, key: ProductKey) -> FilamentProfileDefault | None:
+    """The default stored for exactly this key, case-insensitively."""
+    result = await db.execute(
+        select(FilamentProfileDefault)
+        .options(*_with_children())
+        .where(FilamentProfileDefault.match_key == match_key(key))
+    )
+    return result.scalars().first()
+
+
+async def resolve_profile_default(
+    db: AsyncSession,
+    *,
+    brand: str | None,
+    material: str | None,
+    subtype: str | None,
+    color_name: str | None,
+) -> ResolvedProfiles | None:
+    """The profiles a new spool of this product gets, or None when there is no default.
+
+    The all-colours row is the base; the row for the spool's colour replaces
+    its slicer preset when it has one, and each of its presets and K profiles
+    replaces the base's for the same (model, nozzle) or hotend.
+    """
+    key = product_key(brand=brand, material=material, subtype=subtype, color_name=color_name)
+    if not key[1]:
+        return None
+    rows = [await find_default(db, (*key[:3], ""))]
+    if key[3]:
+        rows.append(await find_default(db, key))
+    rows = [r for r in rows if r is not None]
+    if not rows:
+        return None
+
+    resolved = ResolvedProfiles()
+    presets: dict[tuple[str, str], dict] = {}
+    k_profiles: dict[tuple[int, int, str], dict] = {}
+    for row in rows:
+        resolved.default_ids.append(row.id)
+        if row.slicer_filament:
+            resolved.slicer_filament = row.slicer_filament
+            resolved.slicer_filament_name = row.slicer_filament_name
+        for p in row.presets:
+            preset = {name: getattr(p, name) for name in PRESET_FIELDS}
+            presets[preset_key(preset)] = preset
+        for k in row.k_profiles:
+            k_profile = {name: getattr(k, name) for name in K_FIELDS}
+            k_profiles[k_key(k_profile)] = k_profile
+    resolved.presets = list(presets.values())
+    resolved.k_profiles = list(k_profiles.values())
+    return resolved
+
+
+async def save_default(
+    db: AsyncSession,
+    key: ProductKey,
+    *,
+    slicer_filament: str | None,
+    slicer_filament_name: str | None,
+    presets: list[dict],
+    k_profiles: list[dict],
+    keep_printer_ids: set[int] | None = None,
+) -> FilamentProfileDefault:
+    """Create or replace the default for ``key``. The caller commits.
+
+    ``keep_printer_ids``: K profiles of these printers are left as they are
+    instead of being replaced -- the printers a caller limited to some of them
+    (#1727) cannot see, and so cannot have meant to clear.
+    """
+    row = await find_default(db, key)
+    if row is None:
+        brand, material, subtype, color_name = key
+        row = FilamentProfileDefault(
+            match_key=match_key(key),
+            brand=brand,
+            material=material,
+            subtype=subtype,
+            color_name=color_name,
+            presets=[],
+            k_profiles=[],
+        )
+        db.add(row)
+        await db.flush()
+    row.slicer_filament = slicer_filament or None
+    row.slicer_filament_name = (slicer_filament_name or None) if slicer_filament else None
+
+    await db.execute(
+        delete(FilamentProfileDefaultPreset).where(FilamentProfileDefaultPreset.profile_default_id == row.id)
+    )
+    k_delete = delete(FilamentProfileDefaultKProfile).where(FilamentProfileDefaultKProfile.profile_default_id == row.id)
+    if keep_printer_ids:
+        k_delete = k_delete.where(FilamentProfileDefaultKProfile.printer_id.not_in(keep_printer_ids))
+    await db.execute(k_delete)
+    # The deletes before the inserts, or the UNIQUE constraints see both.
+    await db.flush()
+    kept = {
+        hotend_key(k.printer_id, k.extruder, k.nozzle_diameter)
+        for k in row.k_profiles
+        if keep_printer_ids and k.printer_id in keep_printer_ids
+    }
+    for p in presets:
+        db.add(FilamentProfileDefaultPreset(profile_default_id=row.id, **{n: p.get(n) for n in PRESET_FIELDS}))
+    for k in k_profiles:
+        if k_key(k) in kept:
+            continue
+        db.add(FilamentProfileDefaultKProfile(profile_default_id=row.id, **{n: k.get(n) for n in K_FIELDS}))
+    await db.flush()
+    db.expire(row, ["presets", "k_profiles", "updated_at"])
+    return row
+
+
+async def load_default(db: AsyncSession, default_id: int) -> FilamentProfileDefault | None:
+    result = await db.execute(
+        select(FilamentProfileDefault)
+        .options(*_with_children())
+        .where(FilamentProfileDefault.id == default_id)
+        .execution_options(populate_existing=True)
+    )
+    return result.scalars().first()
+
+
+async def fill_internal_spool(
+    db: AsyncSession,
+    spool: Spool,
+    *,
+    include_slicer: bool = True,
+    resolved: ResolvedProfiles | None = None,
+) -> bool:
+    """Copy the product's default onto an internal spool where it has nothing.
+
+    Never replaces: a slicer preset, a preset for a (model, nozzle) or a K
+    profile for a hotend the spool already has stays. ``include_slicer=False``
+    leaves the spool's own preset alone even when empty. The caller flushes
+    or commits. Returns whether anything was added.
+    """
+    if resolved is None:
+        resolved = await resolve_profile_default(
+            db, brand=spool.brand, material=spool.material, subtype=spool.subtype, color_name=spool.color_name
+        )
+    if resolved is None or resolved.is_empty:
+        return False
+    changed = False
+    if (
+        include_slicer
+        and resolved.slicer_filament
+        and not spool.slicer_filament
+        and len(resolved.slicer_filament) <= _SPOOL_SLICER_FILAMENT_LEN
+    ):
+        spool.slicer_filament = resolved.slicer_filament
+        spool.slicer_filament_name = (resolved.slicer_filament_name or "")[:_SPOOL_SLICER_FILAMENT_NAME_LEN] or None
+        changed = True
+
+    have_presets: set[tuple[str, str]] = set(
+        (
+            await db.execute(
+                select(SpoolFilamentPreset.printer_model, SpoolFilamentPreset.nozzle_diameter).where(
+                    SpoolFilamentPreset.spool_id == spool.id
+                )
+            )
+        ).all()
+    )
+    for p in resolved.presets:
+        if preset_key(p) in have_presets:
+            continue
+        db.add(SpoolFilamentPreset(spool_id=spool.id, **p))
+        changed = True
+
+    # The RFID assign that follows an auto-create reads the spool's in-memory
+    # list, which create_spool_from_tray initialised: rows only added to the
+    # session would miss that assign's K profile.
+    loaded = "k_profiles" not in sa_inspect(spool).unloaded
+    if loaded:
+        have_k = {hotend_key(k.printer_id, k.extruder, k.nozzle_diameter) for k in spool.k_profiles}
+    else:
+        have_k = {
+            hotend_key(pid, ext, dia)
+            for pid, ext, dia in (
+                await db.execute(
+                    select(SpoolKProfile.printer_id, SpoolKProfile.extruder, SpoolKProfile.nozzle_diameter).where(
+                        SpoolKProfile.spool_id == spool.id
+                    )
+                )
+            ).all()
+        }
+    for k in resolved.k_profiles:
+        if k_key(k) in have_k:
+            continue
+        row = SpoolKProfile(spool_id=spool.id, **k)
+        if loaded:
+            spool.k_profiles.append(row)
+        else:
+            db.add(row)
+        have_k.add(k_key(k))
+        changed = True
+    return changed
+
+
+async def fill_spoolman_rows(db: AsyncSession, spoolman_spool_id: int, resolved: ResolvedProfiles) -> bool:
+    """The Bambuddy-side half of ``fill_internal_spool`` for a Spoolman spool:
+    preset overrides and K profiles, which live here keyed by the remote id.
+    The spool's own slicer preset lives in Spoolman, see the callers. The
+    caller commits."""
+    changed = False
+    have_presets = set(
+        (
+            await db.execute(
+                select(SpoolmanFilamentPreset.printer_model, SpoolmanFilamentPreset.nozzle_diameter).where(
+                    SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id
+                )
+            )
+        ).all()
+    )
+    for p in resolved.presets:
+        if preset_key(p) in have_presets:
+            continue
+        db.add(SpoolmanFilamentPreset(spoolman_spool_id=spoolman_spool_id, **p))
+        changed = True
+    have_k = {
+        hotend_key(pid, ext, dia)
+        for pid, ext, dia in (
+            await db.execute(
+                select(SpoolmanKProfile.printer_id, SpoolmanKProfile.extruder, SpoolmanKProfile.nozzle_diameter).where(
+                    SpoolmanKProfile.spoolman_spool_id == spoolman_spool_id
+                )
+            )
+        ).all()
+    }
+    for k in resolved.k_profiles:
+        if k_key(k) in have_k:
+            continue
+        db.add(SpoolmanKProfile(spoolman_spool_id=spoolman_spool_id, **k))
+        have_k.add(k_key(k))
+        changed = True
+    return changed
+
+
+async def fill_auto_added_spoolman_spool(spool: dict, client=None, *, include_slicer: bool = False) -> None:
+    """The profile default for a spool the AMS sync just added to Spoolman.
+
+    Its preset overrides and K profiles, and -- with ``include_slicer``, for a
+    tray whose tag names no preset -- the default's own preset, written to the
+    spool's extra dict through ``client``. A tag's preset is left alone, as for
+    an internal RFID spool. In a session of its own because the sync's callers
+    do not commit, and a failure is only logged: the spool is in Spoolman
+    either way.
+    """
+    import json
+
+    from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+    from backend.app.core.database import async_session
+    from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset
+
+    try:
+        mapped = _map_spoolman_spool(spool)
+        async with async_session() as db:
+            resolved = await resolve_profile_default(
+                db,
+                brand=mapped["brand"],
+                material=mapped["material"],
+                subtype=mapped["subtype"],
+                color_name=None if mapped["color_name_is_synthesized"] else mapped["color_name"],
+            )
+            if resolved is None:
+                return
+            if await fill_spoolman_rows(db, mapped["id"], resolved):
+                await db.commit()
+        if include_slicer and client is not None and resolved.slicer_filament and not mapped["slicer_filament"]:
+            extra = {"bambu_slicer_filament": json.dumps(resolved.slicer_filament)}
+            if resolved.slicer_filament_name:
+                extra["bambu_slicer_filament_name"] = json.dumps(resolved.slicer_filament_name)
+            for name in extra:
+                await client.ensure_extra_field(name)
+            await client.merge_spool_extra(mapped["id"], extra)
+            forget_spoolman_spool_own_preset(mapped["id"])
+    except Exception as exc:  # noqa: BLE001 — never fail the AMS sync over a default
+        logger.warning("Profile default for auto-added Spoolman spool %s not applied: %s", spool.get("id"), exc)
+
+
+def default_matches(row: FilamentProfileDefault, *, brand, material, subtype, color_name) -> bool:
+    """Whether a spool with these fields is covered by ``row`` (its colour, or
+    any colour for an all-colours row)."""
+    spool = match_key(product_key(brand=brand, material=material, subtype=subtype, color_name=color_name)).split("\x1f")
+    want = row.match_key.split("\x1f")
+    return spool[:3] == want[:3] and (not want[3] or spool[3] == want[3])
+
+
+async def apply_to_internal_spools(db: AsyncSession, row: FilamentProfileDefault) -> int:
+    """Fill every active spool ``row`` covers. Returns how many changed; the
+    caller commits."""
+    # Matched in Python, with the same fold as the defaults' match_key.
+    candidates = (await db.execute(select(Spool).where(Spool.archived_at.is_(None)))).scalars().all()
+    spools = [
+        s
+        for s in candidates
+        if default_matches(row, brand=s.brand, material=s.material, subtype=s.subtype, color_name=s.color_name)
+    ]
+    changed = 0
+    cache: dict[str, ResolvedProfiles | None] = {}
+    for spool in spools:
+        # Each spool resolves on its own colour: applying the all-colours row
+        # to a colour that has a row of its own still gives that colour its values.
+        colour = (spool.color_name or "").strip().lower()
+        if colour not in cache:
+            cache[colour] = await resolve_profile_default(
+                db, brand=row.brand, material=row.material, subtype=row.subtype, color_name=spool.color_name
+            )
+        if await fill_internal_spool(db, spool, resolved=cache[colour]):
+            changed += 1
+    return changed

+ 7 - 0
backend/app/services/spool_tag_matcher.py

@@ -255,6 +255,13 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
 
 
     await apply_supplier_inheritance(db, spool)
     await apply_supplier_inheritance(db, spool)
 
 
+    # The product's profile default (#1326): its K profiles and per-model
+    # presets. The tag's own preset stays -- it names what is on the roll.
+    from backend.app.services.filament_profile_default import fill_internal_spool
+
+    await fill_internal_spool(db, spool)
+    await db.flush()
+
     logger.info(
     logger.info(
         "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
         "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
         spool.id,
         spool.id,

+ 4 - 0
backend/app/services/spoolman.py

@@ -1449,6 +1449,10 @@ class SpoolmanClient:
                 initial_weight=tray.tray_weight if tray.tray_weight > 0 else None,
                 initial_weight=tray.tray_weight if tray.tray_weight > 0 else None,
             )
             )
             await self.add_native_tags(created, [tray.tray_uuid, chip_uid], tag_format)
             await self.add_native_tags(created, [tray.tray_uuid, chip_uid], tag_format)
+            # The product's profile default (#1326).
+            from backend.app.services.filament_profile_default import fill_auto_added_spoolman_spool
+
+            await fill_auto_added_spoolman_spool(created, self, include_slicer=not tray.tray_info_idx)
             return created
             return created
 
 
         # No-RFID fallback: use the spool ID resolved from the local slot-assignment table.
         # No-RFID fallback: use the spool ID resolved from the local slot-assignment table.

+ 596 - 0
backend/tests/integration/test_filament_profile_defaults.py

@@ -0,0 +1,596 @@
+"""Profile defaults per product (#1326).
+
+A default holds the slicer preset, the per-model preset overrides and the K
+profiles for one Brand/Material/Subtype, for all colours or for one, and every
+way a spool is added copies it onto the new spool. Both inventory modes; the
+default itself is shared.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+DEFAULTS = "/api/v1/inventory/profile-defaults"
+INTERNAL = "/api/v1/inventory/spools"
+SPOOLMAN = "/api/v1/spoolman/inventory"
+
+
+def _k(printer_id, *, extruder=0, diameter="0.4", cali_idx=5, k_value=0.02, name="PLA HF"):
+    return {
+        "printer_id": printer_id,
+        "extruder": extruder,
+        "nozzle_diameter": diameter,
+        "nozzle_type": None,
+        "k_value": k_value,
+        "name": name,
+        "cali_idx": cali_idx,
+        "setting_id": None,
+    }
+
+
+def _preset(model="H2D", diameter="0.4", code="PFUS01", name="My PLA @BBL H2D"):
+    return {"printer_model": model, "nozzle_diameter": diameter, "slicer_filament": code, "slicer_filament_name": name}
+
+
+def _default(color_name="", **overrides):
+    body = {
+        "brand": "Bambu Lab",
+        "material": "PLA",
+        "subtype": "Basic",
+        "color_name": color_name,
+        "slicer_filament": "PFUS00",
+        "slicer_filament_name": "My PLA",
+        "presets": [],
+        "k_profiles": [],
+    }
+    body.update(overrides)
+    return body
+
+
+def _spool_body(**overrides):
+    body = {"material": "PLA", "subtype": "Basic", "brand": "Bambu Lab", "color_name": "Jade White"}
+    body.update(overrides)
+    return body
+
+
+@pytest.fixture
+async def printers(printer_factory):
+    return [await printer_factory(model="H2D"), await printer_factory(model="X1C")]
+
+
+@pytest.mark.integration
+class TestDefaultsCrud:
+    @pytest.mark.asyncio
+    async def test_put_creates_then_replaces(self, async_client: AsyncClient, printers):
+        a, b = printers
+        response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
+        assert response.status_code == 200, response.text
+        first = response.json()
+
+        response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(b.id)]))
+        assert response.status_code == 200, response.text
+        second = response.json()
+
+        assert second["id"] == first["id"]
+        assert [k["printer_id"] for k in second["k_profiles"]] == [b.id]
+        assert second["presets"] == []
+        listed = (await async_client.get(DEFAULTS)).json()
+        assert len(listed) == 1
+
+    @pytest.mark.asyncio
+    async def test_product_matches_case_insensitively(self, async_client: AsyncClient):
+        await async_client.put(DEFAULTS, json=_default())
+        await async_client.put(DEFAULTS, json=_default(brand="bambu lab", material="pla", subtype="basic"))
+        assert len((await async_client.get(DEFAULTS)).json()) == 1
+
+    @pytest.mark.asyncio
+    async def test_duplicate_hotend_is_refused_before_anything_changes(self, async_client: AsyncClient, printers):
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
+        response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(a.id, cali_idx=7)]))
+        assert response.status_code == 422
+        kept = (await async_client.get(DEFAULTS)).json()[0]
+        assert [k["cali_idx"] for k in kept["k_profiles"]] == [5]
+
+    @pytest.mark.asyncio
+    async def test_unknown_printer_is_refused(self, async_client: AsyncClient):
+        response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(999)]))
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_delete(self, async_client: AsyncClient):
+        row = (await async_client.put(DEFAULTS, json=_default())).json()
+        assert (await async_client.delete(f"{DEFAULTS}/{row['id']}")).status_code == 200
+        assert (await async_client.get(DEFAULTS)).json() == []
+        assert (await async_client.delete(f"{DEFAULTS}/{row['id']}")).status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_deleting_a_printer_takes_its_k_profiles_with_it(self, async_client: AsyncClient, printers):
+        a, b = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
+        assert (await async_client.delete(f"/api/v1/printers/{a.id}")).status_code == 200
+        kept = (await async_client.get(DEFAULTS)).json()[0]
+        assert [k["printer_id"] for k in kept["k_profiles"]] == [b.id]
+
+
+@pytest.mark.integration
+class TestResolve:
+    @pytest.mark.asyncio
+    async def test_no_default(self, async_client: AsyncClient):
+        response = await async_client.get(f"{DEFAULTS}/resolve", params={"material": "PLA"})
+        assert response.status_code == 200
+        assert response.json() is None
+
+    @pytest.mark.asyncio
+    async def test_all_colours_default_covers_every_colour(self, async_client: AsyncClient, printers):
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
+        resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body(color_name="Never Seen"))).json()
+        assert resolved["slicer_filament"] == "PFUS00"
+        assert [k["cali_idx"] for k in resolved["k_profiles"]] == [5]
+
+    @pytest.mark.asyncio
+    async def test_colour_wins_per_hotend_and_keeps_the_rest(self, async_client: AsyncClient, printers):
+        a, b = printers
+        await async_client.put(
+            DEFAULTS,
+            json=_default(
+                k_profiles=[_k(a.id, extruder=0), _k(a.id, extruder=1), _k(b.id)],
+                presets=[_preset("H2D"), _preset("X1C", code="PFUS02")],
+            ),
+        )
+        await async_client.put(
+            DEFAULTS,
+            json=_default(
+                "Jade White",
+                slicer_filament=None,
+                k_profiles=[_k(a.id, extruder=1, cali_idx=9, k_value=0.031)],
+                presets=[_preset("X1C", code="PFUS09")],
+            ),
+        )
+        resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body())).json()
+        by_hotend = {(k["printer_id"], k["extruder"]): k["cali_idx"] for k in resolved["k_profiles"]}
+        assert by_hotend == {(a.id, 0): 5, (a.id, 1): 9, (b.id, 0): 5}
+        by_model = {p["printer_model"]: p["slicer_filament"] for p in resolved["presets"]}
+        assert by_model == {"H2D": "PFUS01", "X1C": "PFUS09"}
+        # The colour row sets no preset of its own, so the product's stays.
+        assert resolved["slicer_filament"] == "PFUS00"
+
+    @pytest.mark.asyncio
+    async def test_other_products_are_not_matched(self, async_client: AsyncClient):
+        await async_client.put(DEFAULTS, json=_default())
+        for params in (_spool_body(subtype="Matte"), _spool_body(brand="Polymaker"), _spool_body(material="PETG")):
+            assert (await async_client.get(f"{DEFAULTS}/resolve", params=params)).json() is None
+
+
+@pytest.mark.integration
+class TestInternalCreate:
+    @pytest.mark.asyncio
+    async def test_new_spool_gets_the_default(self, async_client: AsyncClient, printers):
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
+
+        spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        assert spool["slicer_filament"] == "PFUS00"
+        assert spool["slicer_filament_name"] == "My PLA"
+        assert [(k["printer_id"], k["cali_idx"]) for k in spool["k_profiles"]] == [(a.id, 5)]
+        presets = (await async_client.get(f"{INTERNAL}/{spool['id']}/filament-presets")).json()
+        assert [p["slicer_filament"] for p in presets] == ["PFUS01"]
+
+    @pytest.mark.asyncio
+    async def test_a_preset_in_the_request_is_kept(self, async_client: AsyncClient):
+        await async_client.put(DEFAULTS, json=_default())
+        spool = (
+            await async_client.post(
+                INTERNAL, json=_spool_body(slicer_filament="GFA00", slicer_filament_name="Bambu PLA Basic")
+            )
+        ).json()
+        assert spool["slicer_filament"] == "GFA00"
+
+    @pytest.mark.asyncio
+    async def test_bulk_create_gives_every_copy_the_default(self, async_client: AsyncClient, printers):
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
+        response = await async_client.post(f"{INTERNAL}/bulk", json={"spool": _spool_body(), "quantity": 3})
+        assert response.status_code == 200, response.text
+        spools = response.json()
+        assert len(spools) == 3
+        assert all([k["cali_idx"] for k in s["k_profiles"]] == [5] for s in spools)
+        assert all(s["slicer_filament"] == "PFUS00" for s in spools)
+
+    @pytest.mark.asyncio
+    async def test_without_a_default_nothing_is_added(self, async_client: AsyncClient):
+        spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        assert spool["slicer_filament"] is None
+        assert spool["k_profiles"] == []
+
+    @pytest.mark.asyncio
+    async def test_rfid_auto_create_keeps_the_tag_preset_and_assigns_the_default_k(self, db_session, printers):
+        from backend.app.models.filament_profile_default import (
+            FilamentProfileDefault,
+            FilamentProfileDefaultKProfile,
+        )
+        from backend.app.services.spool_tag_matcher import create_spool_from_tray
+
+        a, _ = printers
+        row = FilamentProfileDefault(
+            match_key="bambu lab\x1fpla\x1fbasic\x1f",
+            brand="Bambu Lab",
+            material="PLA",
+            subtype="Basic",
+            slicer_filament="PFUS00",
+        )
+        db_session.add(row)
+        await db_session.flush()
+        db_session.add(FilamentProfileDefaultKProfile(profile_default_id=row.id, **_k(a.id)))
+        await db_session.commit()
+
+        spool = await create_spool_from_tray(
+            db_session,
+            {
+                "tray_type": "PLA",
+                "tray_sub_brands": "PLA Basic",
+                "tray_color": "FFFFFFFF",
+                "tray_info_idx": "GFA00",
+                "tray_weight": "1000",
+                "remain": 100,
+                "tag_uid": "A1B2C3D4E5F60708",
+                "tray_uuid": "0123456789ABCDEF0123456789ABCDEF",
+            },
+        )
+        assert spool.slicer_filament == "GFA00"
+        # In the in-memory list the RFID assign right after reads.
+        assert [k.cali_idx for k in spool.k_profiles] == [5]
+
+
+@pytest.mark.integration
+class TestInternalApply:
+    @pytest.mark.asyncio
+    async def test_fills_only_what_is_missing(self, async_client: AsyncClient, printers):
+        a, b = printers
+        # Spools first, so the create path has no default to apply.
+        bare = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        own = (await async_client.post(INTERNAL, json=_spool_body(slicer_filament="GFA00"))).json()
+        await async_client.put(f"{INTERNAL}/{own['id']}/k-profiles", json=[_k(a.id, cali_idx=11, k_value=0.05)])
+        other = (await async_client.post(INTERNAL, json=_spool_body(subtype="Matte"))).json()
+        archived = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        await async_client.post(f"{INTERNAL}/{archived['id']}/archive")
+
+        row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))).json()
+        response = await async_client.post(f"{DEFAULTS}/{row['id']}/apply")
+        assert response.status_code == 200, response.text
+        assert response.json() == {"updated": 2}
+
+        def k_of(spool):
+            return {k["printer_id"]: k["cali_idx"] for k in spool["k_profiles"]}
+
+        bare = (await async_client.get(f"{INTERNAL}/{bare['id']}")).json()
+        assert bare["slicer_filament"] == "PFUS00"
+        assert k_of(bare) == {a.id: 5, b.id: 5}
+        own = (await async_client.get(f"{INTERNAL}/{own['id']}")).json()
+        assert own["slicer_filament"] == "GFA00"
+        assert k_of(own) == {a.id: 11, b.id: 5}
+        assert (await async_client.get(f"{INTERNAL}/{other['id']}")).json()["k_profiles"] == []
+        assert (await async_client.get(f"{INTERNAL}/{archived['id']}")).json()["k_profiles"] == []
+
+        # Nothing left to fill.
+        assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 0}
+
+    @pytest.mark.asyncio
+    async def test_all_colours_apply_gives_a_colour_its_own_values(self, async_client: AsyncClient, printers):
+        a, _ = printers
+        white = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        black = (await async_client.post(INTERNAL, json=_spool_body(color_name="Black"))).json()
+        row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))).json()
+        await async_client.put(DEFAULTS, json=_default("Jade White", k_profiles=[_k(a.id, cali_idx=9)]))
+
+        assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 2}
+        white = (await async_client.get(f"{INTERNAL}/{white['id']}")).json()
+        black = (await async_client.get(f"{INTERNAL}/{black['id']}")).json()
+        assert [k["cali_idx"] for k in white["k_profiles"]] == [9]
+        assert [k["cali_idx"] for k in black["k_profiles"]] == [5]
+
+
+# ── Spoolman mode ────────────────────────────────────────────────────────────
+
+
+def _spoolman_spool(spool_id, *, extra=None, name="PLA Basic", archived=False):
+    return {
+        "id": spool_id,
+        "filament": {
+            "id": 7,
+            "name": name,
+            "material": "PLA",
+            "color_hex": "FFFFFF",
+            "weight": 1000,
+            "vendor": {"id": 3, "name": "Bambu Lab"},
+        },
+        "remaining_weight": 1000.0,
+        "used_weight": 0.0,
+        "location": None,
+        "comment": None,
+        "first_used": None,
+        "last_used": None,
+        "registered": "2024-01-01T00:00:00+00:00",
+        "archived": archived,
+        "price": None,
+        "extra": extra or {},
+    }
+
+
+@pytest.fixture
+def spoolman_client():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    ids = iter(range(100, 200))
+    client.create_spool = AsyncMock(side_effect=lambda **kwargs: _spoolman_spool(next(ids)))
+    client.find_or_create_filament = AsyncMock(return_value=7)
+    client.find_or_create_vendor = AsyncMock(return_value=3)
+    client.ensure_extra_field = AsyncMock(return_value=True)
+    client.merge_spool_extra = AsyncMock(side_effect=lambda spool_id, extra: _spoolman_spool(spool_id, extra=extra))
+    with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)):
+        yield client
+
+
+async def _spoolman_rows(db_session, spool_id):
+    from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
+    from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+
+    k = (
+        (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id)))
+        .scalars()
+        .all()
+    )
+    presets = (
+        (
+            await db_session.execute(
+                select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return [r.cali_idx for r in k], [p.slicer_filament for p in presets]
+
+
+@pytest.mark.integration
+class TestSpoolman:
+    @pytest.mark.asyncio
+    async def test_create_gets_the_default(self, async_client: AsyncClient, db_session, printers, spoolman_client):
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
+
+        response = await async_client.post(f"{SPOOLMAN}/spools", json=_spool_body())
+        assert response.status_code == 200, response.text
+        assert response.json()["slicer_filament"] == "PFUS00"
+        extra = spoolman_client.merge_spool_extra.call_args.args[1]
+        assert extra["bambu_slicer_filament"] == '"PFUS00"'
+        assert extra["bambu_color_name"] == '"Jade White"'
+        assert await _spoolman_rows(db_session, 100) == ([5], ["PFUS01"])
+
+    @pytest.mark.asyncio
+    async def test_bulk_create_writes_preset_colour_and_default_on_every_copy(
+        self, async_client: AsyncClient, db_session, printers, spoolman_client
+    ):
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
+        response = await async_client.post(f"{SPOOLMAN}/spools/bulk", json={"spool": _spool_body(), "quantity": 2})
+        assert response.status_code == 200, response.text
+        # Bulk wrote neither the preset nor the colour name before.
+        written = {c.args[0]: c.args[1] for c in spoolman_client.merge_spool_extra.call_args_list}
+        assert set(written) == {100, 101}
+        assert all(e["bambu_color_name"] == '"Jade White"' for e in written.values())
+        assert all(e["bambu_slicer_filament"] == '"PFUS00"' for e in written.values())
+        for spool_id in (100, 101):
+            assert (await _spoolman_rows(db_session, spool_id))[0] == [5]
+
+    @pytest.mark.asyncio
+    async def test_apply_fills_spoolman_spools(self, async_client: AsyncClient, db_session, printers, spoolman_client):
+        a, _ = printers
+        spoolman_client.get_all_spools = AsyncMock(
+            return_value=[
+                _spoolman_spool(1, extra={"bambu_color_name": '"Jade White"'}),
+                _spoolman_spool(2, extra={"bambu_slicer_filament": '"GFA00"'}),
+                _spoolman_spool(3, name="PLA Matte"),
+                _spoolman_spool(4, archived=True),
+            ]
+        )
+        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+
+        # Spool 2 has its own K profile for the hotend the default covers.
+        db_session.add(SpoolmanKProfile(spoolman_spool_id=2, **_k(a.id, cali_idx=11)))
+        await db_session.commit()
+        row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))).json()
+
+        response = await async_client.post(f"{SPOOLMAN}/profile-defaults/{row['id']}/apply")
+        assert response.status_code == 200, response.text
+        # Spool 2 has both its preset and its K profile: nothing to fill.
+        assert response.json() == {"updated": 1}
+        assert (await _spoolman_rows(db_session, 1))[0] == [5]
+        assert (await _spoolman_rows(db_session, 2))[0] == [11]
+        assert (await _spoolman_rows(db_session, 3))[0] == []
+        assert (await _spoolman_rows(db_session, 4))[0] == []
+        # Only the spool without a preset of its own gets the default's.
+        assert [c.args[0] for c in spoolman_client.merge_spool_extra.call_args_list] == [1]
+
+    @pytest.mark.asyncio
+    async def test_ams_auto_add_gets_k_profiles(self, async_client: AsyncClient, db_session, printers):
+        from backend.app.services.filament_profile_default import fill_auto_added_spoolman_spool
+
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)], presets=[_preset()]))
+        await fill_auto_added_spoolman_spool(_spoolman_spool(55))
+        assert await _spoolman_rows(db_session, 55) == ([5], ["PFUS01"])
+
+
+@pytest.fixture
+def limited_to(request):
+    """Run the request as a caller limited to some printers (#1727)."""
+    from backend.app.core.auth import get_printer_scope_if_auth_enabled
+    from backend.app.core.printer_scope import PrinterScope
+    from backend.app.main import app
+
+    def _limit(*printer_ids):
+        app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(frozenset(printer_ids))
+
+    yield _limit
+    app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
+
+
+@pytest.mark.integration
+class TestPrinterScope:
+    @pytest.mark.asyncio
+    async def test_a_limited_caller_sees_only_its_printers(self, async_client: AsyncClient, printers, limited_to):
+        a, b = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
+        limited_to(b.id)
+        listed = (await async_client.get(DEFAULTS)).json()[0]
+        assert [k["printer_id"] for k in listed["k_profiles"]] == [b.id]
+        resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body())).json()
+        assert [k["printer_id"] for k in resolved["k_profiles"]] == [b.id]
+
+    @pytest.mark.asyncio
+    async def test_a_limited_save_keeps_the_printers_it_cannot_see(
+        self, async_client: AsyncClient, printers, limited_to
+    ):
+        a, b = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
+        limited_to(b.id)
+        response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(b.id, cali_idx=8)]))
+        assert response.status_code == 200, response.text
+        limited_to(a.id, b.id)
+        kept = (await async_client.get(DEFAULTS)).json()[0]
+        assert {k["printer_id"]: k["cali_idx"] for k in kept["k_profiles"]} == {a.id: 5, b.id: 8}
+
+    @pytest.mark.asyncio
+    async def test_a_limited_caller_cannot_write_another_printer(self, async_client: AsyncClient, printers, limited_to):
+        # The row is ignored, not refused: a form opened on a spool carries
+        # every printer's profile, and saving it as the default must work.
+        a, b = printers
+        limited_to(b.id)
+        response = await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
+        assert response.status_code == 200, response.text
+        limited_to(a.id, b.id)
+        kept = (await async_client.get(DEFAULTS)).json()[0]
+        assert [k["printer_id"] for k in kept["k_profiles"]] == [b.id]
+
+    @pytest.mark.asyncio
+    async def test_a_limited_caller_cannot_delete_another_printers_profiles(
+        self, async_client: AsyncClient, printers, limited_to
+    ):
+        a, b = printers
+        row = (await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))).json()
+        limited_to(b.id)
+        assert (await async_client.delete(f"{DEFAULTS}/{row['id']}")).status_code == 403
+
+
+@pytest.mark.integration
+class TestInternalPresetFill:
+    @pytest.mark.asyncio
+    async def test_a_spool_keeps_its_own_preset_for_a_model(self, async_client: AsyncClient):
+        spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        await async_client.put(f"{INTERNAL}/{spool['id']}/filament-presets", json=[_preset("H2D", code="OWN01")])
+        row = (
+            await async_client.put(DEFAULTS, json=_default(presets=[_preset("H2D"), _preset("X1C", code="PFUS02")]))
+        ).json()
+        assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 1}
+        presets = (await async_client.get(f"{INTERNAL}/{spool['id']}/filament-presets")).json()
+        assert {p["printer_model"]: p["slicer_filament"] for p in presets} == {"H2D": "OWN01", "X1C": "PFUS02"}
+
+
+@pytest.mark.integration
+class TestNullKeyFields:
+    @pytest.mark.asyncio
+    async def test_null_brand_subtype_and_colour_read_as_empty(self, async_client: AsyncClient):
+        response = await async_client.put(DEFAULTS, json=_default(brand=None, subtype=None, color_name=None))
+        assert response.status_code == 200, response.text
+        assert {k: response.json()[k] for k in ("brand", "subtype", "color_name")} == {
+            "brand": "",
+            "subtype": "",
+            "color_name": "",
+        }
+
+    @pytest.mark.asyncio
+    async def test_null_material_is_refused_not_a_500(self, async_client: AsyncClient):
+        assert (await async_client.put(DEFAULTS, json=_default(material=None))).status_code == 422
+
+
+@pytest.mark.integration
+class TestReviewFixes:
+    @pytest.mark.asyncio
+    async def test_a_non_ascii_colour_matches_its_own_row(self, async_client: AsyncClient, printers):
+        # SQLite's lower() folds ASCII only.
+        a, _ = printers
+        await async_client.put(DEFAULTS, json=_default("Красный", k_profiles=[_k(a.id, cali_idx=9)]))
+        response = await async_client.put(DEFAULTS, json=_default("Красный", k_profiles=[_k(a.id, cali_idx=8)]))
+        assert response.status_code == 200, response.text
+        assert len((await async_client.get(DEFAULTS)).json()) == 1
+        resolved = (await async_client.get(f"{DEFAULTS}/resolve", params=_spool_body(color_name="Красный"))).json()
+        assert [k["cali_idx"] for k in resolved["k_profiles"]] == [8]
+
+    @pytest.mark.asyncio
+    async def test_a_limited_k_profile_save_keeps_the_other_printers(
+        self, async_client: AsyncClient, printers, limited_to
+    ):
+        a, b = printers
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id), _k(b.id)]))
+        spool = (await async_client.post(INTERNAL, json=_spool_body())).json()
+        limited_to(b.id)
+        # The limited form only ever saw printer b.
+        response = await async_client.put(f"{INTERNAL}/{spool['id']}/k-profiles", json=[_k(b.id, cali_idx=7)])
+        assert response.status_code == 200, response.text
+        limited_to(a.id, b.id)
+        spool = (await async_client.get(f"{INTERNAL}/{spool['id']}")).json()
+        assert {k["printer_id"]: k["cali_idx"] for k in spool["k_profiles"]} == {a.id: 5, b.id: 7}
+
+    @pytest.mark.asyncio
+    async def test_a_limited_spoolman_k_profile_save_keeps_the_other_printers(
+        self, async_client: AsyncClient, db_session, printers, limited_to, spoolman_client
+    ):
+        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+
+        a, b = printers
+        spoolman_client.get_spool = AsyncMock(return_value=_spoolman_spool(9))
+        db_session.add(SpoolmanKProfile(spoolman_spool_id=9, **_k(a.id)))
+        db_session.add(SpoolmanKProfile(spoolman_spool_id=9, **_k(b.id)))
+        await db_session.commit()
+        limited_to(b.id)
+        response = await async_client.put(f"{SPOOLMAN}/spools/9/k-profiles", json=[_k(b.id, cali_idx=7)])
+        assert response.status_code == 200, response.text
+        rows = (
+            (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 9)))
+            .scalars()
+            .all()
+        )
+        assert {r.printer_id: r.cali_idx for r in rows} == {a.id: 5, b.id: 7}
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("include_slicer", [True, False])
+    async def test_an_auto_added_spoolman_spool_gets_the_preset_only_without_one_of_its_own(
+        self, async_client: AsyncClient, include_slicer
+    ):
+        from backend.app.services.filament_profile_default import fill_auto_added_spoolman_spool
+
+        await async_client.put(DEFAULTS, json=_default())
+        client = MagicMock()
+        client.ensure_extra_field = AsyncMock(return_value=True)
+        client.merge_spool_extra = AsyncMock(return_value={})
+        await fill_auto_added_spoolman_spool(_spoolman_spool(56), client, include_slicer=include_slicer)
+        if include_slicer:
+            client.merge_spool_extra.assert_awaited_once()
+            assert client.merge_spool_extra.call_args.args[1]["bambu_slicer_filament"] == '"PFUS00"'
+        else:
+            client.merge_spool_extra.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_apply_folds_non_ascii_case_the_same_way_as_the_lookup(self, async_client: AsyncClient, printers):
+        a, _ = printers
+        spool = (await async_client.post(INTERNAL, json=_spool_body(color_name="красный"))).json()
+        row = (await async_client.put(DEFAULTS, json=_default("Красный", k_profiles=[_k(a.id, cali_idx=9)]))).json()
+        await async_client.put(DEFAULTS, json=_default(k_profiles=[_k(a.id)]))
+        assert (await async_client.post(f"{DEFAULTS}/{row['id']}/apply")).json() == {"updated": 1}
+        spool = (await async_client.get(f"{INTERNAL}/{spool['id']}")).json()
+        assert [k["cali_idx"] for k in spool["k_profiles"]] == [9]

+ 16 - 0
backend/tests/unit/services/test_spoolman_service.py

@@ -204,6 +204,22 @@ class TestSpoolmanClient:
             assert "remaining_weight" in call_kwargs
             assert "remaining_weight" in call_kwargs
             assert call_kwargs["remaining_weight"] == 500.0  # 50% of 1000g
             assert call_kwargs["remaining_weight"] == 500.0  # 50% of 1000g
 
 
+    @pytest.mark.asyncio
+    async def test_a_new_spool_gets_its_product_profile_default(self, client, sample_tray, mock_filament):
+        """The spool the AMS sync adds is handed to the profile default (#1326)."""
+        created = {"id": 99}
+        fill = AsyncMock()
+        with (
+            patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
+            patch.object(client, "_find_or_create_filament", AsyncMock(return_value=mock_filament)),
+            patch.object(client, "create_spool", AsyncMock(return_value=created)),
+            patch("backend.app.services.filament_profile_default.fill_auto_added_spoolman_spool", fill),
+        ):
+            assert await client.sync_ams_tray(sample_tray, "TestPrinter", _NoCatalog()) is created
+
+        # The tray names its preset (GFA00): the default's is not wanted.
+        fill.assert_awaited_once_with(created, client, include_slicer=False)
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_a_new_spool_carries_the_catalogue_colour_name(self, client, sample_tray, mock_filament):
     async def test_a_new_spool_carries_the_catalogue_colour_name(self, client, sample_tray, mock_filament):
         """Where #1357 put the colour name on this side: Spoolman has no color_name
         """Where #1357 put the colour name on this side: Spoolman has no color_name

+ 1 - 0
backend/tests/unit/test_not_null_update_schemas.py

@@ -29,6 +29,7 @@ NOT_A_ROW_UPDATE = {
     "SpoolWeightUpdate": "a Spoolman spool, not a database row",
     "SpoolWeightUpdate": "a Spoolman spool, not a database row",
     "SpoolTagLinkRequest": "a Spoolman spool, not a database row",
     "SpoolTagLinkRequest": "a Spoolman spool, not a database row",
     "SpoolmanFilamentPatch": "a Spoolman filament, not a database row",
     "SpoolmanFilamentPatch": "a Spoolman filament, not a database row",
+    "FilamentProfileDefaultWrite": "replaces a default as a whole; its validator reads a null key field as empty",
 }
 }
 
 
 
 

+ 174 - 0
backend/tests/unit/test_silk_plus_color_names.py

@@ -0,0 +1,174 @@
+"""Bambu PLA Silk+ rolls get their colour name.
+
+The AMS reports a single-colour Silk+ roll as "PLA Silk+", and the RFID lookup
+compares that with the catalogue's material. The Silk+ colours were seeded as
+"PLA Silk" (what the AMS reports for the Dual/Tri Color silk), so every Silk+
+roll arrived unnamed. The seed now files them under "PLA Silk+";
+``_migrate_relabel_bambu_silk_plus_colors`` moves an existing install's rows
+and ``_migrate_fill_rfid_color_names`` names the spools already added.
+"""
+
+import pytest
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
+from backend.app.core.database import (
+    _migrate_fill_rfid_color_names,
+    _migrate_relabel_bambu_silk_plus_colors,
+    load_all_models,
+)
+from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.spool import Spool
+from backend.app.services.color_catalog_lookup import resolve_bambu_color
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    # The engine below is SQLite also when the suite runs against PostgreSQL.
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture
+async def engine(tmp_path):
+    # Every table: run_migrations walks the whole schema.
+    metadata = load_all_models()
+    eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
+    async with eng.begin() as conn:
+        await conn.run_sync(metadata.create_all)
+    try:
+        yield eng
+    finally:
+        await eng.dispose()
+
+
+async def _migrate(engine):
+    async with engine.begin() as conn:
+        await _migrate_relabel_bambu_silk_plus_colors(conn)
+        await _migrate_fill_rfid_color_names(conn)
+
+
+def _old_silk_rows():
+    """The catalogue an install seeded before the fix."""
+    return [
+        ColorCatalogEntry(manufacturer=m, color_name=c, hex_color=h, material="PLA Silk", is_default=True)
+        for m, c, h, mat in DEFAULT_COLOR_CATALOG
+        if m == "Bambu Lab" and mat == "PLA Silk+"
+    ]
+
+
+def _rfid_spool(**kw):
+    base = {
+        "material": "PLA",
+        "subtype": "Silk+",
+        "brand": "Bambu Lab",
+        "rgba": "C8C8C8FF",
+        "label_weight": 1000,
+        "core_weight": 250,
+        "weight_used": 0.0,
+        "data_origin": "rfid_auto",
+    }
+    base.update(kw)
+    return Spool(**base)
+
+
+def test_the_seed_files_the_silk_plus_colours_under_silk_plus():
+    silk = {
+        (c, h): mat
+        for m, c, h, mat in DEFAULT_COLOR_CATALOG
+        if m == "Bambu Lab" and (c, h) in {("Silver", "#C8C8C8"), ("Titan Gray", "#5F6367")}
+    }
+    assert silk == {("Silver", "#C8C8C8"): "PLA Silk+", ("Titan Gray", "#5F6367"): "PLA Silk+"}
+    assert not any(m == "Bambu Lab" and mat == "PLA Silk" for m, _c, _h, mat in DEFAULT_COLOR_CATALOG)
+
+
+@pytest.mark.asyncio
+async def test_a_silk_plus_roll_is_named_from_the_seed(engine):
+    session = async_sessionmaker(engine, expire_on_commit=False)
+    async with session() as db:
+        db.add_all(
+            ColorCatalogEntry(manufacturer=m, color_name=c, hex_color=h, material=mat, is_default=True)
+            for m, c, h, mat in DEFAULT_COLOR_CATALOG
+        )
+        await db.commit()
+        assert (await resolve_bambu_color(db, "5F6367FF", "PLA Silk+")).name == "Titan Gray"
+        assert (await resolve_bambu_color(db, "C8C8C8FF", "PLA Silk+")).name == "Silver"
+
+
+@pytest.mark.asyncio
+async def test_an_existing_catalogue_is_relabelled_and_its_spools_named(engine):
+    session = async_sessionmaker(engine, expire_on_commit=False)
+    async with session() as db:
+        rows = _old_silk_rows()
+        # One the user renamed, and one they added: theirs, left alone.
+        next(r for r in rows if r.color_name == "Gold").color_name = "My Gold"
+        rows.append(
+            ColorCatalogEntry(
+                manufacturer="Bambu Lab", color_name="Old Silk Red", hex_color="#AA0000", material="PLA Silk"
+            )
+        )
+        db.add_all(rows)
+        db.add_all(
+            [
+                _rfid_spool(rgba="5F6367FF"),
+                _rfid_spool(),
+                _rfid_spool(color_name="Mine"),  # named by the user
+                _rfid_spool(data_origin=None),  # added by hand
+                _rfid_spool(subtype="Dual Color"),  # a line the create path rewrote
+                _rfid_spool(rgba="C8C8C800"),  # clear: named without the catalogue
+            ]
+        )
+        await db.commit()
+
+    await _migrate(engine)
+
+    async with session() as db:
+        materials = {(r.color_name, r.material) for r in (await db.execute(select(ColorCatalogEntry))).scalars().all()}
+        assert ("Silver", "PLA Silk+") in materials
+        assert ("Titan Gray", "PLA Silk+") in materials
+        assert ("My Gold", "PLA Silk") in materials
+        assert ("Old Silk Red", "PLA Silk") in materials
+        names = [s.color_name for s in (await db.execute(select(Spool).order_by(Spool.id))).scalars().all()]
+        assert names == ["Titan Gray", "Silver", "Mine", None, None, None]
+
+
+@pytest.mark.asyncio
+async def test_both_run_once(engine):
+    await _migrate(engine)
+    session = async_sessionmaker(engine, expire_on_commit=False)
+    async with session() as db:
+        db.add_all(_old_silk_rows())
+        db.add(_rfid_spool())
+        await db.commit()
+
+    await _migrate(engine)
+
+    async with session() as db:
+        assert {r.material for r in (await db.execute(select(ColorCatalogEntry))).scalars().all()} == {"PLA Silk"}
+        assert (await db.execute(select(Spool.color_name))).scalar_one() is None
+        flags = (await db.execute(text('SELECT "key" FROM settings'))).scalars().all()
+        assert {"_migrate_silk_plus_catalog_done", "_backfill_rfid_color_names_done"} <= set(flags)
+
+
+@pytest.mark.asyncio
+async def test_run_migrations_relabels_before_it_names(engine):
+    """Wired into the startup migrations, relabel first: naming against the
+    old "PLA Silk" rows would find nothing for a "Silk+" spool."""
+    from backend.app.core.database import run_migrations
+
+    session = async_sessionmaker(engine, expire_on_commit=False)
+    async with session() as db:
+        db.add_all(_old_silk_rows())
+        db.add(_rfid_spool(rgba="5F6367FF"))
+        await db.commit()
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with session() as db:
+        assert (await db.execute(select(Spool.color_name))).scalar_one() == "Titan Gray"

+ 90 - 0
frontend/src/__tests__/components/ProfileDefaultsSettings.test.tsx

@@ -0,0 +1,90 @@
+/**
+ * Settings > Filament > Profile defaults (#1326): lists the defaults, applies
+ * one to the inventory in use, deletes one after asking.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { ProfileDefaultsSettings } from '../../components/ProfileDefaultsSettings';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getProfileDefaults: vi.fn(),
+    getPrinters: vi.fn().mockResolvedValue([{ id: 3, name: 'H2D Left Bench' }]),
+    getSpoolmanSettings: vi.fn(),
+    applyProfileDefault: vi.fn().mockResolvedValue({ updated: 4 }),
+    deleteProfileDefault: vi.fn().mockResolvedValue({ status: 'deleted' }),
+  },
+}));
+
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
+});
+
+const mApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+
+const row = {
+  id: 7,
+  brand: 'Bambu Lab',
+  material: 'PLA',
+  subtype: 'Basic',
+  color_name: '',
+  slicer_filament: 'PFUS01',
+  slicer_filament_name: 'My PLA',
+  presets: [],
+  k_profiles: [{ printer_id: 3, extruder: 0, nozzle_diameter: '0.4', nozzle_type: null, k_value: 0.02, name: 'PLA HF', cali_idx: 5, setting_id: null }],
+  updated_at: '2026-10-25T10:00:00Z',
+};
+
+describe('ProfileDefaultsSettings', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mApi.getProfileDefaults.mockResolvedValue([row]);
+    mApi.getSpoolmanSettings.mockResolvedValue({ spoolman_enabled: 'false', spoolman_url: '' });
+  });
+
+  it('lists a default with its preset and K profiles', async () => {
+    render(<ProfileDefaultsSettings />);
+    expect(await screen.findByText('Bambu Lab PLA Basic')).toBeInTheDocument();
+    expect(screen.getByText('All colours')).toBeInTheDocument();
+    expect(await screen.findByText('H2D Left Bench 0.4 mm: PLA HF K=0.020')).toBeInTheDocument();
+  });
+
+  it.each([
+    ['internal', 'false', false],
+    ['Spoolman', 'true', true],
+  ])('applies to the %s inventory', async (_label, enabled, spoolmanMode) => {
+    mApi.getSpoolmanSettings.mockResolvedValue({ spoolman_enabled: enabled, spoolman_url: 'http://spoolman.test' });
+    render(<ProfileDefaultsSettings />);
+    await screen.findByText('Bambu Lab PLA Basic');
+    // Enabled once the inventory mode is known: the route depends on it.
+    const apply = screen.getByRole('button', { name: /Apply to existing spools/ });
+    await waitFor(() => expect(apply).not.toBeDisabled());
+    fireEvent.click(apply);
+    await waitFor(() => expect(mApi.applyProfileDefault).toHaveBeenCalledWith(7, spoolmanMode));
+    await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Filled in on 4 spools', 'success'));
+  });
+
+  it('cannot apply before the inventory mode is known', async () => {
+    mApi.getSpoolmanSettings.mockReturnValue(new Promise(() => {}));
+    render(<ProfileDefaultsSettings />);
+    await screen.findByText('Bambu Lab PLA Basic');
+    expect(screen.getByRole('button', { name: /Apply to existing spools/ })).toBeDisabled();
+  });
+
+  it('deletes only after confirming', async () => {
+    render(<ProfileDefaultsSettings />);
+    await screen.findByText('Bambu Lab PLA Basic');
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+    expect(mApi.deleteProfileDefault).not.toHaveBeenCalled();
+    const confirm = await screen.findAllByRole('button', { name: 'Delete' });
+    fireEvent.click(confirm[confirm.length - 1]);
+    await waitFor(() => expect(mApi.deleteProfileDefault).toHaveBeenCalledWith(7));
+  });
+});

+ 290 - 0
frontend/src/__tests__/components/SpoolFormProfileDefaults.test.tsx

@@ -0,0 +1,290 @@
+/**
+ * The spool form and the product's profile default (#1326).
+ *
+ * - a new spool's Printers tab is filled in from the default, and says so;
+ * - an untouched create does not save the tab: the backend applies the
+ *   default itself, and an empty save would clear it;
+ * - "Save as default" stores the tab for all colours unless told otherwise;
+ * - an edit never asks for the default.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import { api } from '../../api/client';
+import type { InventorySpool } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getLocations: vi.fn().mockResolvedValue([]),
+    getColorCatalog: vi.fn().mockResolvedValue([]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getBuiltinFilaments: vi.fn().mockResolvedValue([]),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    bulkCreateSpools: vi.fn().mockResolvedValue([{ id: 100, k_profiles: [] }, { id: 101, k_profiles: [] }]),
+    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    resolveProfileDefault: vi.fn(),
+    saveProfileDefault: vi.fn().mockResolvedValue({ id: 1 }),
+  },
+}));
+
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
+});
+
+const mApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+
+// The product fields a create starts from; handed in as `spool` so the test
+// does not have to type them into the comboboxes.
+const product = {
+  id: 1,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  extra_colors: null,
+  effect_type: null,
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 0,
+  slicer_filament: 'GFL99',
+  slicer_filament_name: 'Generic PLA',
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+} as InventorySpool;
+
+const resolved = {
+  slicer_filament: 'PFUS01',
+  slicer_filament_name: 'My PLA',
+  presets: [{ printer_model: 'X1C', nozzle_diameter: '0.4', slicer_filament: 'PFUS02', slicer_filament_name: 'My PLA @X1C' }],
+  k_profiles: [{ printer_id: 3, extruder: 0, nozzle_diameter: '0.4', nozzle_type: null, k_value: 0.02, name: 'PLA', cali_idx: 5, setting_id: null }],
+  default_ids: [7],
+};
+
+function renderForm(mode: 'create' | 'edit' = 'create', overrides: Partial<InventorySpool> = {}, extra = {}) {
+  return render(
+    <SpoolFormModal
+      isOpen={true}
+      onClose={vi.fn()}
+      spool={{ ...product, ...overrides }}
+      mode={mode}
+      currencySymbol="$"
+      {...extra}
+    />,
+  );
+}
+
+// One printer the user can pick a K profile on.
+const bench = [
+  {
+    printer: { id: 4, name: 'Bench', model: 'X1C', nozzle_count: 1, connected: true },
+    calibrations: [
+      { cali_idx: 6, filament_id: 'PFUS77', setting_id: '', name: 'Own PLA', k_value: 0.03, n_coef: 0, extruder_id: 0, nozzle_diameter: '0.4', nozzle_id: '' },
+    ],
+    nozzles: [{ nozzle_diameter: '0.4' }],
+  },
+];
+
+async function openPrintersTab() {
+  fireEvent.click(await screen.findByText('Printers'));
+}
+
+function submit(label = 'Add Spool') {
+  const buttons = screen.getAllByRole('button', { name: label });
+  fireEvent.click(buttons[buttons.length - 1]);
+}
+
+describe('SpoolFormModal profile defaults (#1326)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mApi.resolveProfileDefault.mockResolvedValue(resolved);
+  });
+
+  it('fills a new spool from the default and says so', async () => {
+    renderForm();
+    await waitFor(() =>
+      expect(mApi.resolveProfileDefault).toHaveBeenCalledWith({
+        brand: 'Polymaker',
+        material: 'PLA',
+        subtype: 'Basic',
+        color_name: 'Red',
+      }),
+    );
+    await openPrintersTab();
+    expect(await screen.findByText('Filled in from the profile default for Polymaker PLA Basic')).toBeInTheDocument();
+  });
+
+  it('an untouched create leaves the Printers tab to the backend', async () => {
+    renderForm();
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalled());
+    submit();
+    await waitFor(() => expect(mApi.createSpool).toHaveBeenCalledTimes(1));
+    await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Spool created', 'success'));
+    expect(mApi.saveSpoolKProfiles).not.toHaveBeenCalled();
+    expect(mApi.saveSpoolFilamentPresets).not.toHaveBeenCalled();
+    expect(mApi.saveProfileDefault).not.toHaveBeenCalled();
+  });
+
+  it('an untouched bulk add leaves every copy to the backend', async () => {
+    renderForm();
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalled());
+    const quickAddToggle = screen
+      .getAllByRole('button')
+      .find((btn) => btn.className.includes('rounded-full') && btn.closest('div')?.textContent?.includes('Quick Add'));
+    fireEvent.click(quickAddToggle!);
+    const quantity = (await screen.findByText('Quantity')).parentElement!.querySelector('input') as HTMLInputElement;
+    fireEvent.change(quantity, { target: { value: '2' } });
+    submit();
+    await waitFor(() => expect(mApi.bulkCreateSpools).toHaveBeenCalledTimes(1));
+    await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('2 spools created', 'success'));
+    expect(mApi.saveSpoolKProfiles).not.toHaveBeenCalled();
+    expect(mApi.saveSpoolFilamentPresets).not.toHaveBeenCalled();
+  });
+
+  it('saves the tab as the default for all colours by default', async () => {
+    renderForm();
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalled());
+    await openPrintersTab();
+    fireEvent.click(await screen.findByLabelText('Save as default for new Polymaker PLA Basic spools'));
+    expect(screen.getByLabelText('All colours')).toBeChecked();
+    submit();
+    await waitFor(() => expect(mApi.saveProfileDefault).toHaveBeenCalledTimes(1));
+    expect(mApi.saveProfileDefault.mock.calls[0][0]).toMatchObject({
+      brand: 'Polymaker',
+      material: 'PLA',
+      subtype: 'Basic',
+      color_name: '',
+      k_profiles: [expect.objectContaining({ printer_id: 3, cali_idx: 5 })],
+      presets: [expect.objectContaining({ printer_model: 'X1C', slicer_filament: 'PFUS02' })],
+    });
+  });
+
+  it('saves a default for one colour when asked', async () => {
+    renderForm();
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalled());
+    await openPrintersTab();
+    fireEvent.click(await screen.findByLabelText('Save as default for new Polymaker PLA Basic spools'));
+    fireEvent.click(screen.getByLabelText('Only Red'));
+    submit();
+    await waitFor(() => expect(mApi.saveProfileDefault).toHaveBeenCalledTimes(1));
+    expect(mApi.saveProfileDefault.mock.calls[0][0].color_name).toBe('Red');
+  });
+
+  it('an edit shows the spool, not the default', async () => {
+    renderForm('edit');
+    await screen.findByText('Edit Spool');
+    await openPrintersTab();
+    await screen.findByLabelText('Save as default for new Polymaker PLA Basic spools');
+    // Past the lookup's debounce.
+    await new Promise((r) => setTimeout(r, 500));
+    expect(mApi.resolveProfileDefault).not.toHaveBeenCalled();
+    expect(screen.queryByText(/Filled in from the profile default/)).not.toBeInTheDocument();
+  });
+
+  it('a tab touched before the default arrives still gets the rest of it', async () => {
+    // The first product's lookup never answers; the user picks a profile, then
+    // changes the product, whose default arrives with the tab already touched.
+    mApi.resolveProfileDefault.mockImplementation((key: { subtype: string }) =>
+      key.subtype === 'Matte' ? Promise.resolve(resolved) : new Promise(() => {}),
+    );
+    renderForm('create', { slicer_filament: 'PFUS77' }, { printersWithCalibrations: bench });
+    await openPrintersTab();
+    fireEvent.change(await screen.findByLabelText(/Bench .*0\.4mm/), { target: { value: '6' } });
+    fireEvent.click(screen.getByText('Filament Info'));
+    fireEvent.focus(screen.getByPlaceholderText('Basic, Matte, Silk...'));
+    fireEvent.click(await screen.findByRole('button', { name: 'Matte' }));
+    await openPrintersTab();
+    await screen.findByText('Filled in from the profile default for Polymaker PLA Matte');
+    fireEvent.click(screen.getByLabelText('Save as default for new Polymaker PLA Matte spools'));
+    submit();
+    await waitFor(() => expect(mApi.saveProfileDefault).toHaveBeenCalledTimes(1));
+    const saved = mApi.saveProfileDefault.mock.calls[0][0].k_profiles.map((k: { printer_id: number; cali_idx: number }) => [k.printer_id, k.cali_idx]);
+    expect(saved).toEqual(expect.arrayContaining([[3, 5], [4, 6]]));
+    // The touched tab is saved, with the default's profile in it.
+    expect(mApi.saveSpoolKProfiles.mock.calls[0][1].map((k: { printer_id: number }) => k.printer_id).sort()).toEqual([3, 4]);
+  });
+
+  it('the next product takes back the preset the previous default put in', async () => {
+    mApi.resolveProfileDefault.mockImplementation(async (key: { subtype: string }) =>
+      key.subtype === 'Basic' ? resolved : { ...resolved, slicer_filament: null, slicer_filament_name: null },
+    );
+    renderForm('create', { slicer_filament: '', slicer_filament_name: '' });
+    expect(await screen.findByDisplayValue('My PLA')).toBeInTheDocument();
+    fireEvent.focus(screen.getByPlaceholderText('Basic, Matte, Silk...'));
+    fireEvent.click(await screen.findByRole('button', { name: 'Matte' }));
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalledWith(expect.objectContaining({ subtype: 'Matte' })));
+    await waitFor(() => expect(screen.queryByDisplayValue('My PLA')).not.toBeInTheDocument());
+  });
+
+  it('a colour-only default falls back to all colours when the colour is cleared', async () => {
+    renderForm();
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalled());
+    await openPrintersTab();
+    fireEvent.click(await screen.findByLabelText('Save as default for new Polymaker PLA Basic spools'));
+    fireEvent.click(screen.getByLabelText('Only Red'));
+    fireEvent.click(screen.getByText('Color & Cost'));
+    fireEvent.change(screen.getByDisplayValue('Red'), { target: { value: '' } });
+    await openPrintersTab();
+    expect(screen.getByLabelText('All colours')).toBeChecked();
+    expect(screen.getByLabelText('Only this colour: this spool has no colour name. Add one on Color & Cost to save a default for this colour alone.')).toBeDisabled();
+  });
+
+  it('a hotend the user cleared stays cleared when the product changes', async () => {
+    const withBench = { ...resolved, k_profiles: [{ ...resolved.k_profiles[0], printer_id: 4, cali_idx: 6 }] };
+    mApi.resolveProfileDefault.mockResolvedValue(withBench);
+    renderForm('create', { slicer_filament: 'PFUS77' }, { printersWithCalibrations: bench });
+    await openPrintersTab();
+    const select = (await screen.findByLabelText(/Bench .*0\.4mm/)) as HTMLSelectElement;
+    await waitFor(() => expect(select.value).toBe('6'));
+    fireEvent.change(select, { target: { value: '' } });
+    fireEvent.click(screen.getByText('Filament Info'));
+    fireEvent.focus(screen.getByPlaceholderText('Basic, Matte, Silk...'));
+    fireEvent.click(await screen.findByRole('button', { name: 'Matte' }));
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalledWith(expect.objectContaining({ subtype: 'Matte' })));
+    await openPrintersTab();
+    await screen.findByText('Filled in from the profile default for Polymaker PLA Matte');
+    expect(((await screen.findByLabelText(/Bench .*0\.4mm/)) as HTMLSelectElement).value).toBe('');
+  });
+
+  it('a preset picked while the lookup is running is kept', async () => {
+    let answer: (value: typeof resolved) => void = () => {};
+    mApi.resolveProfileDefault.mockReturnValue(new Promise((r) => { answer = r; }));
+    renderForm('create', { slicer_filament: '', slicer_filament_name: '' });
+    await waitFor(() => expect(mApi.resolveProfileDefault).toHaveBeenCalled());
+    fireEvent.focus(screen.getByPlaceholderText('Search filament presets...'));
+    fireEvent.click(await screen.findByRole('button', { name: /Generic PLA/ }));
+    expect(screen.getByDisplayValue('Generic PLA')).toBeInTheDocument();
+    answer(resolved);
+    await new Promise((r) => setTimeout(r, 50));
+    expect(screen.getByDisplayValue('Generic PLA')).toBeInTheDocument();
+    expect(screen.queryByDisplayValue('My PLA')).not.toBeInTheDocument();
+  });
+});

+ 39 - 0
frontend/src/__tests__/components/withProductDefaultKProfiles.test.ts

@@ -0,0 +1,39 @@
+/**
+ * withProductDefaultKProfiles (#1326): a page that saves a new spool's K
+ * profiles without showing the product's default keeps the default for every
+ * hotend nothing was picked for.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { api } from '../../api/client';
+import { withProductDefaultKProfiles } from '../../components/spool-form/utils';
+
+vi.mock('../../api/client', () => ({ api: { resolveProfileDefault: vi.fn() } }));
+
+const mApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+const product = { brand: 'Bambu Lab ', material: 'PETG', subtype: 'HF', color_name: '' };
+const k = (printer_id: number, cali_idx: number, extruder = 0) => ({
+  printer_id, extruder, nozzle_diameter: '0.4', k_value: 0.02, name: null, cali_idx, setting_id: null,
+});
+
+describe('withProductDefaultKProfiles', () => {
+  beforeEach(() => vi.clearAllMocks());
+
+  it('adds the default for the hotends nothing was picked for', async () => {
+    mApi.resolveProfileDefault.mockResolvedValue({ k_profiles: [k(1, 5), k(1, 6, 1), k(3, 7)] });
+    const merged = await withProductDefaultKProfiles([k(3, 9)], product);
+    expect(merged.map((p) => [p.printer_id, p.extruder, p.cali_idx])).toEqual([
+      [3, 0, 9],
+      [1, 0, 5],
+      [1, 1, 6],
+    ]);
+    expect(mApi.resolveProfileDefault).toHaveBeenCalledWith({ brand: 'Bambu Lab', material: 'PETG', subtype: 'HF', color_name: '' });
+  });
+
+  it('without a default or a lookup, the picks alone', async () => {
+    mApi.resolveProfileDefault.mockResolvedValue(null);
+    expect(await withProductDefaultKProfiles([k(3, 9)], product)).toEqual([k(3, 9)]);
+    mApi.resolveProfileDefault.mockRejectedValue(new Error('offline'));
+    expect(await withProductDefaultKProfiles([k(3, 9)], product)).toEqual([k(3, 9)]);
+  });
+});

+ 15 - 8
frontend/src/__tests__/pages/SpoolBuddyWriteTagPageWiring.test.tsx

@@ -170,9 +170,11 @@ describe.each([
     expect(mSb.writeTag).not.toHaveBeenCalled();
     expect(mSb.writeTag).not.toHaveBeenCalled();
   });
   });
 
 
-  it('Create with quantity 3 uses the bulk endpoint with N=3 and saves K-profiles for every created spool', async () => {
+  it('Create with quantity 3 uses the bulk endpoint with N=3 and leaves the created K-profiles alone', async () => {
     renderPage();
     renderPage();
     await openQuickAddFormWithQuantity(3);
     await openQuickAddFormWithQuantity(3);
+    const listFetch = isSpoolman ? mApi.getSpoolmanInventorySpools : mApi.getSpools;
+    const fetchesBefore = listFetch.mock.calls.length;
     fireEvent.click(screen.getAllByText('Create Spool').at(-1)!);
     fireEvent.click(screen.getAllByText('Create Spool').at(-1)!);
 
 
     const bulk = isSpoolman ? mApi.bulkCreateSpoolmanInventorySpools : mApi.bulkCreateSpools;
     const bulk = isSpoolman ? mApi.bulkCreateSpoolmanInventorySpools : mApi.bulkCreateSpools;
@@ -188,24 +190,29 @@ describe.each([
     expect(mApi.createSpool).not.toHaveBeenCalled();
     expect(mApi.createSpool).not.toHaveBeenCalled();
     expect(mApi.createSpoolmanInventorySpool).not.toHaveBeenCalled();
     expect(mApi.createSpoolmanInventorySpool).not.toHaveBeenCalled();
 
 
-    const ids = isSpoolman ? [601, 602, 603] : [501, 502, 503];
-    await waitFor(() => expect(saveK).toHaveBeenCalledTimes(3));
-    expect(saveK.mock.calls.map((c) => c[0])).toEqual(ids);
-    // No PA profile picked -> each spool's K-profiles are saved as empty.
-    for (const c of saveK.mock.calls) expect(c[1]).toEqual([]);
+    // No PA profile picked -> nothing is saved: an empty save would clear the
+    // product's profile default the create gave each spool (#1326).
+    // The list refetch comes after the K-profile save would have.
+    await waitFor(() => expect(listFetch.mock.calls.length).toBeGreaterThan(fetchesBefore));
+    expect(saveK).not.toHaveBeenCalled();
     expect(otherSaveK).not.toHaveBeenCalled();
     expect(otherSaveK).not.toHaveBeenCalled();
   });
   });
 
 
   it('Create with quantity 1 uses the single-create endpoint', async () => {
   it('Create with quantity 1 uses the single-create endpoint', async () => {
     renderPage();
     renderPage();
     await openQuickAddFormWithQuantity(1);
     await openQuickAddFormWithQuantity(1);
+    const listFetch = isSpoolman ? mApi.getSpoolmanInventorySpools : mApi.getSpools;
+    const fetchesBefore = listFetch.mock.calls.length;
     fireEvent.click(screen.getAllByText('Create Spool').at(-1)!);
     fireEvent.click(screen.getAllByText('Create Spool').at(-1)!);
 
 
     const single = isSpoolman ? mApi.createSpoolmanInventorySpool : mApi.createSpool;
     const single = isSpoolman ? mApi.createSpoolmanInventorySpool : mApi.createSpool;
     await waitFor(() => expect(single).toHaveBeenCalledTimes(1));
     await waitFor(() => expect(single).toHaveBeenCalledTimes(1));
     expect(mApi.bulkCreateSpools).not.toHaveBeenCalled();
     expect(mApi.bulkCreateSpools).not.toHaveBeenCalled();
     expect(mApi.bulkCreateSpoolmanInventorySpools).not.toHaveBeenCalled();
     expect(mApi.bulkCreateSpoolmanInventorySpools).not.toHaveBeenCalled();
-    const saveK = isSpoolman ? mApi.saveSpoolmanKProfiles : mApi.saveSpoolKProfiles;
-    await waitFor(() => expect(saveK).toHaveBeenCalledWith(isSpoolman ? 800 : 700, []));
+    // No PA profile picked: the spool keeps the profile default the create gave it (#1326).
+    // The list refetch comes after the K-profile save would have.
+    await waitFor(() => expect(listFetch.mock.calls.length).toBeGreaterThan(fetchesBefore));
+    expect(mApi.saveSpoolKProfiles).not.toHaveBeenCalled();
+    expect(mApi.saveSpoolmanKProfiles).not.toHaveBeenCalled();
   });
   });
 });
 });

+ 59 - 0
frontend/src/api/client.ts

@@ -4127,6 +4127,40 @@ export interface SpoolFilamentPreset extends SpoolFilamentPresetInput {
   created_at: string;
   created_at: string;
 }
 }
 
 
+/**
+ * What a new spool of one product starts with (#1326): the slicer preset, the
+ * per-model preset overrides and the K profiles. `color_name` "" is the
+ * default for every colour of the product; one with a colour wins over it per
+ * preset and per hotend. Shared by both inventory modes.
+ */
+export interface ProfileDefaultKey {
+  brand: string;
+  material: string;
+  subtype: string;
+  color_name: string;
+}
+
+export interface ProfileDefaultInput extends ProfileDefaultKey {
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+  presets: SpoolFilamentPresetInput[];
+  k_profiles: SpoolKProfileInput[];
+}
+
+export interface ProfileDefault extends ProfileDefaultInput {
+  id: number;
+  updated_at: string;
+}
+
+/** Both levels of a product's default merged, as a create applies them. */
+export interface ResolvedProfileDefault {
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+  presets: SpoolFilamentPresetInput[];
+  k_profiles: SpoolKProfileInput[];
+  default_ids: number[];
+}
+
 /** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */
 /** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */
 export interface SlotMaterial {
 export interface SlotMaterial {
   ams_id: number;
   ams_id: number;
@@ -7121,6 +7155,31 @@ export const api = {
       method: 'PUT',
       method: 'PUT',
       body: JSON.stringify(presets),
       body: JSON.stringify(presets),
     }),
     }),
+  getProfileDefaults: () => request<ProfileDefault[]>('/inventory/profile-defaults'),
+  resolveProfileDefault: (key: ProfileDefaultKey) => {
+    const params = new URLSearchParams({
+      brand: key.brand,
+      material: key.material,
+      subtype: key.subtype,
+      color_name: key.color_name,
+    });
+    return request<ResolvedProfileDefault | null>(`/inventory/profile-defaults/resolve?${params}`);
+  },
+  saveProfileDefault: (data: ProfileDefaultInput) =>
+    request<ProfileDefault>('/inventory/profile-defaults', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteProfileDefault: (defaultId: number) =>
+    request<{ status: string }>(`/inventory/profile-defaults/${defaultId}`, { method: 'DELETE' }),
+  /** Fills in what active spools of the default's product lack; the inventory mode picks the route. */
+  applyProfileDefault: (defaultId: number, spoolmanMode: boolean) =>
+    request<{ updated: number }>(
+      spoolmanMode
+        ? `/spoolman/inventory/profile-defaults/${defaultId}/apply`
+        : `/inventory/profile-defaults/${defaultId}/apply`,
+      { method: 'POST' },
+    ),
   getAssignments: (printerId?: number) =>
   getAssignments: (printerId?: number) =>
     request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
     request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
   assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
   assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>

+ 158 - 0
frontend/src/components/ProfileDefaultsSettings.tsx

@@ -0,0 +1,158 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Loader2, Sparkles, Trash2, Wand2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { ProfileDefault } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { Button } from './Button';
+import { Card, CardContent, CardHeader } from './Card';
+import { ConfirmModal } from './ConfirmModal';
+
+/**
+ * Settings > Filament > Profile defaults (#1326): what new spools of each
+ * product start with. A default is saved from a spool's Printers tab ("Save as
+ * default"), which is also how one is changed: this card lists them, applies
+ * one to the spools already in stock, and deletes one.
+ */
+export function ProfileDefaultsSettings() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [deleting, setDeleting] = useState<ProfileDefault | null>(null);
+
+  const { data: defaults = [], isLoading } = useQuery({
+    queryKey: ['profile-defaults'],
+    queryFn: api.getProfileDefaults,
+  });
+  const { data: printers = [] } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters });
+  // Applying writes to the spools of the inventory in use.
+  const { data: spoolmanSettings } = useQuery({
+    queryKey: ['spoolman-settings'],
+    queryFn: api.getSpoolmanSettings,
+    staleTime: 5 * 60 * 1000,
+  });
+  const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
+
+  const printerName = (id: number) => printers.find((p) => p.id === id)?.name ?? `#${id}`;
+
+  const applyMutation = useMutation({
+    mutationFn: (row: ProfileDefault) => api.applyProfileDefault(row.id, spoolmanMode),
+    onSuccess: async ({ updated }) => {
+      showToast(t('inventory.profileDefaults.applied', { count: updated }), 'success');
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      await queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
+    },
+    onError: () => showToast(t('inventory.profileDefaults.applyFailed'), 'error'),
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (row: ProfileDefault) => api.deleteProfileDefault(row.id),
+    onSuccess: async () => {
+      setDeleting(null);
+      await queryClient.invalidateQueries({ queryKey: ['profile-defaults'] });
+    },
+    onError: () => {
+      setDeleting(null);
+      showToast(t('inventory.profileDefaults.deleteFailed'), 'error');
+    },
+  });
+
+  const productOf = (row: ProfileDefault) =>
+    [row.brand, row.material, row.subtype].filter((part) => part.trim()).join(' ');
+
+  return (
+    <Card id="card-profiledefaults">
+      <CardHeader>
+        <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+          <Sparkles className="w-5 h-5 text-bambu-green" />
+          {t('inventory.profileDefaults.title')}
+        </h2>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        <p className="text-sm text-bambu-gray">{t('inventory.profileDefaults.description')}</p>
+        {isLoading ? (
+          <div className="flex justify-center py-4">
+            <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
+          </div>
+        ) : defaults.length === 0 ? (
+          <p className="text-sm text-bambu-gray italic">{t('inventory.profileDefaults.empty')}</p>
+        ) : (
+          <ul className="space-y-2">
+            {defaults.map((row) => (
+              <li key={row.id} className="p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
+                <div className="flex flex-wrap items-start gap-2">
+                  <div className="min-w-0 flex-1">
+                    <p className="text-sm text-white">
+                      {productOf(row)}
+                      <span className="ml-2 text-xs text-bambu-gray">
+                        {row.color_name || t('inventory.profileDefaults.allColours')}
+                      </span>
+                    </p>
+                    <ul className="mt-1 space-y-0.5 text-xs text-bambu-gray">
+                      {row.slicer_filament && (
+                        <li>
+                          {t('inventory.filamentPreset')}: {row.slicer_filament_name || row.slicer_filament}
+                        </li>
+                      )}
+                      {row.presets.map((p) => (
+                        <li key={`${p.printer_model}:${p.nozzle_diameter}`}>
+                          {p.printer_model}
+                          {p.nozzle_diameter ? ` ${p.nozzle_diameter} mm` : ''}: {p.slicer_filament_name || p.slicer_filament}
+                        </li>
+                      ))}
+                      {row.k_profiles.map((k) => (
+                        <li key={`${k.printer_id}:${k.extruder}:${k.nozzle_diameter}`}>
+                          {`${printerName(k.printer_id)} ${k.nozzle_diameter ?? '0.4'} mm: ${k.name || '-'} K=${k.k_value.toFixed(3)}`}
+                          {(k.extruder ?? 0) > 0 ? ` (${t('inventory.leftNozzle')})` : ''}
+                        </li>
+                      ))}
+                    </ul>
+                  </div>
+                  <div className="flex gap-2 shrink-0">
+                    <Button
+                      size="sm"
+                      variant="secondary"
+                      onClick={() => applyMutation.mutate(row)}
+                      // Not before the inventory mode is known: it picks the spools written to.
+                      disabled={applyMutation.isPending || spoolmanSettings === undefined}
+                      title={t('inventory.profileDefaults.applyHelp')}
+                    >
+                      {applyMutation.isPending && applyMutation.variables?.id === row.id ? (
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      ) : (
+                        <Wand2 className="w-4 h-4" />
+                      )}
+                      {t('inventory.profileDefaults.apply')}
+                    </Button>
+                    <Button
+                      size="sm"
+                      variant="secondary"
+                      onClick={() => setDeleting(row)}
+                      aria-label={t('common.delete')}
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </Button>
+                  </div>
+                </div>
+              </li>
+            ))}
+          </ul>
+        )}
+      </CardContent>
+      {deleting && (
+        <ConfirmModal
+          title={t('inventory.profileDefaults.deleteTitle')}
+          message={t('inventory.profileDefaults.deleteMessage', {
+            product: `${productOf(deleting)} ${deleting.color_name || t('inventory.profileDefaults.allColours')}`,
+          })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate(deleting)}
+          onCancel={() => setDeleting(null)}
+        />
+      )}
+    </Card>
+  );
+}

+ 218 - 42
frontend/src/components/SpoolFormModal.tsx

@@ -1,9 +1,9 @@
-import { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Link2, Unlink } from 'lucide-react';
 import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Link2, Unlink } from 'lucide-react';
 import { api, ApiError } from '../api/client';
 import { api, ApiError } from '../api/client';
-import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry, ResolvedProfileDefault } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import type {
 import type {
@@ -15,7 +15,7 @@ import type {
   SpoolFormMode,
   SpoolFormMode,
 } from './spool-form/types';
 } from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, selectionFromKProfiles, withCurrentValue } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { ColorSection } from './spool-form/ColorSection';
@@ -23,6 +23,7 @@ import { AdditionalSection } from './spool-form/AdditionalSection';
 import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
 import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
+import { ProfileDefaultOption, type ProfileDefaultScope } from './spool-form/ProfileDefaultOption';
 import { normaliseFlow } from '../utils/nozzleFlow';
 import { normaliseFlow } from '../utils/nozzleFlow';
 import { parseUTCDate, toDateTimeLocalValue } from '../utils/date';
 import { parseUTCDate, toDateTimeLocalValue } from '../utils/date';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
@@ -76,6 +77,8 @@ export function SpoolFormModal({
 
 
   // Form state
   // Form state
   const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
   const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
+  const formDataRef = useRef(formData);
+  formDataRef.current = formData;
   const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
   const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
   const [activeTab, setActiveTab] = useState<TabId>('filament');
   const [activeTab, setActiveTab] = useState<TabId>('filament');
   const [weightTouched, setWeightTouched] = useState(false);
   const [weightTouched, setWeightTouched] = useState(false);
@@ -153,6 +156,48 @@ export function SpoolFormModal({
   // backend cascade does with a missing row.
   // backend cascade does with a missing row.
   const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
   const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
   const [selectedGroupId, setSelectedGroupId] = useState<string>('');
   const [selectedGroupId, setSelectedGroupId] = useState<string>('');
+  // Profile defaults (#1326). Whether the user changed anything on the
+  // Printers tab: an untouched create does not save the tab, so it keeps the
+  // profile default the backend applies instead of wiping it with an empty
+  // list -- the same rule as the supplier assignments.
+  const [printerProfilesTouched, setPrinterProfilesTouched] = useState(false);
+  // The tab (and an empty preset field) was filled in from the product's default.
+  const [prefilledFromDefault, setPrefilledFromDefault] = useState(false);
+  // What the default put in, per hotend and per (model, nozzle), so the next
+  // product's default can take back what the user left as it was.
+  const kFromDefault = useRef<Map<string, CalibrationProfile>>(new Map());
+  const presetsFromDefault = useRef<Map<string, PresetChoice>>(new Map());
+  // Hotends and (model, nozzle) keys the user set or cleared on the tab: theirs,
+  // so a later default never puts back what they removed.
+  const userKKeys = useRef<Set<string>>(new Set());
+  const userPresetKeys = useRef<Set<string>>(new Set());
+  // A ref, not state: the lookup's answer arrives after the render that asked
+  // for it, and must see a preset the user picked in the meantime.
+  const presetFromDefault = useRef(false);
+  const [saveAsDefault, setSaveAsDefault] = useState(false);
+  const [defaultScope, setDefaultScope] = useState<ProfileDefaultScope>('all');
+  // The Printers tab's own setters mark it touched; the loaders here use the raw ones.
+  const markChanged = <V,>(prev: Map<string, V>, next: Map<string, V>, keys: Set<string>) => {
+    for (const key of new Set([...prev.keys(), ...next.keys()])) {
+      if (prev.get(key) !== next.get(key)) keys.add(key);
+    }
+  };
+  const editModelPresets: typeof setModelPresets = (value) => {
+    setPrinterProfilesTouched(true);
+    setModelPresets(prev => {
+      const next = typeof value === 'function' ? value(prev) : value;
+      markChanged(prev, next, userPresetKeys.current);
+      return next;
+    });
+  };
+  const editSelectedProfiles: typeof setSelectedProfiles = (value) => {
+    setPrinterProfilesTouched(true);
+    setSelectedProfiles(prev => {
+      const next = typeof value === 'function' ? value(prev) : value;
+      markChanged(prev, next, userKKeys.current);
+      return next;
+    });
+  };
 
 
   // Use prop if provided, otherwise use self-fetched data
   // Use prop if provided, otherwise use self-fetched data
   const resolvedCalibrations = printersWithCalibrations.length > 0
   const resolvedCalibrations = printersWithCalibrations.length > 0
@@ -443,33 +488,8 @@ export function SpoolFormModal({
         });
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
 
 
-        // Load K-profiles for this spool. The stored row carries everything
-        // the picker needs to show the selection before the printer answers,
-        // so an offline printer still renders what was chosen for it.
-        if (spool.k_profiles && spool.k_profiles.length > 0) {
-          const chosen = new Map<string, CalibrationProfile>();
-          for (const p of spool.k_profiles) {
-            if (p.cali_idx === null || p.cali_idx === undefined) continue;
-            const diameter = (p.nozzle_diameter || '').trim() || '0.4';
-            const extruder = p.extruder ?? 0;
-            chosen.set(hotendKey(p.printer_id, extruder, diameter), {
-              cali_idx: p.cali_idx,
-              filament_id: '',
-              setting_id: p.setting_id || '',
-              name: p.name || '',
-              k_value: p.k_value,
-              n_coef: 0,
-              extruder_id: extruder,
-              nozzle_diameter: diameter,
-              // Stored as the bare flow code; the picker re-derives its label
-              // from the same field it reads off a live profile.
-              nozzle_id: p.nozzle_type || '',
-            });
-          }
-          setSelectedProfiles(chosen);
-        } else {
-          setSelectedProfiles(new Map());
-        }
+        // Load K-profiles for this spool.
+        setSelectedProfiles(selectionFromKProfiles(spool.k_profiles ?? []));
         // Supplier assignments (#2988) — copied on copy: they describe where
         // Supplier assignments (#2988) — copied on copy: they describe where
         // the product is bought, which is what a copy shares.
         // the product is bought, which is what a copy shares.
         setSupplierLinks(
         setSupplierLinks(
@@ -510,6 +530,15 @@ export function SpoolFormModal({
       setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
       setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
       setLocationIdTouched(false);
       setLocationIdTouched(false);
       setSupplierLinksTouched(false);
       setSupplierLinksTouched(false);
+      setPrinterProfilesTouched(false);
+      setPrefilledFromDefault(false);
+      kFromDefault.current = new Map();
+      presetsFromDefault.current = new Map();
+      userKKeys.current = new Set();
+      userPresetKeys.current = new Set();
+      presetFromDefault.current = false;
+      setSaveAsDefault(false);
+      setDefaultScope('all');
     }
     }
   }, [isOpen, spool, mode, isCopying]);
   }, [isOpen, spool, mode, isCopying]);
 
 
@@ -548,6 +577,94 @@ export function SpoolFormModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen, spool?.id, spoolmanMode]);
   }, [isOpen, spool?.id, spoolmanMode]);
 
 
+  // A new spool starts from its product's profile default (#1326): fill the
+  // Printers tab, and an empty preset field, with what the create will apply,
+  // so the user sees it and can change it before saving. Only for a fresh
+  // create -- an edit shows the spool's own profiles and a copy the copied
+  // ones. What the user set wins: the default only fills hotends and models
+  // left empty, so a touched tab still shows (and saves) the rest of it.
+  // Follows the product as it is typed: what one product's default put in and
+  // the user left alone gives way to the next product's.
+  const isCreating = !isEditing && !isCopying;
+  useEffect(() => {
+    if (!isOpen || !isCreating) return;
+    const material = formData.material.trim();
+    let cancelled = false;
+    const apply = (resolved: ResolvedProfileDefault | null) => {
+      if (cancelled) return;
+      const kRows = selectionFromKProfiles(resolved?.k_profiles ?? []);
+      // Read before the updaters, which React may run twice.
+      const previousK = kFromDefault.current;
+      const previousPresets = presetsFromDefault.current;
+      setSelectedProfiles(prev => {
+        const next = new Map(prev);
+        for (const [key, value] of previousK) if (next.get(key) === value) next.delete(key);
+        const filled = new Map<string, CalibrationProfile>();
+        for (const [key, value] of kRows) {
+          if (next.has(key) || userKKeys.current.has(key)) continue;
+          next.set(key, value);
+          filled.set(key, value);
+        }
+        kFromDefault.current = filled;
+        return next;
+      });
+      const presetRows = new Map<string, PresetChoice>();
+      for (const row of resolved?.presets ?? []) {
+        presetRows.set(presetKey(row.printer_model, row.nozzle_diameter || ''), {
+          code: row.slicer_filament || '',
+          name: row.slicer_filament_name || '',
+        });
+      }
+      setModelPresets(prev => {
+        const next = new Map(prev);
+        for (const [key, value] of previousPresets) if (next.get(key) === value) next.delete(key);
+        const filled = new Map<string, PresetChoice>();
+        for (const [key, value] of presetRows) {
+          if (next.has(key) || userPresetKeys.current.has(key)) continue;
+          next.set(key, value);
+          filled.set(key, value);
+        }
+        presetsFromDefault.current = filled;
+        return next;
+      });
+      setPrefilledFromDefault(kRows.size > 0 || presetRows.size > 0 || !!resolved?.slicer_filament);
+      // The current preset, not the one of the render that asked.
+      if (resolved?.slicer_filament && (!formDataRef.current.slicer_filament || presetFromDefault.current)) {
+        setFormData(prev => ({ ...prev, slicer_filament: resolved.slicer_filament || '' }));
+        setPresetInputValue(resolved.slicer_filament_name || resolved.slicer_filament);
+        presetFromDefault.current = true;
+      } else if (presetFromDefault.current) {
+        // The previous product's preset; this one's default has none.
+        setFormData(prev => ({ ...prev, slicer_filament: '' }));
+        setPresetInputValue('');
+        presetFromDefault.current = false;
+      }
+    };
+    if (!material) {
+      apply(null);
+      return;
+    }
+    // Typed field by field: ask once the typing stops.
+    const timer = setTimeout(() => {
+      api
+        .resolveProfileDefault({
+          brand: formData.brand.trim(),
+          material,
+          subtype: formData.subtype.trim(),
+          color_name: formData.color_name.trim(),
+        })
+        .then(apply)
+        .catch(e => {
+          // Non-fatal: the create still applies the default on the server.
+          if (!cancelled) console.error('Failed to look up the profile default:', e);
+        });
+    }, 300);
+    return () => {
+      cancelled = true;
+      clearTimeout(timer);
+    };
+  }, [isOpen, isCreating, formData.brand, formData.material, formData.subtype, formData.color_name]);
+
   // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
   // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
   useEffect(() => {
   useEffect(() => {
     if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
     if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
@@ -577,6 +694,8 @@ export function SpoolFormModal({
     if (key === 'core_weight') setCoreWeightTouched(true);
     if (key === 'core_weight') setCoreWeightTouched(true);
     if (key === 'location_id') setLocationIdTouched(true);
     if (key === 'location_id') setLocationIdTouched(true);
     if (key === 'last_dried_at') setLastDriedTouched(true);
     if (key === 'last_dried_at') setLastDriedTouched(true);
+    // A preset the user picked is theirs: the next product's default leaves it alone.
+    if (key === 'slicer_filament') presetFromDefault.current = false;
     if (errors[key]) {
     if (errors[key]) {
       setErrors(prev => ({ ...prev, [key]: undefined }));
       setErrors(prev => ({ ...prev, [key]: undefined }));
     }
     }
@@ -625,6 +744,7 @@ export function SpoolFormModal({
         const suppliersOk = await saveSupplierLinks(newSpool.id);
         const suppliersOk = await saveSupplierLinks(newSpool.id);
         if (!suppliersOk) return;
         if (!suppliersOk) return;
       }
       }
+      await saveProfileDefault();
       await refreshSpoolQueries();
       await refreshSpoolQueries();
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       showToast(t('inventory.spoolCreated'), 'success');
       showToast(t('inventory.spoolCreated'), 'success');
@@ -659,13 +779,12 @@ export function SpoolFormModal({
         ? spoolmanResult.created
         ? spoolmanResult.created
         : (result as InventorySpool[]);
         : (result as InventorySpool[]);
 
 
-      // Bulk create: every copy gets the same profiles and overrides. Skipped
-      // entirely when the user configured neither, so a plain bulk add does
-      // not fire two writes per spool.
-      if (selectedProfiles.size > 0 || modelPresets.size > 0) {
-        for (const s of createdSpools) {
-          await savePrinterProfiles(s.id);
-        }
+      // Bulk create: every copy gets the same profiles and overrides. A no-op
+      // when the user left the Printers tab alone (savePrinterProfiles), so a
+      // plain bulk add neither fires two writes per spool nor clears the
+      // profile default the backend gave every copy (#1326).
+      for (const s of createdSpools) {
+        await savePrinterProfiles(s.id);
       }
       }
       // Every copy of a bulk add shares the same supplier assignments (#2988).
       // Every copy of a bulk add shares the same supplier assignments (#2988).
       if (shouldSaveSupplierLinks) {
       if (shouldSaveSupplierLinks) {
@@ -673,6 +792,7 @@ export function SpoolFormModal({
           await saveSupplierLinks(s.id);
           await saveSupplierLinks(s.id);
         }
         }
       }
       }
+      await saveProfileDefault();
       await refreshSpoolQueries();
       await refreshSpoolQueries();
       if (onSpoolsCreated) onSpoolsCreated(createdSpools);
       if (onSpoolsCreated) onSpoolsCreated(createdSpools);
       if (spoolmanResult && spoolmanResult.failed_count > 0) {
       if (spoolmanResult && spoolmanResult.failed_count > 0) {
@@ -709,6 +829,7 @@ export function SpoolFormModal({
         const suppliersOk = await saveSupplierLinks(spool.id);
         const suppliersOk = await saveSupplierLinks(spool.id);
         if (!suppliersOk) return;
         if (!suppliersOk) return;
       }
       }
+      await saveProfileDefault();
       await refreshSpoolQueries();
       await refreshSpoolQueries();
       showToast(t('inventory.spoolUpdated'), 'success');
       showToast(t('inventory.spoolUpdated'), 'success');
       onClose();
       onClose();
@@ -869,10 +990,11 @@ export function SpoolFormModal({
   // Save everything the Printers tab holds: one K profile per hotend and the
   // Save everything the Printers tab holds: one K profile per hotend and the
   // per-printer-model preset overrides. Returns false if either write failed,
   // per-printer-model preset overrides. Returns false if either write failed,
   // which keeps the modal open so the user does not lose what they picked.
   // which keeps the modal open so the user does not lose what they picked.
-  const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
-    const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
-    const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
+  // On a create, only once the user changed something there: an untouched
+  // tab holds nothing, or the profile default the backend applies itself.
+  const shouldSavePrinterProfiles = isEditing || isCopying || printerProfilesTouched;
 
 
+  const buildPrinterProfiles = (): { profiles: SpoolKProfileInput[]; presets: SpoolFilamentPresetInput[] } => {
     // The selection Map is keyed by hotend and holds the calibration itself,
     // The selection Map is keyed by hotend and holds the calibration itself,
     // so nothing has to be resolved back out of the printer's live list. That
     // so nothing has to be resolved back out of the printer's live list. That
     // also fixes a real defect in the old key-based lookup: it matched a
     // also fixes a real defect in the old key-based lookup: it matched a
@@ -910,6 +1032,15 @@ export function SpoolFormModal({
       });
       });
     }
     }
 
 
+    return { profiles, presets };
+  };
+
+  const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
+    if (!shouldSavePrinterProfiles) return true;
+    const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
+    const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
+    const { profiles, presets } = buildPrinterProfiles();
+
     // Both are full replacements, so both run even when empty -- that is how
     // Both are full replacements, so both run even when empty -- that is how
     // the user clears the last profile or the last override.
     // the user clears the last profile or the last override.
     try {
     try {
@@ -931,6 +1062,39 @@ export function SpoolFormModal({
     return true;
     return true;
   };
   };
 
 
+  const profileDefaultProduct = [formData.brand, formData.material, formData.subtype]
+    .map(part => part.trim())
+    .filter(Boolean)
+    .join(' ');
+
+  // "Save as default" on the Printers tab (#1326): the tab and the spool's own
+  // preset become what new spools of this product start with. After the
+  // spool is saved, so a refused spool save leaves the default alone; a failed
+  // default save only warns -- the spool is saved either way.
+  const saveProfileDefault = async (): Promise<void> => {
+    if (!saveAsDefault || !formData.material.trim()) return;
+    const { profiles, presets } = buildPrinterProfiles();
+    const slicerFilament = formData.slicer_filament || null;
+    try {
+      await api.saveProfileDefault({
+        brand: formData.brand.trim(),
+        material: formData.material.trim(),
+        subtype: formData.subtype.trim(),
+        // "Only <colour>" needs a colour; without one the tick box shows, and
+        // this saves, the default for all colours.
+        color_name: defaultScope === 'colour' ? formData.color_name.trim() : '',
+        slicer_filament: slicerFilament,
+        slicer_filament_name: slicerFilament ? selectedPresetOption?.displayName || presetInputValue || null : null,
+        presets,
+        k_profiles: profiles,
+      });
+      await queryClient.invalidateQueries({ queryKey: ['profile-defaults'] });
+    } catch (e) {
+      console.error('Failed to save the profile default:', e);
+      showToast(t('inventory.profileDefaults.saveFailed'), 'warning');
+    }
+  };
+
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
     if (!isOpen) return;
     if (!isOpen) return;
@@ -1249,15 +1413,27 @@ export function SpoolFormModal({
               printersWithCalibrations={resolvedCalibrations}
               printersWithCalibrations={resolvedCalibrations}
               filamentOptions={filamentOptions}
               filamentOptions={filamentOptions}
               modelPresets={modelPresets}
               modelPresets={modelPresets}
-              setModelPresets={setModelPresets}
+              setModelPresets={editModelPresets}
               selectedProfiles={selectedProfiles}
               selectedProfiles={selectedProfiles}
-              setSelectedProfiles={setSelectedProfiles}
+              setSelectedProfiles={editSelectedProfiles}
               selectedGroupId={selectedGroupId}
               selectedGroupId={selectedGroupId}
               setSelectedGroupId={setSelectedGroupId}
               setSelectedGroupId={setSelectedGroupId}
               printerModels={printerModelsData}
               printerModels={printerModelsData}
               isLoading={loadingCalibrations}
               isLoading={loadingCalibrations}
             />
             />
           )}
           )}
+          {activeTab === 'printers' && (
+            <ProfileDefaultOption
+              product={profileDefaultProduct}
+              colorName={formData.color_name}
+              prefilled={prefilledFromDefault}
+              checked={saveAsDefault}
+              onCheckedChange={setSaveAsDefault}
+              scope={defaultScope}
+              onScopeChange={setDefaultScope}
+              disabled={!formData.material.trim()}
+            />
+          )}
         </div>
         </div>
 
 
         {/* Footer */}
         {/* Footer */}

+ 89 - 0
frontend/src/components/spool-form/ProfileDefaultOption.tsx

@@ -0,0 +1,89 @@
+import { Sparkles } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+export type ProfileDefaultScope = 'all' | 'colour';
+
+interface ProfileDefaultOptionProps {
+  /** "Bambu Lab PLA Basic": the product the default would be for. */
+  product: string;
+  colorName: string;
+  /** Whether the tab was filled in from an existing default. */
+  prefilled: boolean;
+  checked: boolean;
+  onCheckedChange: (checked: boolean) => void;
+  scope: ProfileDefaultScope;
+  onScopeChange: (scope: ProfileDefaultScope) => void;
+  /** No material yet: there is no product to save a default for. */
+  disabled: boolean;
+}
+
+/**
+ * Below the Printers tab (#1326): says when the tab came from the product's
+ * profile default, and offers to save what the tab holds as that default.
+ *
+ * "All colours" is preselected: it is what saves the work on the first spool
+ * of every colour, while a colour of its own is for one calibrated apart.
+ */
+export function ProfileDefaultOption({
+  product,
+  colorName,
+  prefilled,
+  checked,
+  onCheckedChange,
+  scope,
+  onScopeChange,
+  disabled,
+}: ProfileDefaultOptionProps) {
+  const { t } = useTranslation();
+  const colour = colorName.trim();
+
+  return (
+    <div className="mt-3 p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg space-y-2">
+      {prefilled && (
+        <p className="flex items-center gap-1.5 text-xs text-bambu-green">
+          <Sparkles className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />
+          {t('inventory.profileDefaults.prefilled', { product })}
+        </p>
+      )}
+      <label className={`flex items-center gap-2 text-sm ${disabled ? 'text-bambu-gray' : 'text-white cursor-pointer'}`}>
+        <input
+          type="checkbox"
+          checked={checked && !disabled}
+          disabled={disabled}
+          onChange={(e) => onCheckedChange(e.target.checked)}
+          className="w-4 h-4 accent-bambu-green"
+        />
+        {disabled
+          ? t('inventory.profileDefaults.saveNeedsMaterial')
+          : t('inventory.profileDefaults.saveAsDefault', { product })}
+      </label>
+      {checked && !disabled && (
+        <div role="radiogroup" aria-label={t('inventory.profileDefaults.scope')} className="flex flex-wrap gap-4 pl-6">
+          <label className="flex items-center gap-2 text-sm text-white cursor-pointer">
+            <input
+              type="radio"
+              name="profile-default-scope"
+              checked={scope === 'all' || !colour}
+              onChange={() => onScopeChange('all')}
+              className="accent-bambu-green"
+            />
+            {t('inventory.profileDefaults.allColours')}
+          </label>
+          <label className={`flex items-center gap-2 text-sm ${colour ? 'text-white cursor-pointer' : 'text-bambu-gray'}`}>
+            <input
+              type="radio"
+              name="profile-default-scope"
+              checked={scope === 'colour' && !!colour}
+              disabled={!colour}
+              onChange={() => onScopeChange('colour')}
+              className="accent-bambu-green"
+            />
+            {colour
+              ? t('inventory.profileDefaults.colourOnly', { colour })
+              : t('inventory.profileDefaults.colourOnlyNeedsColour', { tab: t('inventory.colorAndCostTab') })}
+          </label>
+        </div>
+      )}
+    </div>
+  );
+}

+ 65 - 1
frontend/src/components/spool-form/utils.ts

@@ -1,5 +1,5 @@
 import { api } from '../../api/client';
 import { api } from '../../api/client';
-import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
+import type { SlicerSetting, LocalPreset, BuiltinFilament, SpoolKProfileInput } from '../../api/client';
 import { installedNozzleDiameters } from '../../utils/amsHelpers';
 import { installedNozzleDiameters } from '../../utils/amsHelpers';
 import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
 import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS, STANDARD_NOZZLE_DIAMETERS } from './constants';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS, STANDARD_NOZZLE_DIAMETERS } from './constants';
@@ -465,6 +465,70 @@ export function hotendKey(printerId: number, extruder: number, diameter: string)
   return `${printerId}:${extruder}:${diameter}`;
   return `${printerId}:${extruder}:${diameter}`;
 }
 }
 
 
+/**
+ * K profiles picked for a new spool, plus its product's profile default
+ * (#1326) for every hotend nothing was picked for. For a page that saves a
+ * new spool's K profiles without showing the default: the save replaces the
+ * whole list, so the default the create applied would otherwise be lost. A
+ * failed lookup returns the picks alone.
+ */
+export async function withProductDefaultKProfiles(
+  picked: SpoolKProfileInput[],
+  product: { brand: string; material: string; subtype: string; color_name: string },
+): Promise<SpoolKProfileInput[]> {
+  const keyOf = (k: SpoolKProfileInput) =>
+    hotendKey(k.printer_id, k.extruder ?? 0, (k.nozzle_diameter || '').trim() || '0.4');
+  let resolved: Awaited<ReturnType<typeof api.resolveProfileDefault>>;
+  try {
+    resolved = await api.resolveProfileDefault({
+      brand: product.brand.trim(),
+      material: product.material.trim(),
+      subtype: product.subtype.trim(),
+      color_name: product.color_name.trim(),
+    });
+  } catch {
+    return picked;
+  }
+  const taken = new Set(picked.map(keyOf));
+  const merged = [...picked];
+  for (const k of resolved?.k_profiles ?? []) {
+    if (taken.has(keyOf(k))) continue;
+    merged.push(k);
+    taken.add(keyOf(k));
+  }
+  return merged;
+}
+
+/**
+ * The Printers tab's selection, one entry per hotend, from stored K profiles:
+ * a spool's, or a profile default's. The stored row carries everything the
+ * picker needs to show the selection before the printer answers, so an
+ * offline printer still renders what was chosen for it. Rows without a
+ * calibration slot are skipped -- there is nothing on the printer to select.
+ */
+export function selectionFromKProfiles(rows: SpoolKProfileInput[]): Map<string, CalibrationProfile> {
+  const chosen = new Map<string, CalibrationProfile>();
+  for (const p of rows) {
+    if (p.cali_idx === null || p.cali_idx === undefined) continue;
+    const diameter = (p.nozzle_diameter || '').trim() || '0.4';
+    const extruder = p.extruder ?? 0;
+    chosen.set(hotendKey(p.printer_id, extruder, diameter), {
+      cali_idx: p.cali_idx,
+      filament_id: '',
+      setting_id: p.setting_id || '',
+      name: p.name || '',
+      k_value: p.k_value,
+      n_coef: 0,
+      extruder_id: extruder,
+      nozzle_diameter: diameter,
+      // Stored as the bare flow code; the picker re-derives its label
+      // from the same field it reads off a live profile.
+      nozzle_id: p.nozzle_type || '',
+    });
+  }
+  return chosen;
+}
+
 /**
 /**
  * Key one per-printer-model preset override.
  * Key one per-printer-model preset override.
  *
  *

+ 21 - 0
frontend/src/i18n/locales/de.ts

@@ -5221,6 +5221,27 @@ export default {
     lastDriedHelp: 'Wird eingetragen, wenn ein AMS-Trocknungslauf mindestens zur Hälfte gelaufen ist. Für eine anderswo getrocknete Spule hier setzen.',
     lastDriedHelp: 'Wird eingetragen, wenn ein AMS-Trocknungslauf mindestens zur Hälfte gelaufen ist. Für eine anderswo getrocknete Spule hier setzen.',
     materialNumberNone: 'Keine Materialnummer',
     materialNumberNone: 'Keine Materialnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
+    profileDefaults: {
+      title: 'Profil-Standards',
+      description: 'Womit neue Spulen eines Produkts starten: das Slicer-Preset, die Presets pro Druckermodell und die K-Profile. Speichere einen Standard im Drucker-Tab einer Spule mit Als Standard speichern; erneutes Speichern ersetzt ihn.',
+      empty: 'Noch keine Profil-Standards. Richte den Drucker-Tab einer Spule ein und setze den Haken bei Als Standard speichern.',
+      allColours: 'Alle Farben',
+      colourOnly: 'Nur {{colour}}',
+      colourOnlyNeedsColour: 'Nur diese Farbe: Diese Spule hat keinen Farbnamen. Trage unter {{tab}} einen ein, um einen Standard nur für diese Farbe zu speichern.',
+      scope: 'Gilt für',
+      saveAsDefault: 'Als Standard für neue {{product}}-Spulen speichern',
+      saveNeedsMaterial: 'Als Standard speichern (zuerst ein Material festlegen)',
+      prefilled: 'Aus dem Profil-Standard für {{product}} übernommen',
+      saveFailed: 'Spule gespeichert, aber der Profil-Standard konnte nicht gespeichert werden',
+      apply: 'Auf vorhandene Spulen anwenden',
+      applyHelp: 'Ergänzt bei aktiven Spulen dieses Produkts, was ihnen noch fehlt. Vorhandenes bleibt unverändert.',
+      applied_one: 'Bei {{count}} Spule ergänzt',
+      applied_other: 'Bei {{count}} Spulen ergänzt',
+      applyFailed: 'Profil-Standard konnte nicht angewendet werden',
+      deleteTitle: 'Profil-Standard löschen',
+      deleteMessage: 'Profil-Standard für {{product}} löschen? Spulen, die seine Profile schon haben, behalten sie.',
+      deleteFailed: 'Profil-Standard konnte nicht gelöscht werden',
+    },
     suppliers: {
     suppliers: {
       label: 'Lieferanten',
       label: 'Lieferanten',
       none: 'Kein Lieferant',
       none: 'Kein Lieferant',

+ 21 - 0
frontend/src/i18n/locales/en.ts

@@ -5263,6 +5263,27 @@ export default {
     lastDriedHelp: 'Filled in when an AMS drying run of at least half its length ends. Set it here for a spool dried elsewhere.',
     lastDriedHelp: 'Filled in when an AMS drying run of at least half its length ends. Set it here for a spool dried elsewhere.',
     materialNumberNone: 'No material number',
     materialNumberNone: 'No material number',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
+    profileDefaults: {
+      title: 'Profile defaults',
+      description: 'What new spools of a product start with: the slicer preset, the presets per printer model and the K profiles. Save one from a spool\'s Printers tab with Save as default; saving again replaces it.',
+      empty: 'No profile defaults yet. Set up a spool\'s Printers tab and tick Save as default.',
+      allColours: 'All colours',
+      colourOnly: 'Only {{colour}}',
+      colourOnlyNeedsColour: 'Only this colour: this spool has no colour name. Add one on {{tab}} to save a default for this colour alone.',
+      scope: 'Applies to',
+      saveAsDefault: 'Save as default for new {{product}} spools',
+      saveNeedsMaterial: 'Save as default (set a material first)',
+      prefilled: 'Filled in from the profile default for {{product}}',
+      saveFailed: 'Spool saved, but the profile default could not be saved',
+      apply: 'Apply to existing spools',
+      applyHelp: 'Fill in what active spools of this product don\'t have yet. Nothing they already have is changed.',
+      applied_one: 'Filled in on {{count}} spool',
+      applied_other: 'Filled in on {{count}} spools',
+      applyFailed: 'Could not apply the profile default',
+      deleteTitle: 'Delete profile default',
+      deleteMessage: 'Delete the profile default for {{product}}? Spools that already have its profiles keep them.',
+      deleteFailed: 'Could not delete the profile default',
+    },
     suppliers: {
     suppliers: {
       label: 'Suppliers',
       label: 'Suppliers',
       none: 'No supplier',
       none: 'No supplier',

+ 21 - 0
frontend/src/i18n/locales/es.ts

@@ -5224,6 +5224,27 @@ export default {
     lastDriedHelp: 'Se rellena cuando termina un secado en el AMS que ha durado al menos la mitad de su duración. Indícalo aquí para una bobina secada en otro sitio.',
     lastDriedHelp: 'Se rellena cuando termina un secado en el AMS que ha durado al menos la mitad de su duración. Indícalo aquí para una bobina secada en otro sitio.',
     materialNumberNone: 'Sin número de material',
     materialNumberNone: 'Sin número de material',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
+    profileDefaults: {
+      title: 'Perfiles predeterminados',
+      description: 'Con qué empiezan las bobinas nuevas de un producto: el preset del laminador, los presets por modelo de impresora y los perfiles K. Guarda uno desde la pestaña Impresoras de una bobina con Guardar como predeterminado; guardar de nuevo lo reemplaza.',
+      empty: 'Aún no hay perfiles predeterminados. Configura la pestaña Impresoras de una bobina y marca Guardar como predeterminado.',
+      allColours: 'Todos los colores',
+      colourOnly: 'Solo {{colour}}',
+      colourOnlyNeedsColour: 'Solo este color: esta bobina no tiene nombre de color. Añade uno en {{tab}} para guardar un predeterminado solo para este color.',
+      scope: 'Se aplica a',
+      saveAsDefault: 'Guardar como predeterminado para bobinas nuevas de {{product}}',
+      saveNeedsMaterial: 'Guardar como predeterminado (elige primero un material)',
+      prefilled: 'Rellenado desde el perfil predeterminado de {{product}}',
+      saveFailed: 'Bobina guardada, pero no se pudo guardar el perfil predeterminado',
+      apply: 'Aplicar a bobinas existentes',
+      applyHelp: 'Completa lo que les falta a las bobinas activas de este producto. No cambia nada de lo que ya tienen.',
+      applied_one: 'Completado en {{count}} bobina',
+      applied_other: 'Completado en {{count}} bobinas',
+      applyFailed: 'No se pudo aplicar el perfil predeterminado',
+      deleteTitle: 'Eliminar perfil predeterminado',
+      deleteMessage: '¿Eliminar el perfil predeterminado de {{product}}? Las bobinas que ya tienen sus perfiles los conservan.',
+      deleteFailed: 'No se pudo eliminar el perfil predeterminado',
+    },
     suppliers: {
     suppliers: {
       label: 'Proveedores',
       label: 'Proveedores',
       none: 'Sin proveedor',
       none: 'Sin proveedor',

+ 21 - 0
frontend/src/i18n/locales/fr.ts

@@ -5208,6 +5208,27 @@ export default {
     lastDriedNow: 'Maintenant',
     lastDriedNow: 'Maintenant',
     lastDriedHelp: 'Renseigné à la fin d\'un séchage AMS ayant duré au moins la moitié de sa durée. Indiquez-le ici pour une bobine séchée ailleurs.',
     lastDriedHelp: 'Renseigné à la fin d\'un séchage AMS ayant duré au moins la moitié de sa durée. Indiquez-le ici pour une bobine séchée ailleurs.',
     materialNumberNone: 'Aucun numéro de matière',
     materialNumberNone: 'Aucun numéro de matière',
+    profileDefaults: {
+      title: 'Profils par défaut',
+      description: 'Ce avec quoi démarrent les nouvelles bobines d\'un produit : le preset du trancheur, les presets par modèle d\'imprimante et les profils K. Enregistrez-en un depuis l\'onglet Imprimantes d\'une bobine avec Enregistrer par défaut ; un nouvel enregistrement le remplace.',
+      empty: 'Aucun profil par défaut pour l\'instant. Configurez l\'onglet Imprimantes d\'une bobine et cochez Enregistrer par défaut.',
+      allColours: 'Toutes les couleurs',
+      colourOnly: 'Seulement {{colour}}',
+      colourOnlyNeedsColour: 'Seulement cette couleur : cette bobine n\'a pas de nom de couleur. Ajoutez-en un dans {{tab}} pour enregistrer un défaut pour cette seule couleur.',
+      scope: 'S\'applique à',
+      saveAsDefault: 'Enregistrer par défaut pour les nouvelles bobines {{product}}',
+      saveNeedsMaterial: 'Enregistrer par défaut (choisissez d\'abord un matériau)',
+      prefilled: 'Rempli à partir du profil par défaut de {{product}}',
+      saveFailed: 'Bobine enregistrée, mais le profil par défaut n\'a pas pu être enregistré',
+      apply: 'Appliquer aux bobines existantes',
+      applyHelp: 'Complète ce qui manque encore aux bobines actives de ce produit. Rien de ce qu\'elles ont déjà n\'est modifié.',
+      applied_one: 'Complété sur {{count}} bobine',
+      applied_other: 'Complété sur {{count}} bobines',
+      applyFailed: 'Impossible d\'appliquer le profil par défaut',
+      deleteTitle: 'Supprimer le profil par défaut',
+      deleteMessage: 'Supprimer le profil par défaut de {{product}} ? Les bobines qui ont déjà ses profils les gardent.',
+      deleteFailed: 'Impossible de supprimer le profil par défaut',
+    },
     suppliers: {
     suppliers: {
       label: 'Fournisseurs',
       label: 'Fournisseurs',
       none: 'Aucun fournisseur',
       none: 'Aucun fournisseur',

+ 21 - 0
frontend/src/i18n/locales/it.ts

@@ -5207,6 +5207,27 @@ export default {
     lastDriedNow: 'Ora',
     lastDriedNow: 'Ora',
     lastDriedHelp: 'Compilato quando termina un\'essiccazione AMS durata almeno metà del tempo previsto. Impostalo qui per una bobina essiccata altrove.',
     lastDriedHelp: 'Compilato quando termina un\'essiccazione AMS durata almeno metà del tempo previsto. Impostalo qui per una bobina essiccata altrove.',
     materialNumberNone: 'Nessun numero materiale',
     materialNumberNone: 'Nessun numero materiale',
+    profileDefaults: {
+      title: 'Profili predefiniti',
+      description: 'Con cosa partono le nuove bobine di un prodotto: il preset dello slicer, i preset per modello di stampante e i profili K. Salvane uno dalla scheda Stampanti di una bobina con Salva come predefinito; salvando di nuovo lo sostituisci.',
+      empty: 'Ancora nessun profilo predefinito. Configura la scheda Stampanti di una bobina e spunta Salva come predefinito.',
+      allColours: 'Tutti i colori',
+      colourOnly: 'Solo {{colour}}',
+      colourOnlyNeedsColour: 'Solo questo colore: questa bobina non ha un nome colore. Aggiungine uno in {{tab}} per salvare un predefinito solo per questo colore.',
+      scope: 'Si applica a',
+      saveAsDefault: 'Salva come predefinito per le nuove bobine {{product}}',
+      saveNeedsMaterial: 'Salva come predefinito (imposta prima un materiale)',
+      prefilled: 'Compilato dal profilo predefinito di {{product}}',
+      saveFailed: 'Bobina salvata, ma non è stato possibile salvare il profilo predefinito',
+      apply: 'Applica alle bobine esistenti',
+      applyHelp: 'Completa ciò che manca alle bobine attive di questo prodotto. Nulla di ciò che hanno già viene modificato.',
+      applied_one: 'Completato su {{count}} bobina',
+      applied_other: 'Completato su {{count}} bobine',
+      applyFailed: 'Impossibile applicare il profilo predefinito',
+      deleteTitle: 'Elimina profilo predefinito',
+      deleteMessage: 'Eliminare il profilo predefinito di {{product}}? Le bobine che hanno già i suoi profili li mantengono.',
+      deleteFailed: 'Impossibile eliminare il profilo predefinito',
+    },
     suppliers: {
     suppliers: {
       label: 'Fornitori',
       label: 'Fornitori',
       none: 'Nessun fornitore',
       none: 'Nessun fornitore',

+ 21 - 0
frontend/src/i18n/locales/ja.ts

@@ -5221,6 +5221,27 @@ export default {
     lastDriedHelp: 'AMS の乾燥が予定時間の半分以上行われて終了すると記録されます。他の場所で乾燥したスプールはここで設定してください。',
     lastDriedHelp: 'AMS の乾燥が予定時間の半分以上行われて終了すると記録されます。他の場所で乾燥したスプールはここで設定してください。',
     materialNumberNone: '資材番号なし',
     materialNumberNone: '資材番号なし',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
+    profileDefaults: {
+      title: 'プロファイルの既定値',
+      description: '製品の新しいスプールに最初から入る設定: スライサープリセット、プリンターモデルごとのプリセット、Kプロファイル。スプールのプリンタータブで「既定値として保存」から保存します。もう一度保存すると置き換わります。',
+      empty: 'プロファイルの既定値はまだありません。スプールのプリンタータブを設定し、「既定値として保存」にチェックを入れてください。',
+      allColours: 'すべての色',
+      colourOnly: '{{colour}} のみ',
+      colourOnlyNeedsColour: 'この色のみ: このスプールには色名がありません。この色だけの既定値を保存するには、{{tab}} で色名を追加してください。',
+      scope: '適用先',
+      saveAsDefault: '新しい {{product}} スプールの既定値として保存',
+      saveNeedsMaterial: '既定値として保存 (先に素材を設定してください)',
+      prefilled: '{{product}} のプロファイル既定値から入力しました',
+      saveFailed: 'スプールは保存しましたが、プロファイルの既定値を保存できませんでした',
+      apply: '既存のスプールに適用',
+      applyHelp: 'この製品の使用中スプールに足りないものを補います。既にあるものは変更しません。',
+      applied_one: '{{count}} 本のスプールに入力しました',
+      applied_other: '{{count}} 本のスプールに入力しました',
+      applyFailed: 'プロファイルの既定値を適用できませんでした',
+      deleteTitle: 'プロファイルの既定値を削除',
+      deleteMessage: '{{product}} のプロファイル既定値を削除しますか? 既にそのプロファイルを持つスプールはそのまま保持します。',
+      deleteFailed: 'プロファイルの既定値を削除できませんでした',
+    },
     suppliers: {
     suppliers: {
       label: 'サプライヤー',
       label: 'サプライヤー',
       none: 'サプライヤーなし',
       none: 'サプライヤーなし',

+ 21 - 0
frontend/src/i18n/locales/ko.ts

@@ -5006,6 +5006,27 @@ export default {
     lastDriedNow: '지금',
     lastDriedNow: '지금',
     lastDriedHelp: 'AMS 건조가 예정 시간의 절반 이상 진행된 뒤 끝나면 기록됩니다. 다른 곳에서 건조한 스풀은 여기에서 설정하세요.',
     lastDriedHelp: 'AMS 건조가 예정 시간의 절반 이상 진행된 뒤 끝나면 기록됩니다. 다른 곳에서 건조한 스풀은 여기에서 설정하세요.',
     materialNumberNone: '자재 번호 없음',
     materialNumberNone: '자재 번호 없음',
+    profileDefaults: {
+      title: '프로필 기본값',
+      description: '제품의 새 스풀이 처음부터 갖는 설정: 슬라이서 프리셋, 프린터 모델별 프리셋, K 프로필. 스풀의 프린터 탭에서 기본값으로 저장으로 저장합니다. 다시 저장하면 교체됩니다.',
+      empty: '아직 프로필 기본값이 없습니다. 스풀의 프린터 탭을 설정하고 기본값으로 저장을 선택하세요.',
+      allColours: '모든 색상',
+      colourOnly: '{{colour}}만',
+      colourOnlyNeedsColour: '이 색상만: 이 스풀에는 색상 이름이 없습니다. 이 색상만의 기본값을 저장하려면 {{tab}}에서 이름을 추가하세요.',
+      scope: '적용 대상',
+      saveAsDefault: '새 {{product}} 스풀의 기본값으로 저장',
+      saveNeedsMaterial: '기본값으로 저장 (먼저 재료를 지정하세요)',
+      prefilled: '{{product}} 프로필 기본값에서 채웠습니다',
+      saveFailed: '스풀은 저장했지만 프로필 기본값을 저장하지 못했습니다',
+      apply: '기존 스풀에 적용',
+      applyHelp: '이 제품의 활성 스풀에 아직 없는 것을 채웁니다. 이미 있는 것은 바꾸지 않습니다.',
+      applied_one: '스풀 {{count}}개에 채웠습니다',
+      applied_other: '스풀 {{count}}개에 채웠습니다',
+      applyFailed: '프로필 기본값을 적용하지 못했습니다',
+      deleteTitle: '프로필 기본값 삭제',
+      deleteMessage: '{{product}}의 프로필 기본값을 삭제할까요? 이미 해당 프로필이 있는 스풀은 그대로 유지합니다.',
+      deleteFailed: '프로필 기본값을 삭제하지 못했습니다',
+    },
     suppliers: {
     suppliers: {
       label: '공급업체',
       label: '공급업체',
       none: '공급업체 없음',
       none: '공급업체 없음',

+ 21 - 0
frontend/src/i18n/locales/nl.ts

@@ -5261,6 +5261,27 @@ export default {
     lastDriedHelp: 'Wordt ingevuld wanneer een AMS-droogcyclus van minstens de helft van zijn duur eindigt. Stel het hier in voor een elders gedroogde spoel.',
     lastDriedHelp: 'Wordt ingevuld wanneer een AMS-droogcyclus van minstens de helft van zijn duur eindigt. Stel het hier in voor een elders gedroogde spoel.',
     materialNumberNone: 'Geen materiaalnummer',
     materialNumberNone: 'Geen materiaalnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     // Suppliers (#2988): the master list and the per-spool assignments.
+    profileDefaults: {
+      title: 'Profielstandaarden',
+      description: 'Waarmee nieuwe spoelen van een product beginnen: de slicer-preset, de presets per printermodel en de K-profielen. Sla er een op vanuit het tabblad Printers van een spoel met Opslaan als standaard; opnieuw opslaan vervangt hem.',
+      empty: 'Nog geen profielstandaarden. Stel het tabblad Printers van een spoel in en vink Opslaan als standaard aan.',
+      allColours: 'Alle kleuren',
+      colourOnly: 'Alleen {{colour}}',
+      colourOnlyNeedsColour: 'Alleen deze kleur: deze spoel heeft geen kleurnaam. Voeg er een toe onder {{tab}} om een standaard voor alleen deze kleur op te slaan.',
+      scope: 'Geldt voor',
+      saveAsDefault: 'Opslaan als standaard voor nieuwe {{product}}-spoelen',
+      saveNeedsMaterial: 'Opslaan als standaard (kies eerst een materiaal)',
+      prefilled: 'Ingevuld vanuit de profielstandaard voor {{product}}',
+      saveFailed: 'Spoel opgeslagen, maar de profielstandaard kon niet worden opgeslagen',
+      apply: 'Toepassen op bestaande spoelen',
+      applyHelp: 'Vult aan wat actieve spoelen van dit product nog missen. Wat ze al hebben blijft ongewijzigd.',
+      applied_one: 'Aangevuld bij {{count}} spoel',
+      applied_other: 'Aangevuld bij {{count}} spoelen',
+      applyFailed: 'Profielstandaard kon niet worden toegepast',
+      deleteTitle: 'Profielstandaard verwijderen',
+      deleteMessage: 'Profielstandaard voor {{product}} verwijderen? Spoelen die de profielen al hebben, houden ze.',
+      deleteFailed: 'Profielstandaard kon niet worden verwijderd',
+    },
     suppliers: {
     suppliers: {
       label: 'Leveranciers',
       label: 'Leveranciers',
       none: 'Geen leverancier',
       none: 'Geen leverancier',

+ 21 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5207,6 +5207,27 @@ export default {
     lastDriedNow: 'Agora',
     lastDriedNow: 'Agora',
     lastDriedHelp: 'Preenchido quando termina uma secagem no AMS que durou pelo menos metade do tempo previsto. Defina aqui para um carretel secado em outro lugar.',
     lastDriedHelp: 'Preenchido quando termina uma secagem no AMS que durou pelo menos metade do tempo previsto. Defina aqui para um carretel secado em outro lugar.',
     materialNumberNone: 'Sem número de material',
     materialNumberNone: 'Sem número de material',
+    profileDefaults: {
+      title: 'Perfis padrão',
+      description: 'Com o que os carretéis novos de um produto começam: o preset do fatiador, os presets por modelo de impressora e os perfis K. Salve um na aba Impressoras de um carretel com Salvar como padrão; salvar de novo o substitui.',
+      empty: 'Nenhum perfil padrão ainda. Configure a aba Impressoras de um carretel e marque Salvar como padrão.',
+      allColours: 'Todas as cores',
+      colourOnly: 'Somente {{colour}}',
+      colourOnlyNeedsColour: 'Somente esta cor: este carretel não tem nome de cor. Adicione um em {{tab}} para salvar um padrão só para esta cor.',
+      scope: 'Aplica-se a',
+      saveAsDefault: 'Salvar como padrão para novos carretéis {{product}}',
+      saveNeedsMaterial: 'Salvar como padrão (defina um material primeiro)',
+      prefilled: 'Preenchido a partir do perfil padrão de {{product}}',
+      saveFailed: 'Carretel salvo, mas não foi possível salvar o perfil padrão',
+      apply: 'Aplicar aos carretéis existentes',
+      applyHelp: 'Preenche o que falta nos carretéis ativos deste produto. Nada do que eles já têm é alterado.',
+      applied_one: 'Preenchido em {{count}} carretel',
+      applied_other: 'Preenchido em {{count}} carretéis',
+      applyFailed: 'Não foi possível aplicar o perfil padrão',
+      deleteTitle: 'Excluir perfil padrão',
+      deleteMessage: 'Excluir o perfil padrão de {{product}}? Carretéis que já têm os perfis os mantêm.',
+      deleteFailed: 'Não foi possível excluir o perfil padrão',
+    },
     suppliers: {
     suppliers: {
       label: 'Fornecedores',
       label: 'Fornecedores',
       none: 'Sem fornecedor',
       none: 'Sem fornecedor',

+ 21 - 0
frontend/src/i18n/locales/ru.ts

@@ -4997,6 +4997,27 @@ export default {
     lastDriedNow: 'Сейчас',
     lastDriedNow: 'Сейчас',
     lastDriedHelp: 'Заполняется, когда сушка в AMS завершается, проработав не меньше половины заданного времени. Для катушки, высушенной в другом месте, укажите здесь.',
     lastDriedHelp: 'Заполняется, когда сушка в AMS завершается, проработав не меньше половины заданного времени. Для катушки, высушенной в другом месте, укажите здесь.',
     materialNumberNone: 'Без артикула',
     materialNumberNone: 'Без артикула',
+    profileDefaults: {
+      title: "Профили по умолчанию",
+      description: "С чем начинают новые катушки продукта: пресет слайсера, пресеты по модели принтера и K-профили. Сохраните его на вкладке Принтеры катушки через Сохранить по умолчанию; повторное сохранение заменяет его.",
+      empty: "Профилей по умолчанию пока нет. Настройте вкладку Принтеры катушки и отметьте Сохранить по умолчанию.",
+      allColours: "Все цвета",
+      colourOnly: "Только {{colour}}",
+      colourOnlyNeedsColour: "Только этот цвет: у катушки нет названия цвета. Добавьте его на вкладке {{tab}}, чтобы сохранить значение по умолчанию только для этого цвета.",
+      scope: "Применяется к",
+      saveAsDefault: "Сохранить по умолчанию для новых катушек {{product}}",
+      saveNeedsMaterial: "Сохранить по умолчанию (сначала укажите материал)",
+      prefilled: "Заполнено из профиля по умолчанию для {{product}}",
+      saveFailed: "Катушка сохранена, но профиль по умолчанию сохранить не удалось",
+      apply: "Применить к имеющимся катушкам",
+      applyHelp: "Дополняет то, чего ещё нет у активных катушек этого продукта. То, что у них уже есть, не меняется.",
+      applied_one: "Дополнено катушек: {{count}}",
+      applied_other: "Дополнено катушек: {{count}}",
+      applyFailed: "Не удалось применить профиль по умолчанию",
+      deleteTitle: "Удалить профиль по умолчанию",
+      deleteMessage: "Удалить профиль по умолчанию для {{product}}? Катушки, у которых уже есть его профили, сохранят их.",
+      deleteFailed: "Не удалось удалить профиль по умолчанию",
+    },
     suppliers: {
     suppliers: {
       label: "Поставщики",
       label: "Поставщики",
       none: "Без поставщика",
       none: "Без поставщика",

+ 21 - 0
frontend/src/i18n/locales/sv.ts

@@ -5259,6 +5259,27 @@ errors: {
     lastDriedNow: 'Nu',
     lastDriedNow: 'Nu',
     lastDriedHelp: 'Fylls i när en AMS-torkning som pågått minst halva sin tid avslutas. Ange det här för en rulle som torkats någon annanstans.',
     lastDriedHelp: 'Fylls i när en AMS-torkning som pågått minst halva sin tid avslutas. Ange det här för en rulle som torkats någon annanstans.',
     materialNumberNone: 'Inget materialnummer',
     materialNumberNone: 'Inget materialnummer',
+    profileDefaults: {
+      title: 'Profilstandarder',
+      description: 'Det nya spolar av en produkt börjar med: slicer-preset, preset per skrivarmodell och K-profiler. Spara en från en spoles flik Skrivare med Spara som standard; att spara igen ersätter den.',
+      empty: 'Inga profilstandarder ännu. Ställ in en spoles flik Skrivare och kryssa i Spara som standard.',
+      allColours: 'Alla färger',
+      colourOnly: 'Endast {{colour}}',
+      colourOnlyNeedsColour: 'Endast denna färg: spolen har inget färgnamn. Lägg till ett under {{tab}} för att spara en standard för bara den här färgen.',
+      scope: 'Gäller',
+      saveAsDefault: 'Spara som standard för nya {{product}}-spolar',
+      saveNeedsMaterial: 'Spara som standard (välj ett material först)',
+      prefilled: 'Ifyllt från profilstandarden för {{product}}',
+      saveFailed: 'Spolen sparades, men profilstandarden kunde inte sparas',
+      apply: 'Tillämpa på befintliga spolar',
+      applyHelp: 'Fyller i det som aktiva spolar av den här produkten saknar. Inget de redan har ändras.',
+      applied_one: 'Ifyllt på {{count}} spole',
+      applied_other: 'Ifyllt på {{count}} spolar',
+      applyFailed: 'Kunde inte tillämpa profilstandarden',
+      deleteTitle: 'Ta bort profilstandard',
+      deleteMessage: 'Ta bort profilstandarden för {{product}}? Spolar som redan har dess profiler behåller dem.',
+      deleteFailed: 'Kunde inte ta bort profilstandarden',
+    },
     suppliers: {
     suppliers: {
       label: 'Leverantörer',
       label: 'Leverantörer',
       none: 'Ingen leverantör',
       none: 'Ingen leverantör',

+ 21 - 0
frontend/src/i18n/locales/tr.ts

@@ -5208,6 +5208,27 @@ export default {
     lastDriedNow: 'Şimdi',
     lastDriedNow: 'Şimdi',
     lastDriedHelp: 'Süresinin en az yarısı kadar çalışan bir AMS kurutması bittiğinde doldurulur. Başka yerde kurutulan bir makara için buradan ayarlayın.',
     lastDriedHelp: 'Süresinin en az yarısı kadar çalışan bir AMS kurutması bittiğinde doldurulur. Başka yerde kurutulan bir makara için buradan ayarlayın.',
     materialNumberNone: 'Malzeme numarası yok',
     materialNumberNone: 'Malzeme numarası yok',
+    profileDefaults: {
+      title: 'Profil varsayılanları',
+      description: 'Bir ürünün yeni makaralarının başladığı ayarlar: dilimleyici preseti, yazıcı modeline göre presetler ve K profilleri. Bir makaranın Yazıcılar sekmesinden Varsayılan olarak kaydet ile kaydedin; yeniden kaydetmek onu değiştirir.',
+      empty: 'Henüz profil varsayılanı yok. Bir makaranın Yazıcılar sekmesini ayarlayın ve Varsayılan olarak kaydet seçeneğini işaretleyin.',
+      allColours: 'Tüm renkler',
+      colourOnly: 'Yalnızca {{colour}}',
+      colourOnlyNeedsColour: 'Yalnızca bu renk: bu makaranın renk adı yok. Yalnızca bu renk için varsayılan kaydetmek için {{tab}} sekmesinde bir ad ekleyin.',
+      scope: 'Uygulandığı yer',
+      saveAsDefault: 'Yeni {{product}} makaraları için varsayılan olarak kaydet',
+      saveNeedsMaterial: 'Varsayılan olarak kaydet (önce bir malzeme seçin)',
+      prefilled: '{{product}} profil varsayılanından dolduruldu',
+      saveFailed: 'Makara kaydedildi ancak profil varsayılanı kaydedilemedi',
+      apply: 'Mevcut makaralara uygula',
+      applyHelp: 'Bu ürünün aktif makaralarında eksik olanı doldurur. Zaten sahip oldukları hiçbir şey değişmez.',
+      applied_one: '{{count}} makarada dolduruldu',
+      applied_other: '{{count}} makarada dolduruldu',
+      applyFailed: 'Profil varsayılanı uygulanamadı',
+      deleteTitle: 'Profil varsayılanını sil',
+      deleteMessage: '{{product}} için profil varsayılanı silinsin mi? Profillerine zaten sahip makaralar onları korur.',
+      deleteFailed: 'Profil varsayılanı silinemedi',
+    },
     suppliers: {
     suppliers: {
       label: 'Tedarikçiler',
       label: 'Tedarikçiler',
       none: 'Tedarikçi yok',
       none: 'Tedarikçi yok',

+ 21 - 0
frontend/src/i18n/locales/uk.ts

@@ -5256,6 +5256,27 @@ export default {
     lastDriedNow: 'Зараз',
     lastDriedNow: 'Зараз',
     lastDriedHelp: 'Заповнюється, коли сушіння в AMS завершується, пропрацювавши щонайменше половину заданого часу. Для котушки, висушеної деінде, вкажіть тут.',
     lastDriedHelp: 'Заповнюється, коли сушіння в AMS завершується, пропрацювавши щонайменше половину заданого часу. Для котушки, висушеної деінде, вкажіть тут.',
     materialNumberNone: 'Без номера матеріалу',
     materialNumberNone: 'Без номера матеріалу',
+    profileDefaults: {
+      title: "Профілі за замовчуванням",
+      description: "З чим починають нові котушки продукту: пресет слайсера, пресети за моделлю принтера та K-профілі. Збережіть його на вкладці Принтери котушки через Зберегти за замовчуванням; повторне збереження замінює його.",
+      empty: "Профілів за замовчуванням ще немає. Налаштуйте вкладку Принтери котушки та позначте Зберегти за замовчуванням.",
+      allColours: "Усі кольори",
+      colourOnly: "Лише {{colour}}",
+      colourOnlyNeedsColour: "Лише цей колір: у котушки немає назви кольору. Додайте її на вкладці {{tab}}, щоб зберегти типове значення лише для цього кольору.",
+      scope: "Застосовується до",
+      saveAsDefault: "Зберегти за замовчуванням для нових котушок {{product}}",
+      saveNeedsMaterial: "Зберегти за замовчуванням (спершу вкажіть матеріал)",
+      prefilled: "Заповнено з профілю за замовчуванням для {{product}}",
+      saveFailed: "Котушку збережено, але профіль за замовчуванням зберегти не вдалося",
+      apply: "Застосувати до наявних котушок",
+      applyHelp: "Доповнює те, чого ще немає в активних котушках цього продукту. Те, що в них уже є, не змінюється.",
+      applied_one: "Доповнено котушок: {{count}}",
+      applied_other: "Доповнено котушок: {{count}}",
+      applyFailed: "Не вдалося застосувати профіль за замовчуванням",
+      deleteTitle: "Видалити профіль за замовчуванням",
+      deleteMessage: "Видалити профіль за замовчуванням для {{product}}? Котушки, які вже мають його профілі, збережуть їх.",
+      deleteFailed: "Не вдалося видалити профіль за замовчуванням",
+    },
     suppliers: {
     suppliers: {
       label: "Постачальники",
       label: "Постачальники",
       none: "Без постачальника",
       none: "Без постачальника",

+ 21 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5207,6 +5207,27 @@ export default {
     lastDriedNow: '现在',
     lastDriedNow: '现在',
     lastDriedHelp: 'AMS 烘干运行至少一半时长后结束时自动填写。在其他地方烘干的料盘请在此设置。',
     lastDriedHelp: 'AMS 烘干运行至少一半时长后结束时自动填写。在其他地方烘干的料盘请在此设置。',
     materialNumberNone: '无物料号',
     materialNumberNone: '无物料号',
+    profileDefaults: {
+      title: '配置文件默认值',
+      description: '某产品的新料盘一开始就带有的设置:切片预设、按打印机型号的预设和 K 配置文件。在料盘的打印机选项卡中通过“保存为默认值”保存;再次保存会替换它。',
+      empty: '还没有配置文件默认值。设置料盘的打印机选项卡并勾选“保存为默认值”。',
+      allColours: '所有颜色',
+      colourOnly: '仅 {{colour}}',
+      colourOnlyNeedsColour: '仅此颜色:此料盘没有颜色名称。在“{{tab}}”中添加一个,即可仅为此颜色保存默认值。',
+      scope: '适用于',
+      saveAsDefault: '保存为新 {{product}} 料盘的默认值',
+      saveNeedsMaterial: '保存为默认值(请先设置材料)',
+      prefilled: '已从 {{product}} 的配置文件默认值填入',
+      saveFailed: '料盘已保存,但无法保存配置文件默认值',
+      apply: '应用到现有料盘',
+      applyHelp: '为该产品的在用料盘补上尚缺的设置。已有的设置不会更改。',
+      applied_one: '已为 {{count}} 个料盘填入',
+      applied_other: '已为 {{count}} 个料盘填入',
+      applyFailed: '无法应用配置文件默认值',
+      deleteTitle: '删除配置文件默认值',
+      deleteMessage: '删除 {{product}} 的配置文件默认值?已有其配置文件的料盘会保留它们。',
+      deleteFailed: '无法删除配置文件默认值',
+    },
     suppliers: {
     suppliers: {
       label: '供应商',
       label: '供应商',
       none: '无供应商',
       none: '无供应商',

+ 21 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5207,6 +5207,27 @@ export default {
     lastDriedNow: '現在',
     lastDriedNow: '現在',
     lastDriedHelp: 'AMS 烘乾執行至少一半時長後結束時自動填寫。在其他地方烘乾的料盤請在此設定。',
     lastDriedHelp: 'AMS 烘乾執行至少一半時長後結束時自動填寫。在其他地方烘乾的料盤請在此設定。',
     materialNumberNone: '無料號',
     materialNumberNone: '無料號',
+    profileDefaults: {
+      title: '設定檔預設值',
+      description: '某產品的新料盤一開始就帶有的設定:切片預設、依印表機型號的預設與 K 設定檔。在料盤的印表機分頁中以「儲存為預設值」儲存;再次儲存會取代它。',
+      empty: '還沒有設定檔預設值。設定料盤的印表機分頁並勾選「儲存為預設值」。',
+      allColours: '所有顏色',
+      colourOnly: '僅 {{colour}}',
+      colourOnlyNeedsColour: '僅此顏色:此料盤沒有顏色名稱。在「{{tab}}」中新增一個,即可僅為此顏色儲存預設值。',
+      scope: '適用於',
+      saveAsDefault: '儲存為新 {{product}} 料盤的預設值',
+      saveNeedsMaterial: '儲存為預設值(請先設定材料)',
+      prefilled: '已從 {{product}} 的設定檔預設值填入',
+      saveFailed: '料盤已儲存,但無法儲存設定檔預設值',
+      apply: '套用到現有料盤',
+      applyHelp: '為此產品的使用中料盤補上尚缺的設定。已有的設定不會變更。',
+      applied_one: '已為 {{count}} 個料盤填入',
+      applied_other: '已為 {{count}} 個料盤填入',
+      applyFailed: '無法套用設定檔預設值',
+      deleteTitle: '刪除設定檔預設值',
+      deleteMessage: '刪除 {{product}} 的設定檔預設值?已有其設定檔的料盤會保留它們。',
+      deleteFailed: '無法刪除設定檔預設值',
+    },
     suppliers: {
     suppliers: {
       label: '供應商',
       label: '供應商',
       none: '無供應商',
       none: '無供應商',

+ 3 - 0
frontend/src/pages/SettingsPage.tsx

@@ -44,6 +44,7 @@ import { LdapUserPicker } from '../components/LdapUserPicker';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
 import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
 import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
 import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
+import { ProfileDefaultsSettings } from '../components/ProfileDefaultsSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
 import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
@@ -103,6 +104,7 @@ registerSettingsSearch({ labelKey: 'settings.queueDrying', tab: 'queue', keyword
 registerSettingsSearch({ labelKey: 'settings.filamentChecks', tab: 'filament', keywords: 'filament check warning runout remaining', anchor: 'card-filamentchecks' });
 registerSettingsSearch({ labelKey: 'settings.filamentChecks', tab: 'filament', keywords: 'filament check warning runout remaining', anchor: 'card-filamentchecks' });
 registerSettingsSearch({ labelKey: 'settings.printModal', tab: 'filament', keywords: 'print modal custom mapping', anchor: 'card-printmodal' });
 registerSettingsSearch({ labelKey: 'settings.printModal', tab: 'filament', keywords: 'print modal custom mapping', anchor: 'card-printmodal' });
 registerSettingsSearch({ labelKey: 'settings.amsDisplayThresholds', tab: 'filament', keywords: 'ams humidity temperature threshold history retention', anchor: 'card-amsthresholds' });
 registerSettingsSearch({ labelKey: 'settings.amsDisplayThresholds', tab: 'filament', keywords: 'ams humidity temperature threshold history retention', anchor: 'card-amsthresholds' });
+registerSettingsSearch({ labelKey: 'inventory.profileDefaults.title', tab: 'filament', keywords: 'profile default k-profile k factor pressure advance slicer preset new spool', anchor: 'card-profiledefaults' });
 registerSettingsSearch({ labelKey: 'settings.externalUrl', tab: 'network', keywords: 'external url reverse proxy public notification link', anchor: 'card-externalurl' });
 registerSettingsSearch({ labelKey: 'settings.externalUrl', tab: 'network', keywords: 'external url reverse proxy public notification link', anchor: 'card-externalurl' });
 registerSettingsSearch({ labelKey: 'settings.ftpRetry', tab: 'network', keywords: 'ftp retry upload retries backoff', anchor: 'card-ftpretry' });
 registerSettingsSearch({ labelKey: 'settings.ftpRetry', tab: 'network', keywords: 'ftp retry upload retries backoff', anchor: 'card-ftpretry' });
 registerSettingsSearch({ labelKey: 'settings.homeAssistant', tab: 'network', keywords: 'home assistant ha hass mqtt integration', anchor: 'card-ha' });
 registerSettingsSearch({ labelKey: 'settings.homeAssistant', tab: 'network', keywords: 'home assistant ha hass mqtt integration', anchor: 'card-ha' });
@@ -6588,6 +6590,7 @@ export function SettingsPage() {
 
 
           {/* Right Column (2/3) - Spool Catalog + Color Catalog */}
           {/* Right Column (2/3) - Spool Catalog + Color Catalog */}
           <div className="lg:w-2/3 space-y-3">
           <div className="lg:w-2/3 space-y-3">
+            <ProfileDefaultsSettings />
             <SpoolCatalogSettings />
             <SpoolCatalogSettings />
             <ColorCatalogSettings />
             <ColorCatalogSettings />
           </div>
           </div>

+ 10 - 9
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -12,6 +12,7 @@ import {
   type LocalPreset,
   type LocalPreset,
   type SlicerSetting,
   type SlicerSetting,
   type SpoolCatalogEntry,
   type SpoolCatalogEntry,
+  type SpoolKProfileInput,
 } from '../../api/client';
 } from '../../api/client';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
@@ -51,6 +52,7 @@ import {
   parsePresetName,
   parsePresetName,
   saveRecentColor,
   saveRecentColor,
   withCurrentValue,
   withCurrentValue,
+  withProductDefaultKProfiles,
 } from '../../components/spool-form/utils';
 } from '../../components/spool-form/utils';
 import { MATERIALS } from '../../components/spool-form/constants';
 import { MATERIALS } from '../../components/spool-form/constants';
 import { NumberInput } from '../../components/NumberInput';
 import { NumberInput } from '../../components/NumberInput';
@@ -714,16 +716,12 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
 
 
   const saveKProfiles = async (spoolId: number) => {
   const saveKProfiles = async (spoolId: number) => {
     const save = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
     const save = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
-    if (selectedProfiles.size === 0) {
-      try {
-        await save(spoolId, []);
-      } catch {
-        // ignore
-      }
-      return;
-    }
+    // Only ever a spool just created: nothing picked leaves it with what the
+    // create gave it, the product's profile default (#1326). Saving the empty
+    // list would clear that.
+    if (selectedProfiles.size === 0) return;
 
 
-    const profiles = [];
+    let profiles: SpoolKProfileInput[] = [];
     for (const key of selectedProfiles) {
     for (const key of selectedProfiles) {
       const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
       const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
       const printerId = parseInt(printerIdStr);
       const printerId = parseInt(printerIdStr);
@@ -754,6 +752,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
     }
     }
 
 
     if (profiles.length > 0) {
     if (profiles.length > 0) {
+      // The save replaces the spool's whole list, and this page never shows
+      // the product's profile default the create applied (#1326).
+      profiles = await withProductDefaultKProfiles(profiles, formData);
       await save(spoolId, profiles);
       await save(spoolId, profiles);
     }
     }
   };
   };

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AdditionalSection-BGtsYm0C.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AmsUnitCard-D51Ij2kp.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ArchivesPage-BEEA2uU6.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AreaChart-B_iFta1W.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AssignToAmsModal-BrRlD3On.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AuthContext-CtlY1WKC.js


+ 1 - 1
static/assets/Button-C_-d0X5W.js → static/assets/Button-Bj3LhBE2.js

@@ -1 +1 @@
-import{w as e}from"./client-DSw2TdH4.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-Wixdakxn.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CamWallPage-DzGmZDoh.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CameraDiagnoseModal-wS3Ojtq8.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CameraPage-CXkSzxWy.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CameraWall-DQWH0EjZ.js


+ 1 - 1
static/assets/Card-BT_YWvIC.js → static/assets/Card-FgTRnwUz.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CartesianChart-D5b3folV.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ColumnConfigModal-DYsBO8W3.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConfigureAmsSlotModal-B1n2MyhW.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConfirmModal-Cswi0gVe.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConfirmOutcomeDialog-DPsFuoiC.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConnectAuthorizePage-CE5y-GnU.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConnectionDiagnostic-DSIiTSOB.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ContextMenu-T2eJpTAg.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CopyButton-BgtLjU7h.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/EmbeddedCameraViewer-DcRG4MV9.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ErrorBarContext-CyYEO29m.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ExternalLinkPage-BF6ZqoR1.js


+ 1 - 1
static/assets/FilamentSwatch-CElzyuMD.js → static/assets/FilamentSwatch-CAdwRRk_.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/FileManagerPage-VPvItkly.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/FileUploadModal-C5EmGbLJ.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/FinancePage-CJ5NIKQb.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/GCodeViewerPage-b8U_fpv-.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/GcodeToolpathViewer-Bem88qte.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/GroupEditPage-9qgwBVAv.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/IconPicker-CEohXxLZ.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ImagePreviewModal-BLwogUZv.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/InventoryPage-CcAMJzgy.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/InventoryPage-DYzz0NU6.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/Layout-BpeAYG7Z.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/LibraryTrashPage-DfsdLcnh.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/LoginPage-DlDkNbTh.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/MaintenancePage-nb-T6m-M.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ModelSourcesPage-B-_VQh36.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/NotificationsPage-C-wvgDo_.js


+ 1 - 1
static/assets/NumberInput-BhsGuNcX.js → static/assets/NumberInput-VlZYDJ-L.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-CPrBopi9.js → static/assets/OverlayFrame-CshZQHBk.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-DSw2TdH4.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-Wixdakxn.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PdfPreviewModal-DcTJTry4.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PhotoGalleryModal-BJsSxerb.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PrintModal-Bs3aSIR5.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PrinterLocationsPage-DvcxU12H.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PrintersPage-DTfjL5O3.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ProfilesPage-CJK7CLAE.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ProjectDetailPage-BVvYuBA3.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ProjectsPage-BpxjkLGA.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/QueryClientProvider-DEK0HFZS.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/QueuePage-WURT4IjU.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ReferenceLine-UYgJeF5T.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/RichTextEditor-BqK8UORc.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SettingsPage-CptQ63cW.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SettingsPage-DjxLFpCF.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SetupPage-PKyonBaG.js


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است