4 Incheckningar 5afb485c12 ... 2e30470dfb

Upphovsman SHA1 Meddelande Datum
  maziggy 2e30470dfb fix(spoolman): Cost/kg in the inventory and on the SpoolBuddy spool card shows the rate prints are charged, falling back to the filament price when the spool has none (or 0) 1 vecka sedan
  maziggy fcc20cb62c fix(spoolman): AMS syncs write only the slots whose spool they changed and keep a spool assigned meanwhile; the Sync buttons announce slot changes and update the slot preset; a new Spoolman spool is not seeded from leftover built-in assignments 1 vecka sedan
  maziggy c2bc41a5ef fix(spoolman): a location delete is refused while Spoolman can't be read; labels print for archived spools; archived spools are not assigned or linked to slots; slot requests outside the AMS ranges are refused before Spoolman is written; bulk counter resets report the spools that failed 1 vecka sedan
  maziggy 19bd258672 fix(backup): a Git backup includes each spool's "Total Consumed" reset point and a restore brings it back (an older backup keeps the live one) 1 vecka sedan
43 ändrade filer med 1078 tillägg och 155 borttagningar
  1. 12 0
      CHANGELOG.md
  2. 14 0
      backend/app/api/routes/_spoolman_helpers.py
  3. 14 3
      backend/app/api/routes/inventory.py
  4. 4 5
      backend/app/api/routes/labels.py
  5. 83 67
      backend/app/api/routes/spoolman.py
  6. 14 7
      backend/app/api/routes/spoolman_inventory.py
  7. 18 46
      backend/app/main.py
  8. 17 1
      backend/app/schemas/spoolman.py
  9. 2 0
      backend/app/services/github_backup.py
  10. 5 0
      backend/app/services/github_restore.py
  11. 141 0
      backend/app/services/spoolman_slot_sync.py
  12. 28 0
      backend/tests/integration/test_labels.py
  13. 48 0
      backend/tests/integration/test_locations_api.py
  14. 14 1
      backend/tests/integration/test_spool_reset_usage.py
  15. 7 4
      backend/tests/integration/test_spoolman_ams_sync_broadcast.py
  16. 4 0
      backend/tests/integration/test_spoolman_api.py
  17. 31 1
      backend/tests/integration/test_spoolman_inventory_api.py
  18. 88 0
      backend/tests/integration/test_spoolman_slot_requests.py
  19. 248 0
      backend/tests/integration/test_spoolman_slot_sync_writes.py
  20. 12 0
      backend/tests/unit/test_github_backup_spool_fields.py
  21. 39 0
      backend/tests/unit/test_github_restore.py
  22. 15 0
      backend/tests/unit/test_spoolman_inventory_helpers.py
  23. 18 0
      frontend/src/__tests__/components/InventorySpoolInfoCard.test.tsx
  24. 97 2
      frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx
  25. 5 2
      frontend/src/api/client.ts
  26. 5 2
      frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx
  27. 3 0
      frontend/src/i18n/locales/de.ts
  28. 3 0
      frontend/src/i18n/locales/en.ts
  29. 3 0
      frontend/src/i18n/locales/es.ts
  30. 3 0
      frontend/src/i18n/locales/fr.ts
  31. 3 0
      frontend/src/i18n/locales/it.ts
  32. 3 0
      frontend/src/i18n/locales/ja.ts
  33. 3 0
      frontend/src/i18n/locales/ko.ts
  34. 3 0
      frontend/src/i18n/locales/nl.ts
  35. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  36. 3 0
      frontend/src/i18n/locales/ru.ts
  37. 3 0
      frontend/src/i18n/locales/sv.ts
  38. 3 0
      frontend/src/i18n/locales/tr.ts
  39. 3 0
      frontend/src/i18n/locales/uk.ts
  40. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  41. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  42. 32 14
      frontend/src/pages/InventoryPage.tsx
  43. 18 0
      frontend/src/utils/spoolCost.ts

+ 12 - 0
CHANGELOG.md

@@ -164,6 +164,18 @@ 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.
 
 ### Fixed
+- **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it.
+- **Spoolman AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases:
+  - **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read.
+  - **The Sync buttons in Spoolman settings** changed slots without telling other open pages, and without updating the slot's filament preset, so the printer card could keep the previous spool's preset name. They now do both, as the automatic sync does.
+  - **The Sync buttons** gave a newly created Spoolman spool the remaining weight of a built-in inventory spool last assigned to that slot before the switch to Spoolman, when the printer reported no weight of its own. That leftover weight is no longer used, as in the automatic sync.
+- **Spoolman inventory: five requests refused or reported correctly** —
+  - **Deleting a storage location while Spoolman was unreachable** counted its spools from the built-in inventory, found none, and deleted a location Spoolman spools still used. The delete is now refused until Spoolman answers.
+  - **Printing labels for an archived Spoolman spool** failed the whole batch with "not found", as Spoolman leaves archived spools out of its plain spool list.
+  - **An archived Spoolman spool could be assigned to an AMS slot** (or put on one by a tag link) and then charged the next print's usage. It is now refused, as in the built-in inventory.
+  - **A slot assignment or tag link for an AMS number no printer has** failed with a server error, and the link had already written the tag to Spoolman. Such a request is now refused before anything is written.
+  - **Resetting the "Total Consumed" counters** reported success even when some or all of them failed. The result now says how many failed, as the other bulk actions do.
+- **A Git backup restore keeps "Reset Total Consumed"** — The backup did not include the point each spool's "Total Consumed" counter was reset at, so a restore brought every reset counter back to its full lifetime figure. It is now backed up and restored; a backup made before this keeps the reset points the spools already have.
 - **A finished print's filament is charged once** — Two cases in the built-in inventory:
   - **An AMS update right as a print finished** could raise the spool's used weight from the AMS remain% while the print's exact usage was still being worked out, and that usage was then added on top: the print was charged twice. The AMS sync now waits until the print is charged, as it does while the print runs.
   - **A print the printer reported finished without any file name** (one that failed while preparing, right after Bambuddy started) kept the AMS weight sync off until the next print started. It now turns the sync back on.

+ 14 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -59,6 +59,10 @@ class MappedSpoolFields(TypedDict):
     created_at: str | None  # None when Spoolman spool has no registered timestamp
     updated_at: str | None
     cost_per_kg: float | None
+    # What prints are charged per kg: the spool's own price, else its
+    # filament's catalogue price (SM-C8). Display only; the form edits
+    # cost_per_kg, so the catalogue price is never written onto the spool.
+    effective_cost_per_kg: float | None
     # Spoolman's native filament.article_number, surfaced as the internal
     # material number (#2870). Read-only in Spoolman mode — the number is
     # filament-level there and maintained in Spoolman itself.
@@ -240,6 +244,15 @@ def spoolman_cost_per_kg(spool: dict) -> float | None:
     return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
 
 
+def _charged_cost_per_kg(spool: dict) -> float | None:
+    """The per-kg rate a print off this spool is charged at, from the same
+    function the charge uses, so the Inventory shows what prints cost."""
+    from backend.app.services.spoolman_tracking import _spool_cost_per_gram
+
+    per_gram = _spool_cost_per_gram(spool)
+    return round(per_gram * 1000.0, 4) if per_gram is not None else None
+
+
 def _extract_extra_str(extra: dict, key: str) -> str:
     """Extract a JSON-encoded string from a Spoolman extra dict.
 
@@ -539,6 +552,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "updated_at": created_at,
         # Spoolman's spool price is what the whole spool cost (#3194).
         "cost_per_kg": spoolman_cost_per_kg(spool),
+        "effective_cost_per_kg": _charged_cost_per_kg(spool),
         # Spoolman's filament.article_number maps 1:1 onto the internal
         # material number (#2870): both identify the purchasable product.
         # Trimmed for the same reason the schema validator trims the internal

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

@@ -641,14 +641,24 @@ async def _spool_counts_for_locations(
     db: AsyncSession,
     locations: list[Location],
     settings: dict[str, str],
+    *,
+    strict: bool = False,
 ) -> dict[int, int]:
+    """Spools at each location. In Spoolman mode they are Spoolman's; when it
+    can't be read the built-in counts are shown instead, which ``strict``
+    refuses with a 503 (SM-C13): the built-in table holds no Spoolman spools,
+    so a location still in use would count as empty and could be deleted."""
     if _spoolman_is_enabled(settings):
         client = await _ensure_spoolman_client(settings)
+        if not client and strict:
+            raise HTTPException(status_code=503, detail="Spoolman is unavailable")
         if client:
             try:
                 spools = await client.get_all_spools(allow_archived=False)
-            except Exception:
+            except Exception as exc:
                 logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
+                if strict:
+                    raise HTTPException(status_code=503, detail="Spoolman is unavailable") from exc
             else:
                 # Use the canonical key helper so this matches what the
                 # migration backfill, Location.name_key, and every other
@@ -791,7 +801,7 @@ async def delete_location(
         raise HTTPException(status_code=404, detail="Location not found")
 
     settings = await _load_settings_map(db)
-    counts = await _spool_counts_for_locations(db, [location], settings)
+    counts = await _spool_counts_for_locations(db, [location], settings, strict=True)
     if counts.get(location.id, 0) > 0:
         raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
 
@@ -1820,7 +1830,8 @@ async def bulk_reset_spool_consumed_counter(
         spool.weight_used_baseline = spool.weight_used or 0
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
-    return {"reset": len(spools)}
+    found = {spool.id for spool in spools}
+    return {"reset": len(spools), "not_found": [sid for sid in spool_ids if sid not in found]}
 
 
 class BulkUpdateRequest(BaseModel):

+ 4 - 5
backend/app/api/routes/labels.py

@@ -268,10 +268,9 @@ async def _local_label_data(spool_ids: list[int], request: Request, db: AsyncSes
 
 
 async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
-    """The Spoolman client doesn't expose a per-id endpoint, so this fetches the
-    full spool list and filters in-memory. For typical libraries (~50 spools)
-    that's negligible; for very large libraries this is the trade-off until
-    Spoolman gains a bulk filter.
+    """Fetches the full spool list once and filters in-memory, rather than one
+    request per id. Archived spools included: Spoolman leaves them out of its
+    plain list, and the Inventory page can show and select them (SM-C12).
     """
     spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
     if not spoolman_on:
@@ -282,7 +281,7 @@ async def _spoolman_label_data(spool_ids: list[int], request: Request, db: Async
         raise HTTPException(503, "Spoolman not reachable")
 
     try:
-        all_spools = await client.get_spools()
+        all_spools = await client.get_all_spools(allow_archived=True)
     except Exception as exc:
         logger.warning("Spoolman fetch failed during label render: %s", exc)
         raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc

+ 83 - 67
backend/app/api/routes/spoolman.py

@@ -26,8 +26,10 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
 from backend.app.services import slot_unlink_grace
 from backend.app.services.ams_slot_presence import spool_present
+from backend.app.services.inventory_mode import spoolman_owns_assignments
 from backend.app.services.printer_manager import PrinterManager, printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
@@ -40,6 +42,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     init_spoolman_client,
 )
+from backend.app.services.spoolman_slot_sync import write_slot_results
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -232,6 +235,24 @@ def _empty_slot_may_be_unlinked(state, tray_data: dict) -> bool:
     return spool_present(tray_data) is not True
 
 
+async def _refresh_slot_preset(db: AsyncSession, spool: dict, tray, printer_id: int, ams_id: int) -> None:
+    """The slot's preset row follows the spool synced into it, as in the
+    automatic pass: the slot card reads that row ahead of the live tray, so it
+    kept the previous spool's preset name (SSA-2)."""
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spoolman_spool
+
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db,
+        spoolman_spool=spool,
+        tray_info_idx=tray.tray_info_idx or "",
+        tray_sub_brands=tray.tray_sub_brands or "",
+        tray_type=tray.tray_type or "",
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray.tray_id,
+    )
+
+
 @router.post("/sync/{printer_id}", response_model=SyncResult)
 async def sync_printer_ams(
     printer_id: int,
@@ -326,30 +347,36 @@ async def sync_printer_ams(
             detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
         )
 
-    # Load inventory weights as fallback (when AMS MQTT data lacks remain values)
+    # Load inventory weights as fallback (when AMS MQTT data lacks remain values).
+    # Inert in Spoolman mode, as in the automatic pass (main.on_ams_change): the
+    # built-in slot rows are left over from before the switch, and a new Spoolman
+    # spool would be seeded with an unrelated weight (SM-C3).
     inv_weights: dict[tuple[int, int], float] = {}
-    try:
-        assign_result = await db.execute(
-            select(SpoolAssignment)
-            .options(selectinload(SpoolAssignment.spool))
-            .where(SpoolAssignment.printer_id == printer_id)
-        )
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(
+                select(SpoolAssignment)
+                .options(selectinload(SpoolAssignment.spool))
+                .where(SpoolAssignment.printer_id == printer_id)
+            )
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
     # Load existing Spoolman slot assignments for the no-RFID fallback path
     spoolman_slot_map: dict[tuple[int, int], int] = {}
+    slot_map_read = False
     try:
         slot_result = await db.execute(
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
         )
         for slot in slot_result.scalars().all():
             spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        slot_map_read = True
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
 
@@ -404,6 +431,7 @@ async def sync_printer_ams(
                         if not spool_exists:
                             cached_spools.append(sync_result)
                             logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                        await _refresh_slot_preset(db, sync_result, tray, printer_id, ams_id)
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
@@ -432,29 +460,18 @@ async def sync_printer_ams(
                 logger.error(error_msg)
                 errors.append(error_msg)
 
-    # Persist slot assignment changes to the local table
+    # Persist slot assignment changes to the local table: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if slot_changes or empty_slots:
         try:
-            for ams_id, tray_id, spool_id in slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for ams_id, tray_id in empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == printer_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(
+                db,
+                {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
+                if slot_map_read
+                else None,
+                [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
+                [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
+            )
         except Exception as e:
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
@@ -521,24 +538,28 @@ async def sync_all_printers(
 
     # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
     # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
+    # Inert in Spoolman mode, as in sync_printer_ams above (SM-C3).
     inventory_weights: dict[tuple[int, int, int], float] = {}
-    try:
-        assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
-        for assignment in assign_result.scalars().all():
-            spool = assignment.spool
-            if spool and spool.label_weight > 0:
-                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
-                inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
-    except Exception as e:
-        logger.debug("Could not load inventory assignments for weight fallback: %s", e)
+    if not await spoolman_owns_assignments(db):
+        try:
+            assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+            for assignment in assign_result.scalars().all():
+                spool = assignment.spool
+                if spool and spool.label_weight > 0:
+                    remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
+                    inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
+        except Exception as e:
+            logger.debug("Could not load inventory assignments for weight fallback: %s", e)
 
     # Load all Spoolman slot assignments for the no-RFID fallback
     # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
     all_slot_map: dict[tuple[int, int, int], int] = {}
+    all_slot_map_read = False
     try:
         slot_result = await db.execute(select(SpoolmanSlotAssignment))
         for slot in slot_result.scalars().all():
             all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+        all_slot_map_read = True
     except Exception as e:
         logger.warning("Could not load Spoolman slot assignments: %s", e)
 
@@ -624,6 +645,7 @@ async def sync_all_printers(
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                            await _refresh_slot_preset(db, sync_result, tray, printer.id, ams_id)
                     elif spool_tag and not auto_add_unknown_rfid:
                         all_skipped.append(
                             SkippedSpool(
@@ -647,29 +669,11 @@ async def sync_all_printers(
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
-    # Persist slot assignment changes across all printers
+    # Persist slot assignment changes across all printers: only what changed,
+    # and only where the slot still holds what was read (SSA-3).
     if all_slot_changes or all_empty_slots:
         try:
-            for p_id, ams_id, tray_id, spool_id in all_slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for p_id, ams_id, tray_id in all_empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == p_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            await write_slot_results(db, all_slot_map if all_slot_map_read else None, all_slot_changes, all_empty_slots)
         except Exception as e:
             await db.rollback()
             logger.error("Error persisting Spoolman slot assignments: %s", e)
@@ -850,8 +854,8 @@ class LinkSpoolRequest(BaseModel):
     tray_uuid: str | None = None
     tag_uid: str | None = None
     printer_id: int | None = None
-    ams_id: int | None = None
-    tray_id: int | None = None
+    ams_id: SlotAmsId | None = None
+    tray_id: SlotTrayId | None = None
 
 
 @router.post("/spools/{spool_id}/link")
@@ -903,6 +907,18 @@ async def link_spool(
         if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
         printer_context = (request.printer_id, request.ams_id, request.tray_id)
+        # An archived spool on a slot would be charged the next print's usage;
+        # refused before anything is written, as the slot assignment does (SSA-8).
+        try:
+            linked = await client.get_spool(spool_id)
+        except SpoolmanNotFoundError:
+            raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
+        except SpoolmanClientError:
+            raise HTTPException(status_code=502, detail="Spoolman rejected the request")
+        except SpoolmanUnavailableError:
+            raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+        if linked.get("archived"):
+            raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
     try:
         # Spoolman 0.27+: a tray UUID also becomes a native tag of the spool. The

+ 14 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -53,7 +53,7 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
-from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import slot_unlink_grace
 from backend.app.services.location_service import (
@@ -456,9 +456,8 @@ class SpoolTagLinkRequest(BaseModel):
 class SpoolSlotAssignmentRequest(BaseModel):
     spoolman_spool_id: int = Field(..., gt=0)
     printer_id: int = Field(..., gt=0)
-    # ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
-    ams_id: int = Field(..., ge=0, le=255)
-    tray_id: int = Field(..., ge=0, le=3)
+    ams_id: SlotAmsId
+    tray_id: SlotTrayId
 
 
 # ---------------------------------------------------------------------------
@@ -1280,8 +1279,10 @@ async def bulk_reset_spool_consumed_counter(
 
     Caller passes an explicit list of IDs — no "reset all" shortcut, since
     a typo on a wildcard would wipe the entire inventory's tracking.
-    Returns the count of spools successfully reset; individual failures are
-    logged but do not abort the batch.
+    Returns the count of spools reset and, like the other bulk actions, the
+    ones Spoolman refused in ``errors``: an individual failure does not abort
+    the batch, and a batch where every reset failed is not reported as a
+    success (FO-C8).
     """
     spool_ids = payload.get("spool_ids")
     if not isinstance(spool_ids, list) or not spool_ids:
@@ -1291,6 +1292,7 @@ async def bulk_reset_spool_consumed_counter(
 
     client = await _get_client(db)
     reset_count = 0
+    errors: list[dict] = []
     for spool_id in spool_ids:
         try:
             async with _translate_spoolman_errors():
@@ -1298,9 +1300,10 @@ async def bulk_reset_spool_consumed_counter(
             reset_count += 1
         except HTTPException as exc:
             logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
+            errors.append({"id": spool_id, "status": exc.status_code, "detail": exc.detail})
     if reset_count:
         await ws_manager.broadcast({"type": "inventory_changed"})
-    return {"reset": reset_count}
+    return {"reset": reset_count, "errors": errors}
 
 
 @router.patch("/spools/{spool_id}/weight")
@@ -1788,6 +1791,10 @@ async def assign_spoolman_slot(
     # This prevents ghost rows pointing at non-existent spool IDs.
     async with _translate_spoolman_errors():
         spool = await client.get_spool(body.spoolman_spool_id)
+    # As the built-in inventory refuses it: an archived spool on a slot would
+    # be charged the next print's usage (SSA-8).
+    if spool.get("archived"):
+        raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
     # Spool confirmed in Spoolman — upsert into local slot-assignment table
     # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)

+ 18 - 46
backend/app/main.py

@@ -3242,12 +3242,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
 
             # Load existing Spoolman slot assignments for the no-RFID fallback path
             spoolman_slot_map: dict[tuple[int, int], int] = {}
+            slot_map_read = False
             try:
                 slot_result = await db.execute(
                     select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
                 )
                 for slot in slot_result.scalars().all():
                     spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+                slot_map_read = True
             except Exception as e:
                 logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
 
@@ -3392,57 +3394,27 @@ async def on_ams_change(printer_id: int, ams_data: list):
             if synced > 0:
                 logger.info("Auto-synced %s AMS trays to Spoolman for printer %s", synced, printer_id)
 
-            # Persist slot assignment changes to the local table
+            # Persist slot assignment changes to the local table: only slots
+            # whose spool this pass changed, and only while they still hold what
+            # it read before awaiting Spoolman, so a spool assigned by hand in
+            # between stays (SSA-3). The changed slots are announced: the slot
+            # card reads slot_preset_mappings (rewritten above) ahead of the live
+            # tray, so with no event it kept the previous spool's preset name.
             if slot_changes or empty_slots:
+                from backend.app.services.spoolman_slot_sync import write_slot_results
+
                 try:
-                    for ams_id, tray_id, spool_id in slot_changes:
-                        await db.execute(
-                            text(
-                                "INSERT INTO spoolman_slot_assignments"
-                                " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                                " ON CONFLICT(printer_id, ams_id, tray_id)"
-                                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                            ),
-                            {
-                                "printer_id": printer_id,
-                                "ams_id": ams_id,
-                                "tray_id": tray_id,
-                                "spool_id": spool_id,
-                            },
-                        )
-                    for ams_id, tray_id in empty_slots:
-                        await db.execute(
-                            delete(SpoolmanSlotAssignment).where(
-                                SpoolmanSlotAssignment.printer_id == printer_id,
-                                SpoolmanSlotAssignment.ams_id == ams_id,
-                                SpoolmanSlotAssignment.tray_id == tray_id,
-                            )
-                        )
-                    await db.commit()
+                    await write_slot_results(
+                        db,
+                        {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
+                        if slot_map_read
+                        else None,
+                        [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
+                        [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
+                    )
                 except Exception as e:
                     await db.rollback()
                     logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
-                else:
-                    # Tell open browsers the slot changed. This loop rewrites
-                    # slot_preset_mappings via upsert_slot_preset_for_spoolman_spool
-                    # above, and the AMS slot card reads that row ahead of the
-                    # live tray_info_idx -- so with no event the card keeps
-                    # showing the previous spool's preset name. Internal mode
-                    # raises spool_auto_assigned for the same reason; this loop
-                    # broadcast nothing at all, which made Spoolman mode the
-                    # worse half of the same bug. On the else branch so a
-                    # failed commit stays silent and a broadcast failure cannot
-                    # roll back rows that are already committed.
-                    for ams_id, tray_id, *_ in (*slot_changes, *empty_slots):
-                        await ws_manager.broadcast(
-                            {
-                                "type": "spool_assignment_changed",
-                                "printer_id": printer_id,
-                                "ams_id": ams_id,
-                                "tray_id": tray_id,
-                            }
-                        )
 
     except Exception as e:
         logging.getLogger(__name__).error("Spoolman AMS sync failed for printer %s: %s", printer_id, e)

+ 17 - 1
backend/app/schemas/spoolman.py

@@ -1,4 +1,20 @@
-from pydantic import BaseModel, Field, model_validator
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field, model_validator
+
+
+def _a_slot_ams_id(ams_id: int) -> int:
+    """The ams_ids spoolman_slot_assignments' CHECK admits: AMS units 0-7,
+    AMS-HT 128-191, the external slot 255. Checked on the request, so a write
+    to Spoolman that goes with the slot row is never made for a row the
+    database then refuses (SSA-9)."""
+    if 0 <= ams_id <= 7 or 128 <= ams_id <= 191 or ams_id == 255:
+        return ams_id
+    raise ValueError("ams_id must be 0-7 (AMS), 128-191 (AMS-HT) or 255 (external)")
+
+
+SlotAmsId = Annotated[int, AfterValidator(_a_slot_ams_id)]
+SlotTrayId = Annotated[int, Field(ge=0, le=3)]
 
 
 class SpoolmanFilamentPatch(BaseModel):

+ 2 - 0
backend/app/services/github_backup.py

@@ -793,6 +793,8 @@ class GitHubBackupService:
                 "label_weight": s.label_weight,
                 "core_weight": s.core_weight,
                 "weight_used": s.weight_used,
+                # The "Reset Total Consumed" point (WU-7).
+                "weight_used_baseline": s.weight_used_baseline,
                 "weight_locked": s.weight_locked,
                 "slicer_filament": s.slicer_filament,
                 "slicer_filament_name": s.slicer_filament_name,

+ 5 - 0
backend/app/services/github_restore.py

@@ -1684,6 +1684,11 @@ class GitHubRestoreService:
             for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
                 if late_field in entry:
                     fields[late_field] = entry[late_field]
+            # The "Reset Total Consumed" point (WU-7), same late-field rule; a
+            # value that is no amount of filament keeps what the row holds.
+            baseline = _restored_number(entry.get("weight_used_baseline"))
+            if baseline is not None:
+                fields["weight_used_baseline"] = baseline
             # Last drying (#2863), same late-field rule.
             if "last_dried_at" in entry:
                 fields["last_dried_at"] = _parse_dt(entry.get("last_dried_at"))

+ 141 - 0
backend/app/services/spoolman_slot_sync.py

@@ -0,0 +1,141 @@
+"""Writing a Spoolman AMS sync's results to the local slot table.
+
+Shared by the automatic pass in ``on_ams_change`` and the Sync buttons in
+Settings. A sync reads the slot table, then awaits Spoolman for every tray, so
+by the time it writes, a slot may hold a spool the user assigned meanwhile.
+Writing every synced slot back, the unchanged ones included, overwrote that
+assignment with what the sync had read (SSA-3). Only a slot whose spool the
+sync changed is written, and only while it still holds what the sync read.
+
+What it did change is announced, its grace hold dropped, as a hand assignment
+does; the Sync buttons did neither (SSA-2).
+"""
+
+import logging
+from collections.abc import Iterable, Mapping
+from typing import cast
+
+from sqlalchemy import delete, text, update
+from sqlalchemy.engine import CursorResult
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.websocket import ws_manager
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services import slot_unlink_grace
+
+logger = logging.getLogger(__name__)
+
+Slot = tuple[int, int, int]  # (printer_id, ams_id, tray_id)
+
+
+async def write_slot_results(
+    db: AsyncSession,
+    read: Mapping[Slot, int] | None,
+    synced: Iterable[tuple[int, int, int, int]],
+    emptied: Iterable[Slot],
+) -> list[Slot]:
+    """Write the slots the sync changed and commit; returns them.
+
+    ``read`` is the slot table as the sync read it, ``synced`` the
+    (printer_id, ams_id, tray_id, spoolman_spool_id) it found in the slots, and
+    ``emptied`` the slots it found empty. Raises on a database error, as the
+    callers report it.
+
+    ``read`` None: the table could not be read, so there is nothing to compare
+    against, and every slot is written as it was before the compare existed. A
+    swap and a removal must not be dropped for want of a snapshot.
+    """
+    if read is None:
+        return await _write_unconditionally(db, synced, emptied)
+    changed: list[Slot] = []
+    assigned: list[Slot] = []
+    for printer_id, ams_id, tray_id, spool_id in synced:
+        slot = (printer_id, ams_id, tray_id)
+        before = read.get(slot)
+        if before == spool_id:
+            continue
+        where = (
+            SpoolmanSlotAssignment.printer_id == printer_id,
+            SpoolmanSlotAssignment.ams_id == ams_id,
+            SpoolmanSlotAssignment.tray_id == tray_id,
+        )
+        if before is None:
+            statement = text(
+                "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                " ON CONFLICT(printer_id, ams_id, tray_id) DO NOTHING"
+            ).bindparams(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spool_id=spool_id)
+        else:
+            statement = (
+                update(SpoolmanSlotAssignment)
+                .where(*where, SpoolmanSlotAssignment.spoolman_spool_id == before)
+                .values(spoolman_spool_id=spool_id)
+                .execution_options(synchronize_session=False)
+            )
+        if cast(CursorResult, await db.execute(statement)).rowcount:
+            changed.append(slot)
+            assigned.append(slot)
+    for slot in emptied:
+        # A slot the sync read without a spool matches no row below: there is
+        # nothing of the sync's own to remove.
+        before = read.get(slot)
+        printer_id, ams_id, tray_id = slot
+        result = cast(
+            CursorResult,
+            await db.execute(
+                delete(SpoolmanSlotAssignment)
+                .where(
+                    SpoolmanSlotAssignment.printer_id == printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                    SpoolmanSlotAssignment.spoolman_spool_id == before,
+                )
+                .execution_options(synchronize_session=False)
+            ),
+        )
+        if result.rowcount:
+            changed.append(slot)
+    await db.commit()
+    await _announce(assigned, changed)
+    return changed
+
+
+async def _write_unconditionally(
+    db: AsyncSession, synced: Iterable[tuple[int, int, int, int]], emptied: Iterable[Slot]
+) -> list[Slot]:
+    assigned: list[Slot] = []
+    for printer_id, ams_id, tray_id, spool_id in synced:
+        await db.execute(
+            text(
+                "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                " ON CONFLICT(printer_id, ams_id, tray_id)"
+                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+            ).bindparams(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spool_id=spool_id)
+        )
+        assigned.append((printer_id, ams_id, tray_id))
+    removed: list[Slot] = []
+    for printer_id, ams_id, tray_id in emptied:
+        await db.execute(
+            delete(SpoolmanSlotAssignment)
+            .where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == ams_id,
+                SpoolmanSlotAssignment.tray_id == tray_id,
+            )
+            .execution_options(synchronize_session=False)
+        )
+        removed.append((printer_id, ams_id, tray_id))
+    await db.commit()
+    changed = assigned + removed
+    await _announce(assigned, changed)
+    return changed
+
+
+async def _announce(assigned: list[Slot], changed: list[Slot]) -> None:
+    for printer_id, ams_id, tray_id in assigned:
+        slot_unlink_grace.forget_slot(printer_id, ams_id, tray_id)
+    for printer_id, ams_id, tray_id in changed:
+        await ws_manager.broadcast(
+            {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
+        )

+ 28 - 0
backend/tests/integration/test_labels.py

@@ -216,6 +216,7 @@ class TestSpoolmanLabels:
         mock_client = MagicMock()
         mock_client.is_connected = True
         mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
+        mock_client.get_all_spools = AsyncMock(return_value=[spoolman_spool])
 
         with patch(
             "backend.app.api.routes.labels.get_spoolman_client",
@@ -243,6 +244,7 @@ class TestSpoolmanLabels:
         mock_client = MagicMock()
         mock_client.is_connected = True
         mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
+        mock_client.get_all_spools = mock_client.get_spools
 
         with patch(
             "backend.app.api.routes.labels.get_spoolman_client",
@@ -255,6 +257,31 @@ class TestSpoolmanLabels:
         assert resp.status_code == 404
         assert "99" in resp.text
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_renders_an_archived_spool(self, async_client: AsyncClient, db_session: AsyncSession):
+        """SM-C12: Spoolman leaves archived spools out of its plain list, so the
+        whole batch failed with a 404 when one was selected."""
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+        active = {"id": 1, "filament": {"name": "X", "material": "PLA"}}
+        archived = {"id": 2, "archived": True, "filament": {"name": "Y", "material": "PETG"}}
+        mock_client = MagicMock()
+        mock_client.is_connected = True
+        mock_client.get_spools = AsyncMock(return_value=[active])
+        mock_client.get_all_spools = AsyncMock(
+            side_effect=lambda allow_archived=False: [active, archived] if allow_archived else [active]
+        )
+
+        with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=mock_client)):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels", json={"spool_ids": [1, 2], "template": "box_62x29"}
+            )
+
+        assert resp.status_code == 200, resp.text
+
 
 # ── Validation cross-cutting ─────────────────────────────────────────────────
 
@@ -308,6 +335,7 @@ def _spoolman_client(spools: list[dict]) -> MagicMock:
     client = MagicMock()
     client.is_connected = True
     client.get_spools = AsyncMock(return_value=spools)
+    client.get_all_spools = AsyncMock(return_value=spools)
     return client
 
 

+ 48 - 0
backend/tests/integration/test_locations_api.py

@@ -187,3 +187,51 @@ async def test_locations_routes_require_auth_when_enabled(async_client: AsyncCli
     # DELETE /locations/{id} — write-gated
     delete_resp = await async_client.delete("/api/v1/inventory/locations/99999")
     assert delete_resp.status_code == 401, delete_resp.text
+
+
+async def _spoolman_mode(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("spoolman", ["unreachable", "no client"])
+async def test_spoolman_mode_refuses_a_delete_it_cannot_count_for(async_client: AsyncClient, db_session, spoolman):
+    """SM-C13: the count fell back to the built-in table, which holds no
+    Spoolman spools, so a location Spoolman spools still use was deleted."""
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    await _spoolman_mode(db_session)
+    loc = (await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})).json()
+    client = MagicMock()
+    client.get_all_spools = AsyncMock(side_effect=ConnectionError("refused"))
+
+    with patch(
+        "backend.app.api.routes.inventory._ensure_spoolman_client",
+        AsyncMock(return_value=client if spoolman == "unreachable" else None),
+    ):
+        resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
+
+    assert resp.status_code == 503, resp.text
+    names = [row["name"] for row in (await async_client.get("/api/v1/inventory/locations")).json()]
+    assert "Shelf A" in names
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spoolman_mode_deletes_a_location_no_spool_uses(async_client: AsyncClient, db_session):
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    await _spoolman_mode(db_session)
+    loc = (await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})).json()
+    client = MagicMock()
+    client.get_all_spools = AsyncMock(return_value=[{"id": 1, "location": "Shelf B"}])
+
+    with patch("backend.app.api.routes.inventory._ensure_spoolman_client", AsyncMock(return_value=client)):
+        resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
+
+    assert resp.status_code == 200, resp.text

+ 14 - 1
backend/tests/integration/test_spool_reset_usage.py

@@ -159,7 +159,7 @@ class TestBulkResetSpoolUsage:
         )
 
         assert response.status_code == 200
-        assert response.json() == {"reset": 2}
+        assert response.json() == {"reset": 2, "not_found": []}
 
         # The endpoint commits via its own session — expire our session so the
         # next read pulls fresh values rather than the cached pre-reset state.
@@ -173,6 +173,19 @@ class TestBulkResetSpoolUsage:
         assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
         assert by_id[untouched.id].weight_used_baseline == 0, "Untouched baseline must stay at 0"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
+        """As the other bulk actions do, so the page can tell a partial reset (FO-C8)."""
+        spool = await spool_factory(weight_used=100.0)
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-consumed-counter-bulk",
+            json={"spool_ids": [spool.id, 99999]},
+        )
+
+        assert response.json() == {"reset": 1, "not_found": [99999]}
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):

+ 7 - 4
backend/tests/integration/test_spoolman_ams_sync_broadcast.py

@@ -48,12 +48,12 @@ def _tray(ams_id: int, tray_id: int):
     return tray
 
 
-async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
+async def _run_ams_change(printer_id: int, ams_data: list, *, parsed, sync=None):
     """Drive ``on_ams_change`` with Spoolman standing in for the real server.
 
     ``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
     which is the only thing that decides whether the sync treats the slot as
-    occupied or cleared.
+    occupied or cleared. ``sync`` stands in for ``sync_ams_tray``.
     """
     from backend.app.main import on_ams_change
 
@@ -63,14 +63,17 @@ async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
     spoolman.parse_ams_tray = MagicMock(
         side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
     )
-    spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
+    spoolman.sync_ams_tray = sync or AsyncMock(return_value={"id": 4242})
 
     status = _status(ams_data)
+    ws = MagicMock()
     with (
         patch("backend.app.main.printer_manager") as pm_main,
         patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
         patch("backend.app.main.mqtt_relay") as relay,
-        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.ws_manager", ws),
+        # The slot writes are announced by the shared writer.
+        patch("backend.app.services.spoolman_slot_sync.ws_manager", ws),
         patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
         patch(
             "backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",

+ 4 - 0
backend/tests/integration/test_spoolman_api.py

@@ -488,6 +488,8 @@ class TestSpoolmanAPI:
         mock_spoolman_client.merge_spool_extra = AsyncMock(
             return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
         )
+        # A link onto a slot checks the spool is not archived first.
+        mock_spoolman_client.get_spool = AsyncMock(return_value={"id": 5, "archived": False, "extra": {}})
         printer = await printer_factory()
 
         response = await async_client.post(
@@ -607,6 +609,8 @@ class TestSpoolmanAPI:
         mock_spoolman_client.merge_spool_extra = AsyncMock(
             return_value={"id": 7, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
         )
+        # A link onto a slot checks the spool is not archived first.
+        mock_spoolman_client.get_spool = AsyncMock(return_value={"id": 7, "archived": False, "extra": {}})
         printer = await printer_factory()
 
         # First link to create the slot assignment

+ 31 - 1
backend/tests/integration/test_spoolman_inventory_api.py

@@ -709,9 +709,39 @@ class TestSpoolmanInventoryCRUD:
         )
 
         assert response.status_code == 200
-        assert response.json() == {"reset": 3}
+        assert response.json() == {"reset": 3, "errors": []}
         assert mock_spoolman_client.reset_spool_consumed_counter.call_count == 3
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_reports_the_spools_it_could_not_reset(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """FO-C8: every failure was swallowed into {"reset": 0}, which the page
+        showed as a green "Counter reset for 0 spool(s)"."""
+        from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
+
+        def reset(spool_id):
+            if spool_id == 2:
+                raise SpoolmanNotFoundError("gone")
+            if spool_id == 3:
+                raise SpoolmanUnavailableError("down")
+
+        mock_spoolman_client.reset_spool_consumed_counter = AsyncMock(side_effect=reset)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk",
+            json={"spool_ids": [1, 2, 3]},
+        )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["reset"] == 1
+        assert [(e["id"], e["status"]) for e in body["errors"]] == [(2, 404), (3, 503)]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_reset_rejects_empty_list(

+ 88 - 0
backend/tests/integration/test_spoolman_slot_requests.py

@@ -0,0 +1,88 @@
+"""Spoolman slot requests the database would refuse are refused up front (SSA-8, SSA-9).
+
+An ams_id the table's CHECK does not admit (it allows AMS units 0-7, AMS-HT
+128-191 and the external slot 255) came back as a 500. The link route also
+wrote the spool's tag to Spoolman before the local insert failed, leaving a
+half-applied link. And an archived Spoolman spool could be assigned to a slot,
+then charged the next print's usage; the built-in inventory refuses that.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.tests.integration.test_spoolman_assignment_verification import (  # noqa: F401
+    SPOOL,
+    _spoolman_client,
+    printer_id,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _assign(async_client, printer_id, spool=SPOOL, **slot):
+    client = _spoolman_client()
+    client.get_spool = AsyncMock(return_value=spool)
+    with (
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)),
+        patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
+    ):
+        pm.get_client = MagicMock(return_value=None)
+        pm.get_status = MagicMock(return_value=None)
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments",
+            json={"spoolman_spool_id": 10, "printer_id": printer_id, **{"ams_id": 0, "tray_id": 0, **slot}},
+        )
+    return response
+
+
+@pytest.mark.parametrize("ams_id", [8, 50, 127, 192, 254])
+async def test_an_ams_id_no_slot_has_is_refused(async_client: AsyncClient, printer_id, ams_id):
+    assert (await _assign(async_client, printer_id, ams_id=ams_id)).status_code == 422
+
+
+@pytest.mark.parametrize("ams_id", [0, 7, 128, 191, 255])
+async def test_every_real_slot_is_accepted(async_client: AsyncClient, printer_id, ams_id):
+    response = await _assign(async_client, printer_id, ams_id=ams_id)
+
+    assert response.status_code == 200, response.text
+
+
+async def test_an_archived_spool_is_refused(async_client: AsyncClient, printer_id):
+    response = await _assign(async_client, printer_id, spool={**SPOOL, "archived": True})
+
+    assert response.status_code == 400, response.text
+
+
+@pytest.mark.parametrize(("ams_id", "tray_id"), [(50, 0), (0, 4)])
+async def test_a_link_to_no_real_slot_writes_nothing_to_spoolman(
+    async_client: AsyncClient, printer_id, ams_id, tray_id
+):
+    client = _spoolman_client()
+    with patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/10/link",
+            json={
+                "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                "printer_id": printer_id,
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+            },
+        )
+
+    assert response.status_code == 422, response.text
+    client.merge_spool_extra.assert_not_awaited()
+
+
+async def test_a_link_puts_no_archived_spool_on_a_slot(async_client: AsyncClient, printer_id):
+    client = _spoolman_client()
+    client.get_spool = AsyncMock(return_value={**SPOOL, "archived": True})
+    with patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/10/link",
+            json={"spool_tag": "A1B2C3D4E5F6A1B2", "printer_id": printer_id, "ams_id": 0, "tray_id": 0},
+        )
+
+    assert response.status_code == 400, response.text
+    client.merge_spool_extra.assert_not_awaited()

+ 248 - 0
backend/tests/integration/test_spoolman_slot_sync_writes.py

@@ -0,0 +1,248 @@
+"""What the Spoolman AMS syncs write to the slot table, and announce (SM-C3, SSA-2, SSA-3).
+
+- The automatic pass read the slot table, then awaited Spoolman for every tray
+  and wrote every synced slot back, unchanged ones included. A spool assigned
+  by hand in between was overwritten with what the pass had read. It now writes
+  only slots whose spool it changed, and only while they still hold what it read.
+- The Sync buttons in Settings wrote slots without announcing them, refreshing
+  the slot's preset or clearing its grace hold, as the automatic pass does.
+- Both seeded a newly created spool's weight from the built-in inventory's
+  slot assignments, which in Spoolman mode are left over from before the switch.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, update
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.spoolman import AMSTray
+from backend.tests.integration.test_spoolman_ams_sync_broadcast import (
+    _enable_spoolman,
+    _run_ams_change,
+    _slot_events,
+    _tray,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _slots(db_session, printer_id) -> dict[tuple[int, int], int]:
+    rows = (
+        await db_session.execute(
+            select(SpoolmanSlotAssignment)
+            .where(SpoolmanSlotAssignment.printer_id == printer_id)
+            .execution_options(populate_existing=True)
+        )
+    ).scalars()
+    return {(row.ams_id, row.tray_id): row.spoolman_spool_id for row in rows}
+
+
+async def _printer_with_slot(db_session, printer_factory, spool_id: int = 7) -> int:
+    await _enable_spoolman(db_session)
+    printer = await printer_factory()
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=spool_id))
+    await db_session.commit()
+    return printer.id
+
+
+def _assigning_by_hand_meanwhile(db_session, printer_id, slot, spool_id, returns):
+    """sync_ams_tray, during which the user assigns *spool_id* to *slot*."""
+
+    async def sync(tray, *args, **kwargs):
+        await db_session.execute(
+            update(SpoolmanSlotAssignment)
+            .where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == slot[0],
+                SpoolmanSlotAssignment.tray_id == slot[1],
+            )
+            .values(spoolman_spool_id=spool_id)
+        )
+        await db_session.commit()
+        return returns
+
+    return AsyncMock(side_effect=sync)
+
+
+AMS = [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}]
+
+
+@pytest.mark.parametrize("synced", [7, 8], ids=["unchanged", "changed"])
+async def test_the_automatic_pass_keeps_a_spool_assigned_meanwhile(
+    async_client: AsyncClient, db_session, printer_factory, synced
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+    sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": synced})
+
+    broadcast, _client = await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=sync)
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 9}
+    assert (0, 0) not in _slot_events(broadcast), "nothing the pass wrote changed"
+
+
+async def test_the_automatic_pass_announces_no_slot_it_left_as_it_was(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    broadcast, _client = await _run_ams_change(
+        printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 7})
+    )
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 7}
+    assert (0, 0) not in _slot_events(broadcast)
+
+
+async def test_the_automatic_pass_still_writes_and_announces_what_it_changed(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    broadcast, _client = await _run_ams_change(
+        printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 8})
+    )
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 8}
+    assert _slot_events(broadcast) == {(0, 0)}
+
+
+# ── The Sync buttons ─────────────────────────────────────────────────────────
+
+PATHS = ["/api/v1/spoolman/sync/{printer_id}", "/api/v1/spoolman/sync-all"]
+
+
+def _parsed_tray(ams_id, tray_data):
+    return AMSTray(
+        ams_id=ams_id,
+        tray_id=int(tray_data.get("id", 0)),
+        tray_type="PLA",
+        tray_sub_brands="PLA Matte",
+        tray_color="042F56FF",
+        remain=-1,
+        tag_uid="7757EF0100000100",
+        tray_uuid="EB335968299543078925C71D83DA3864",
+        tray_info_idx="GFA01",
+        tray_weight=1000,
+    )
+
+
+async def _manual_sync(async_client, printer_id, path, synced: int, *, trays=None, sync=None):
+    trays = trays or [{"id": 0, "tray_type": "PLA"}]
+    state = SimpleNamespace(state="IDLE", raw_data={"ams": [{"id": 0, "tray": trays}]})
+    client = MagicMock()
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spools = AsyncMock(return_value=[])
+    # A tray without a type reads empty.
+    client.parse_ams_tray = MagicMock(
+        side_effect=lambda ams_id, tray: _parsed_tray(ams_id, tray) if tray.get("tray_type") else None
+    )
+    client.sync_ams_tray = sync or AsyncMock(return_value={"id": synced})
+    preset = AsyncMock()
+    with (
+        patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+        patch("backend.app.api.routes.spoolman.printer_manager") as pm,
+        patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws,
+        patch("backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool", preset),
+        patch("backend.app.services.slot_unlink_grace.forget_slot") as forget,
+    ):
+        ws.broadcast = AsyncMock()
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_all_statuses = MagicMock(return_value={printer_id: state})
+        response = await async_client.post(path.format(printer_id=printer_id))
+    assert response.status_code == 200, response.text
+    return client, ws.broadcast, preset, forget
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_announces_a_changed_slot(async_client: AsyncClient, db_session, printer_factory, path):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    _client, broadcast, preset, forget = await _manual_sync(async_client, printer_id, path, synced=8)
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 8}
+    assert _slot_events(broadcast) == {(0, 0)}
+    assert preset.await_args.kwargs["spoolman_spool"] == {"id": 8}
+    forget.assert_called_once_with(printer_id, 0, 0)
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_does_not_seed_a_spool_from_the_built_in_inventory(
+    async_client: AsyncClient, db_session, printer_factory, path
+):
+    """A slot assignment left from before the switch to Spoolman: 880 g of
+    1000 used, nothing to do with the spool now in the slot."""
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+    stale = Spool(material="PLA", label_weight=1000, weight_used=880)
+    db_session.add(stale)
+    await db_session.flush()
+    db_session.add(SpoolAssignment(spool_id=stale.id, printer_id=printer_id, ams_id=0, tray_id=0))
+    await db_session.commit()
+
+    client, *_ = await _manual_sync(async_client, printer_id, path, synced=8)
+
+    assert client.sync_ams_tray.await_args.kwargs.get("inventory_remaining") is None
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_keeps_a_slot_that_was_filled_meanwhile(
+    async_client: AsyncClient, db_session, printer_factory, path
+):
+    """Slot 0 read empty, so the sync would unlink spool 7; by then it holds 9."""
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+    sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": 5})
+
+    await _manual_sync(
+        async_client, printer_id, path, synced=5, trays=[{"id": 0}, {"id": 1, "tray_type": "PLA"}], sync=sync
+    )
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 9, (0, 1): 5}
+
+
+@pytest.mark.parametrize("path", PATHS)
+async def test_a_sync_button_still_unlinks_a_slot_that_reads_empty(
+    async_client: AsyncClient, db_session, printer_factory, path
+):
+    printer_id = await _printer_with_slot(db_session, printer_factory)
+
+    _client, broadcast, *_ = await _manual_sync(async_client, printer_id, path, synced=5, trays=[{"id": 0}])
+
+    assert await _slots(db_session, printer_id) == {}
+    assert _slot_events(broadcast) == {(0, 0)}
+
+
+async def test_the_automatic_pass_keeps_a_spool_assigned_to_a_slot_it_read_empty(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    await _enable_spoolman(db_session)
+    printer_id = (await printer_factory()).id
+
+    async def sync(tray, *args, **kwargs):
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=0, spoolman_spool_id=9))
+        await db_session.commit()
+        return {"id": 8}
+
+    await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(side_effect=sync))
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 9}
+
+
+async def test_without_the_slot_table_as_read_the_syncs_write_as_before(db_session, printer_factory):
+    """Its load failed: nothing to compare against, so a swap and a removal are
+    written as before rather than dropped."""
+    from backend.app.services.spoolman_slot_sync import write_slot_results
+
+    printer_id = await _printer_with_slot(db_session, printer_factory, spool_id=10)
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=1, spoolman_spool_id=11))
+    await db_session.commit()
+
+    with patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        changed = await write_slot_results(db_session, None, [(printer_id, 0, 0, 99)], [(printer_id, 0, 1)])
+
+    assert await _slots(db_session, printer_id) == {(0, 0): 99}
+    assert sorted(changed) == [(printer_id, 0, 0), (printer_id, 0, 1)]

+ 12 - 0
backend/tests/unit/test_github_backup_spool_fields.py

@@ -64,3 +64,15 @@ async def test_collects_the_drying_record(db_session):
     assert entry["last_dried_at"] == "2026-10-06 12:30:00"
     assert entry["last_dried_temp"] == 55
     assert entry["last_dried_hours"] == 7.5
+
+
+@pytest.mark.asyncio
+async def test_collects_the_total_consumed_reset_point(db_session):
+    """WU-7: without it, a restore lost every "Reset Total Consumed"."""
+    db_session.add(Spool(material="PLA", weight_used=500, weight_used_baseline=300))
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+
+    assert files["spools/inventory.json"]["spools"][0]["weight_used_baseline"] == 300

+ 39 - 0
backend/tests/unit/test_github_restore.py

@@ -964,6 +964,45 @@ class TestRestoreSpools:
         assert len(spools) == 1
         assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
 
+    @pytest.mark.asyncio
+    async def test_restores_the_total_consumed_reset_point(self, db_session):
+        """WU-7: a restore lost every "Reset Total Consumed"."""
+        await _service()._restore_spools(
+            db_session,
+            {"spools": [self._spool_entry(weight_used=500, weight_used_baseline=300)]},
+            None,
+            False,
+            _CategoryTally(),
+            {},
+        )
+        await db_session.commit()
+
+        assert (await db_session.execute(select(Spool))).scalar_one().weight_used_baseline == 300
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("backed_up", "expected"),
+        [({"weight_used_baseline": 300}, 300), ({}, 650), ({"weight_used_baseline": "300"}, 650)]
+        + [({"weight_used_baseline": bad}, 650) for bad in (-5, float("inf"), None, True)],
+    )
+    async def test_overwriting_a_spool_takes_the_backups_reset_point(self, db_session, backed_up, expected):
+        """Only a real amount: an older backup without one, or a damaged
+        value, keeps the live point."""
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", weight_used=700, weight_used_baseline=650))
+        await db_session.commit()
+
+        await _service()._restore_spools(
+            db_session,
+            {"spools": [self._spool_entry(weight_used=500, **backed_up)]},
+            None,
+            True,
+            _CategoryTally(),
+            {},
+        )
+        await db_session.commit()
+
+        assert (await db_session.execute(select(Spool))).scalar_one().weight_used_baseline == expected
+
     @pytest.mark.asyncio
     async def test_restores_the_owners_own_bookkeeping_fields(self, db_session):
         """#2870 + #729: purchasing number, category, threshold, storage."""

+ 15 - 0
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -192,6 +192,21 @@ class TestMapSpoolmanSpool:
         result = _map_spoolman_spool(spool)
         assert result["cost_per_kg"] is None
 
+    @pytest.mark.parametrize(
+        ("spool_price", "filament_price", "own", "charged"),
+        [(None, 24.0, None, 24.0), (30.0, 24.0, 30.0, 30.0), (None, None, None, None)],
+        ids=["filament-only", "spool-wins", "unpriced"],
+    )
+    def test_the_rate_prints_are_charged_at(self, spool_price, filament_price, own, charged):
+        """SM-C8: with only a filament price, Cost/kg read "-" although every
+        print was charged at it. cost_per_kg stays the spool's own, which the
+        edit form writes back."""
+        filament = {**MINIMAL_SPOOL["filament"], "weight": 1000, "price": filament_price}
+        result = _map_spoolman_spool({**MINIMAL_SPOOL, "price": spool_price, "filament": filament})
+
+        assert result["cost_per_kg"] == (pytest.approx(own) if own is not None else None)
+        assert result["effective_cost_per_kg"] == (pytest.approx(charged) if charged is not None else None)
+
     def test_infinity_weight_falls_back(self):
         spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}}
         result = _map_spoolman_spool(spool)

+ 18 - 0
frontend/src/__tests__/components/InventorySpoolInfoCard.test.tsx

@@ -119,4 +119,22 @@ describe('InventorySpoolInfoCard', () => {
     fireEvent.click(screen.getByText(/unassign/i));
     expect(onUnassign).toHaveBeenCalledTimes(1);
   });
+
+  // SM-C8: the SpoolBuddy card hid Cost/kg for a Spoolman spool priced only by
+  // its filament, which prints off it are charged.
+  it.each([
+    [{ cost_per_kg: null, effective_cost_per_kg: 24 }, '24.00/kg'],
+    [{ cost_per_kg: 0, effective_cost_per_kg: 24 }, '24.00/kg'],
+    [{ cost_per_kg: 30, effective_cost_per_kg: 30 }, '30.00/kg'],
+  ])('shows the rate prints are charged (%o)', (prices, shown) => {
+    render(
+      <InventorySpoolInfoCard
+        spool={{ ...mockSpool, ...prices }}
+        liveScaleWeight={null}
+        onAssignToAms={vi.fn()}
+        isAssigned={false}
+      />
+    );
+    expect(screen.getByText(shown)).toBeInTheDocument();
+  });
 });

+ 97 - 2
frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx

@@ -16,8 +16,9 @@
  *      the user can zero an archived spool's tracking counter without having
  *      to un-archive it first.
  */
-import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import InventoryPageRouter from '../../pages/InventoryPage';
 import { http, HttpResponse } from 'msw';
@@ -206,3 +207,97 @@ describe('InventoryPage — Total Consumed includes archived (#1390 follow-up)',
     });
   });
 });
+
+// FO-C8: a reset of every counter where some or all failed (Spoolman refusing
+// them, spools deleted meanwhile) was shown as a green "Counter reset for N".
+describe('InventoryPage — resetting all counters says what failed', () => {
+  const resetAll = async (response: Record<string, unknown>) => {
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json(mockSpools)),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
+      http.post('/api/v1/inventory/spools/reset-consumed-counter-bulk', () => HttpResponse.json(response)),
+    );
+    const user = userEvent.setup();
+    render(<InventoryPageRouter />);
+    await user.click(await screen.findByRole('button', { name: 'Reset all counters' }));
+    const modal = (await screen.findByText(/Reset the consumed-grams counter to 0 on all/)).closest('.fixed');
+    await user.click(within(modal as HTMLElement).getByRole('button', { name: 'Reset counter' }));
+  };
+
+  it('says so when none was reset', async () => {
+    await resetAll({ reset: 0, not_found: [1, 2] });
+
+    expect(await screen.findByText('No counter was reset: all 2 failed')).toBeInTheDocument();
+  });
+
+  it('says how many failed when some were', async () => {
+    await resetAll({ reset: 1, errors: [{ id: 2, status: 503, detail: 'down' }] });
+
+    expect(await screen.findByText('Counter reset for 1 spool(s), 1 failed')).toBeInTheDocument();
+  });
+
+  it('confirms a full reset as before', async () => {
+    await resetAll({ reset: 2, not_found: [] });
+
+    expect(await screen.findByText('Counter reset for 2 spool(s)')).toBeInTheDocument();
+  });
+});
+
+// SM-C8: a Spoolman spool priced only by its filament showed "-" in Cost/kg,
+// although every print off it was charged that price.
+describe('InventoryPage — Cost/kg shows what prints are charged', () => {
+  const show = async (spool: Record<string, unknown>) => {
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+      key === 'bambuddy-inventory-columns'
+        ? JSON.stringify([
+            { id: 'brand', label: 'Brand', visible: true },
+            { id: 'cost_per_kg', label: 'Cost/kg', visible: true },
+          ])
+        : null,
+    );
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json([{ ...mockSpools[0], ...spool }])),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
+    );
+    render(<InventoryPageRouter />);
+    await screen.findAllByText('Polymaker');
+  };
+
+  it("falls back to the filament's price, marked as such", async () => {
+    await show({ cost_per_kg: null, effective_cost_per_kg: 24 });
+
+    expect(await screen.findByText('$24.00')).toBeInTheDocument();
+    expect(screen.getByText('(filament price)')).toBeInTheDocument();
+  });
+
+  it('falls back past a spool price of 0, as the charge does', async () => {
+    // Spoolman imports often write 0 for "no price"; prints then pay the filament's.
+    await show({ cost_per_kg: 0, effective_cost_per_kg: 24 });
+
+    expect(await screen.findByText('$24.00')).toBeInTheDocument();
+    expect(screen.getByText('(filament price)')).toBeInTheDocument();
+  });
+
+  it('keeps a built-in spool priced 0 at $0.00', async () => {
+    await show({ cost_per_kg: 0 });
+
+    expect(await screen.findByText('$0.00')).toBeInTheDocument();
+  });
+
+  it("shows the spool's own price unmarked", async () => {
+    await show({ cost_per_kg: 30, effective_cost_per_kg: 30 });
+
+    expect(await screen.findByText('$30.00')).toBeInTheDocument();
+    expect(screen.queryByText('(filament price)')).toBeNull();
+  });
+
+  it('shows "-" for an unpriced spool', async () => {
+    await show({ cost_per_kg: null, effective_cost_per_kg: null });
+
+    await waitFor(() => expect(screen.queryByText(/^\$\d/)).toBeNull());
+  });
+});

+ 5 - 2
frontend/src/api/client.ts

@@ -3906,6 +3906,9 @@ export interface InventorySpool {
   created_at: string;
   updated_at: string;
   cost_per_kg: number | null;
+  // Spoolman mode: what prints are charged per kg, the spool's own price or
+  // else its filament's. Display only; the form edits cost_per_kg.
+  effective_cost_per_kg?: number | null;
   last_scale_weight: number | null;
   last_weighed_at: string | null;
   // User-defined category + per-spool low-stock threshold override (#729).
@@ -7014,7 +7017,7 @@ export const api = {
   resetSpoolConsumedCounter: (id: number) =>
     request<InventorySpool>(`/inventory/spools/${id}/reset-consumed-counter`, { method: 'POST' }),
   bulkResetSpoolConsumedCounter: (spoolIds: number[]) =>
-    request<{ reset: number }>(`/inventory/spools/reset-consumed-counter-bulk`, {
+    request<{ reset: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }>(`/inventory/spools/reset-consumed-counter-bulk`, {
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),
@@ -7250,7 +7253,7 @@ export const api = {
   resetSpoolmanInventorySpoolConsumedCounter: (id: number) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-consumed-counter`, { method: 'POST' }),
   bulkResetSpoolmanInventorySpoolConsumedCounter: (spoolIds: number[]) =>
-    request<{ reset: number }>(`/spoolman/inventory/spools/reset-consumed-counter-bulk`, {
+    request<{ reset: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/reset-consumed-counter-bulk`, {
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),

+ 5 - 2
frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx

@@ -6,6 +6,7 @@ import type { InventorySpool } from '../../api/client';
 import { spoolbuddyApi, api } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
 import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
+import { shownCostPerKg } from '../../utils/spoolCost';
 
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
 
@@ -112,6 +113,8 @@ export function InventorySpoolInfoCard({
   // Inventory fallback so gross is always populated across spools.
   const inventoryDerivedGrossWeight = Math.round(calculatedWeight);
   const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight;
+  // The rate prints off this spool are charged (SM-C8).
+  const costPerKg = shownCostPerKg(spool);
   const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null)
     ? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C`
     : null;
@@ -233,10 +236,10 @@ export function InventorySpoolInfoCard({
             <span className="font-mono text-zinc-300">{nozzleTempRange}</span>
           </div>
         )}
-        {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
+        {costPerKg.rate != null && costPerKg.rate > 0 && (
           <div className="flex justify-between items-center">
             <span className="text-zinc-500">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>
-            <span className="font-mono text-zinc-300">{spool.cost_per_kg.toFixed(2)}/kg</span>
+            <span className="font-mono text-zinc-300">{costPerKg.rate.toFixed(2)}/kg</span>
           </div>
         )}
         {kFactorSummary && (

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

@@ -5002,6 +5002,8 @@ export default {
       updateFailed: 'Sammelaktualisierung fehlgeschlagen',
       updatePartial: '{{ok}} Spulen aktualisiert, {{failed}} fehlgeschlagen',
       updateAllFailed: 'Alle {{count}} Aktualisierungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      resetPartial: 'Zähler für {{ok}} Spule(n) zurückgesetzt, {{failed}} fehlgeschlagen',
+      resetAllFailed: 'Kein Zähler zurückgesetzt: alle {{count}} fehlgeschlagen',
       deleteSuccess: '{{count}} Spulen gelöscht',
       deleteFailed: 'Sammellöschung fehlgeschlagen',
       deletePartial: '{{ok}} Spulen gelöscht, {{failed}} fehlgeschlagen',
@@ -5155,6 +5157,7 @@ export default {
     measuredWeight: 'Gemessenes Gewicht',
     spoolName: 'Spule',
     costPerKg: 'Kosten pro kg',
+    costFromFilament: '(Filamentpreis)',
     storageLocation: 'Lagerstandort',
     temperature: 'Temperatur',
     humidity: 'Luftfeuchtigkeit',

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

@@ -5040,6 +5040,8 @@ export default {
       updateFailed: 'Bulk update failed',
       updatePartial: '{{ok}} spools updated, {{failed}} failed',
       updateAllFailed: 'All {{count}} spool updates failed — selection kept so you can retry',
+      resetPartial: 'Counter reset for {{ok}} spool(s), {{failed}} failed',
+      resetAllFailed: 'No counter was reset: all {{count}} failed',
       deleteSuccess: '{{count}} spools deleted',
       deleteFailed: 'Bulk delete failed',
       deletePartial: '{{ok}} spools deleted, {{failed}} failed',
@@ -5193,6 +5195,7 @@ export default {
     measuredWeight: 'Measured Weight',
     spoolName: 'Spool',
     costPerKg: 'Cost per kg',
+    costFromFilament: '(filament price)',
     storageLocation: 'Storage Location',
     temperature: 'Temperature',
     humidity: 'Humidity',

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

@@ -5004,6 +5004,8 @@ export default {
       updateFailed: 'Actualización masiva fallida',
       updatePartial: '{{ok}} bobinas actualizadas, {{failed}} fallidas',
       updateAllFailed: 'Las {{count}} actualizaciones fallaron — la selección se mantiene para reintentar',
+      resetPartial: 'Contador restablecido en {{ok}} bobina(s), {{failed}} fallidas',
+      resetAllFailed: 'No se restableció ningún contador: las {{count}} fallaron',
       deleteSuccess: '{{count}} bobinas eliminadas',
       deleteFailed: 'Eliminación masiva fallida',
       deletePartial: '{{ok}} bobinas eliminadas, {{failed}} fallidas',
@@ -5157,6 +5159,7 @@ export default {
     measuredWeight: 'Peso medido',
     spoolName: 'Bobina',
     costPerKg: 'Coste por kg',
+    costFromFilament: '(precio del filamento)',
     storageLocation: 'Ubicación de almacenamiento',
     temperature: 'Temperatura',
     humidity: 'Humedad',

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

@@ -4990,6 +4990,8 @@ export default {
       updateFailed: 'Mise à jour groupée échouée',
       updatePartial: '{{ok}} bobines mises à jour, {{failed}} échouées',
       updateAllFailed: 'Les {{count}} mises à jour ont échoué — la sélection est conservée pour réessayer',
+      resetPartial: 'Compteur réinitialisé pour {{ok}} bobine(s), {{failed}} en échec',
+      resetAllFailed: 'Aucun compteur réinitialisé : les {{count}} ont échoué',
       deleteSuccess: '{{count}} bobines supprimées',
       deleteFailed: 'Suppression groupée échouée',
       deletePartial: '{{ok}} bobines supprimées, {{failed}} échouées',
@@ -5143,6 +5145,7 @@ export default {
     measuredWeight: 'Poids mesuré',
     spoolName: 'Bobine',
     costPerKg: 'Coût par kg',
+    costFromFilament: '(prix du filament)',
     storageLocation: 'Emplacement de stockage',
     temperature: 'Température',
     humidity: 'Humidité',

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: 'Aggiornamento in blocco fallito',
       updatePartial: '{{ok}} bobine aggiornate, {{failed}} fallite',
       updateAllFailed: 'Tutti i {{count}} aggiornamenti sono falliti — la selezione viene mantenuta per riprovare',
+      resetPartial: 'Contatore azzerato per {{ok}} bobina/e, {{failed}} non riuscite',
+      resetAllFailed: 'Nessun contatore azzerato: tutte le {{count}} operazioni sono fallite',
       deleteSuccess: '{{count}} bobine eliminate',
       deleteFailed: 'Eliminazione in blocco fallita',
       deletePartial: '{{ok}} bobine eliminate, {{failed}} fallite',
@@ -5142,6 +5144,7 @@ export default {
     measuredWeight: 'Peso Misurato',
     spoolName: 'Bobina',
     costPerKg: 'Costo per kg',
+    costFromFilament: '(prezzo del filamento)',
     storageLocation: 'Posizione di archiviazione',
     temperature: 'Temperatura',
     humidity: 'Umidità',

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

@@ -5002,6 +5002,8 @@ export default {
       updateFailed: '一括更新に失敗しました',
       updatePartial: '{{ok}} 件更新、{{failed}} 件失敗',
       updateAllFailed: '{{count}} 件すべての更新に失敗しました — 再試行のため選択は維持されます',
+      resetPartial: '{{ok}} 件のカウンターをリセット、{{failed}} 件失敗',
+      resetAllFailed: 'カウンターはリセットされませんでした: {{count}} 件すべて失敗',
       deleteSuccess: '{{count}} 件のスプールを削除しました',
       deleteFailed: '一括削除に失敗しました',
       deletePartial: '{{ok}} 件削除、{{failed}} 件失敗',
@@ -5155,6 +5157,7 @@ export default {
     measuredWeight: '計測重量',
     spoolName: 'スプール',
     costPerKg: 'kgあたりのコスト',
+    costFromFilament: '(フィラメント価格)',
     storageLocation: '保管場所',
     temperature: '温度',
     humidity: '湿度',

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

@@ -4790,6 +4790,8 @@ export default {
       updateFailed: '일괄 업데이트 실패',
       updatePartial: '{{ok}}개 업데이트, {{failed}}개 실패',
       updateAllFailed: '{{count}}개의 업데이트가 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      resetPartial: '{{ok}}개 카운터 초기화, {{failed}}개 실패',
+      resetAllFailed: '초기화된 카운터 없음: {{count}}개 모두 실패',
       deleteSuccess: '{{count}}개의 스풀 삭제됨',
       deleteFailed: '일괄 삭제 실패',
       deletePartial: '{{ok}}개 삭제, {{failed}}개 실패',
@@ -4943,6 +4945,7 @@ export default {
     measuredWeight: '측정된 무게',
     spoolName: '스풀',
     costPerKg: 'kg당 비용',
+    costFromFilament: '(필라멘트 가격)',
     storageLocation: '보관 위치',
     temperature: '온도',
     humidity: '습도',

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

@@ -5038,6 +5038,8 @@ export default {
       updateFailed: 'Bulkupdate mislukt',
       updatePartial: '{{ok}} spoelen bijgewerkt, {{failed}} mislukt',
       updateAllFailed: 'Alle {{count}} spoelupdates zijn mislukt — selectie blijft behouden zodat je opnieuw kunt proberen',
+      resetPartial: 'Teller gereset voor {{ok}} spoel(en), {{failed}} mislukt',
+      resetAllFailed: 'Geen teller gereset: alle {{count}} mislukt',
       deleteSuccess: '{{count}} spoelen verwijderd',
       deleteFailed: 'Bulkverwijdering mislukt',
       deletePartial: '{{ok}} spoelen verwijderd, {{failed}} mislukt',
@@ -5191,6 +5193,7 @@ export default {
     measuredWeight: 'Gemeten gewicht',
     spoolName: 'Spoel',
     costPerKg: 'Kosten per kg',
+    costFromFilament: '(filamentprijs)',
     storageLocation: 'Opslaglocatie',
     temperature: 'Temperatuur',
     humidity: 'Luchtvochtigheid',

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: 'Atualização em massa falhou',
       updatePartial: '{{ok}} carretéis atualizados, {{failed}} falharam',
       updateAllFailed: 'Todas as {{count}} atualizações falharam — a seleção é mantida para nova tentativa',
+      resetPartial: 'Contador zerado em {{ok}} carretel(is), {{failed}} falharam',
+      resetAllFailed: 'Nenhum contador foi zerado: todos os {{count}} falharam',
       deleteSuccess: '{{count}} carretéis excluídos',
       deleteFailed: 'Exclusão em massa falhou',
       deletePartial: '{{ok}} carretéis excluídos, {{failed}} falharam',
@@ -5142,6 +5144,7 @@ export default {
     measuredWeight: 'Peso Medido',
     spoolName: 'Bobina',
     costPerKg: 'Custo por kg',
+    costFromFilament: '(preço do filamento)',
     storageLocation: 'Local de armazenamento',
     temperature: 'Temperatura',
     humidity: 'Umidade',

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

@@ -4780,6 +4780,8 @@ export default {
       updateFailed: "Ошибка массового обновления",
       updatePartial: "Обновлено: {{ok}}, с ошибкой: {{failed}}",
       updateAllFailed: "Не удалось обновить все {{count}} катушек. Выделение сохранено для повторной попытки.",
+      resetPartial: "Счётчик сброшен: {{ok}}, с ошибкой: {{failed}}",
+      resetAllFailed: "Ни один счётчик не сброшен: все {{count}} с ошибкой",
       deleteSuccess: "Удалено катушек: {{count}}",
       deleteFailed: "Ошибка массового удаления",
       deletePartial: "Удалено: {{ok}}, с ошибкой: {{failed}}",
@@ -4933,6 +4935,7 @@ export default {
     measuredWeight: "Измеренная масса",
     spoolName: "Катушка",
     costPerKg: "Стоимость за кг",
+    costFromFilament: "(цена филамента)",
     storageLocation: "Место хранения",
     temperature: "Температура",
     humidity: "Влажность",

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

@@ -5037,6 +5037,8 @@ errors: {
       updateFailed: 'Kunde inte uppdatera',
       updatePartial: '{{ok}} rullar uppdaterade, {{failed}} misslyckades',
       updateAllFailed: 'Alla {{count}} rulluppdateringar misslyckades — val bevaras så att du kan försöka igen',
+      resetPartial: 'Räknaren nollställd för {{ok}} rulle/rullar, {{failed}} misslyckades',
+      resetAllFailed: 'Ingen räknare nollställdes: alla {{count}} misslyckades',
       deleteSuccess: '{{count}} rullar borttagna',
       deleteFailed: 'Kunde inte ta bort',
       deletePartial: '{{ok}} rullar borttagna, {{failed}} misslyckades',
@@ -5190,6 +5192,7 @@ errors: {
     measuredWeight: 'Uppmätt vikt',
     spoolName: 'Rulle',
     costPerKg: 'Kostnad per kg',
+    costFromFilament: '(filamentpris)',
     storageLocation: 'Lagringsplats',
     temperature: 'Temperatur',
     humidity: 'Luftfuktighet',

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

@@ -4991,6 +4991,8 @@ export default {
       updateFailed: 'Toplu güncelleme başarısız',
       updatePartial: '{{ok}} makara güncellendi, {{failed}} başarısız',
       updateAllFailed: '{{count}} güncellemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      resetPartial: '{{ok}} makaranın sayacı sıfırlandı, {{failed}} başarısız',
+      resetAllFailed: 'Hiçbir sayaç sıfırlanmadı: {{count}} işlemin tümü başarısız',
       deleteSuccess: '{{count}} makara silindi',
       deleteFailed: 'Toplu silme başarısız',
       deletePartial: '{{ok}} makara silindi, {{failed}} başarısız',
@@ -5144,6 +5146,7 @@ export default {
     measuredWeight: 'Ölçülen Ağırlık',
     spoolName: 'Makara',
     costPerKg: 'kg başına maliyet',
+    costFromFilament: '(filament fiyatı)',
     storageLocation: 'Depolama Konumu',
     temperature: 'Sıcaklık',
     humidity: 'Nem',

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

@@ -5034,6 +5034,8 @@ export default {
       updateFailed: "Помилка масового оновлення",
       updatePartial: "Оновлено: {{ok}}; не вдалося оновити: {{failed}}",
       updateAllFailed: "Не вдалося оновити жодну з вибраних котушок ({{count}}). Вибір збережено для повторної спроби.",
+      resetPartial: "Лічильник скинуто: {{ok}}; не вдалося: {{failed}}",
+      resetAllFailed: "Жоден лічильник не скинуто: усі {{count}} з помилкою",
       deleteSuccess: "Котушок видалено: {{count}}",
       deleteFailed: "Не вдалося масово видалити",
       deletePartial: "Видалено: {{ok}}; не вдалося видалити: {{failed}}",
@@ -5187,6 +5189,7 @@ export default {
     measuredWeight: "Виміряна вага",
     spoolName: "Котушка",
     costPerKg: "Вартість за кг",
+    costFromFilament: "(ціна філаменту)",
     storageLocation: "Місце зберігання",
     temperature: "Температура",
     humidity: "Вологість",

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: '批量更新失败',
       updatePartial: '已更新 {{ok}} 个,{{failed}} 个失败',
       updateAllFailed: '{{count}} 个料盘更新全部失败 — 已保留选择以便重试',
+      resetPartial: '已重置 {{ok}} 个料盘的计数器,{{failed}} 个失败',
+      resetAllFailed: '未重置任何计数器:{{count}} 个全部失败',
       deleteSuccess: '已删除 {{count}} 个料盘',
       deleteFailed: '批量删除失败',
       deletePartial: '已删除 {{ok}} 个,{{failed}} 个失败',
@@ -5142,6 +5144,7 @@ export default {
     measuredWeight: '称量重量',
     spoolName: '线轴',
     costPerKg: '每公斤成本',
+    costFromFilament: '(耗材价格)',
     storageLocation: '存放位置',
     temperature: '温度',
     humidity: '湿度',

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: '批次更新失敗',
       updatePartial: '已更新 {{ok}} 個,{{failed}} 個失敗',
       updateAllFailed: '{{count}} 個料盤更新全部失敗 — 已保留選擇以便重試',
+      resetPartial: '已重設 {{ok}} 個料盤的計數器,{{failed}} 個失敗',
+      resetAllFailed: '未重設任何計數器:{{count}} 個全部失敗',
       deleteSuccess: '已刪除 {{count}} 個料盤',
       deleteFailed: '批次刪除失敗',
       deletePartial: '已刪除 {{ok}} 個,{{failed}} 個失敗',
@@ -5142,6 +5144,7 @@ export default {
     measuredWeight: '稱量重量',
     spoolName: '料盤',
     costPerKg: '每公斤成本',
+    costFromFilament: '(耗材價格)',
     storageLocation: '存放位置',
     temperature: '溫度',
     humidity: '濕度',

+ 32 - 14
frontend/src/pages/InventoryPage.tsx

@@ -29,6 +29,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { colorSortKey, resolveSpoolColorName } from '../utils/colors';
 import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { getCurrencySymbol } from '../utils/currency';
+import { shownCostPerKg } from '../utils/spoolCost';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatSlotLabel } from '../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
@@ -487,11 +488,17 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     const entry = spool.core_weight_catalog_id != null ? catalogMap[spool.core_weight_catalog_id] : undefined;
     return <span className="text-sm text-bambu-gray">{entry?.name || '-'}</span>;
   },
-  cost_per_kg: ({ spool, currencySymbol }) => (
-    <span className="text-sm text-bambu-gray">
-      {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}
-    </span>
-  ),
+  cost_per_kg: ({ spool, currencySymbol, t }) => {
+    // A Spoolman spool without a price of its own is charged its filament's,
+    // shown here and marked as such (SM-C8).
+    const { rate, fromFilament } = shownCostPerKg(spool);
+    return (
+      <span className="text-sm text-bambu-gray">
+        {rate != null ? `${currencySymbol}${rate.toFixed(2)}` : '-'}
+        {fromFilament && <span className="ml-1 text-xs opacity-70">{t('inventory.costFromFilament')}</span>}
+      </span>
+    );
+  },
   weight_check: ({ spool, onSyncWeight }) => {
     const scaleWeight = spool.last_scale_weight;
     if (scaleWeight == null) return <span className="text-sm text-bambu-gray/50" title="No scale measurement">-</span>;
@@ -597,7 +604,7 @@ const columnSortValues: Record<
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   stock: (s) => s.slicer_filament ? 1 : 0,
   spool_name: (s) => s.core_weight_catalog_id ?? 0,
-  cost_per_kg: (s) => s.cost_per_kg ?? 0,
+  cost_per_kg: (s) => shownCostPerKg(s).rate ?? 0,
   weight_check: (s) => {
     if (s.last_scale_weight == null) return -1;
     const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;
@@ -966,6 +973,13 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     },
   });
 
+  // Helper: count items that didn't succeed across the two response shapes
+  // (internal mode returns not_found, Spoolman returns errors[]). When the
+  // success count is 0 OR any failures occurred, surface that to the user
+  // instead of the silent green-toast-and-clear flow the first cut shipped.
+  const failedCount = (data: { not_found?: number[]; errors?: Array<{ id: number }> }): number =>
+    (data.not_found?.length ?? 0) + (data.errors?.length ?? 0);
+
   const bulkResetConsumedCounterMutation = useMutation({
     mutationFn: (ids: number[]) =>
       spoolmanMode
@@ -973,7 +987,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         : api.bulkResetSpoolConsumedCounter(ids),
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
-      showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
+      // As the other bulk actions: a reset that failed for some or all spools
+      // is not a success (FO-C8).
+      const failed = failedCount(data);
+      if (data.reset === 0 && failed > 0) {
+        showToast(t('inventory.bulk.resetAllFailed', { count: failed }), 'error');
+        return; // keep the selection so the user can retry
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.resetPartial', { ok: data.reset, failed }), 'warning');
+      } else {
+        showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
+      }
       // Close any open bulk-confirm modal + clear selection so the toolbar
       // collapses after the action — matches the other three bulk mutations
       // and stops the confirm dialog from lingering after Reset.
@@ -987,13 +1012,6 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
 
   // Bulk action mutations (#1795). Each invalidates the same query keys as
   // the per-spool equivalents so the table refreshes with the new state.
-  // Helper: count items that didn't succeed across the two response shapes
-  // (internal mode returns not_found, Spoolman returns errors[]). When the
-  // success count is 0 OR any failures occurred, surface that to the user
-  // instead of the silent green-toast-and-clear flow the first cut shipped.
-  const failedCount = (data: { not_found?: number[]; errors?: Array<{ id: number }> }): number =>
-    (data.not_found?.length ?? 0) + (data.errors?.length ?? 0);
-
   const bulkUpdateMutation = useMutation({
     mutationFn: async ({ ids, update }: { ids: number[]; update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>> }): Promise<{ updated: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
       if (spoolmanMode) return api.bulkUpdateSpoolmanInventorySpools(ids, update);

+ 18 - 0
frontend/src/utils/spoolCost.ts

@@ -0,0 +1,18 @@
+import type { InventorySpool } from '../api/client';
+
+/**
+ * The cost per kg to show for a spool: the rate prints off it are charged
+ * (SM-C8). A Spoolman spool's own price wins when it is above 0; otherwise
+ * prints pay its filament's catalogue price (``effective_cost_per_kg``), as
+ * the charge treats a missing or 0 spool price as "not set". Without one, the
+ * spool's own value stands, so a built-in spool priced 0 still shows 0.
+ */
+export function shownCostPerKg(
+  spool: Pick<InventorySpool, 'cost_per_kg' | 'effective_cost_per_kg'>,
+): { rate: number | null; fromFilament: boolean } {
+  const own = spool.cost_per_kg;
+  if (own != null && own > 0) return { rate: own, fromFilament: false };
+  const charged = spool.effective_cost_per_kg;
+  if (charged != null) return { rate: charged, fromFilament: true };
+  return { rate: own ?? null, fromFilament: false };
+}