Просмотр исходного кода

fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp

      Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
      2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
      2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
      00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
      settings defaults; nozzle_mapping never made it onto the wire.

      Three pieces:

      1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
         wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
         slicers that never send MQTT.

      2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
         UPDATEs slicer-driven fields on a recently-committed queue item
         when the event wait already gave up. Tracked via
         _recent_queue_items dict (30 s TTL, evicted on every queue-add).
         Gated on status='pending' so we never race the dispatcher.
         Multi-plate covered via WHERE id IN (...).

      3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
         arrive during any await inside _add_to_print_queue (wait_for,
         archive_print, db.flush, db.commit), and on_print_command would
         stash data with no event consumer AND no _recent_queue_items entry
         yet. After populating _recent_queue_items, _add_to_print_queue now
         pops _slicer_print_options[file_path.name] one last time and
         routes any hit through _restamp inline.
maziggy 2 месяцев назад
Родитель
Сommit
71b0575ff9

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 120 - 0
backend/app/api/routes/inventory.py

@@ -1431,6 +1431,126 @@ async def bulk_reset_spool_consumed_counter(
     return {"reset": len(spools)}
     return {"reset": len(spools)}
 
 
 
 
+class BulkUpdateRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+    update: SpoolUpdate
+
+
+class BulkIdsRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+
+
+@router.post("/spools/bulk-update")
+async def bulk_update_spools(
+    payload: BulkUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Apply the same partial update to every listed spool.
+
+    Per-spool errors are collected and returned alongside the success count so
+    a single bad ID doesn't abort the whole batch. Unknown IDs are reported
+    in the ``not_found`` list.
+    """
+    update_data = payload.update.model_dump(exclude_unset=True)
+    fields_set = set(payload.update.model_fields_set)
+    if not update_data:
+        raise HTTPException(status_code=400, detail="update must include at least one field")
+    try:
+        prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    # Auto-lock weight when the user explicitly sets weight_used — mirrors the
+    # per-spool PATCH behaviour so bulk edits don't desync the lock state.
+    if "weight_used" in prepared and "weight_locked" not in prepared:
+        prepared["weight_locked"] = True
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = {s.id: s for s in result.scalars().all()}
+    not_found = [sid for sid in payload.ids if sid not in spools]
+    updated_ids: list[int] = []
+    for sid, spool in spools.items():
+        for field, value in prepared.items():
+            setattr(spool, field, value)
+        updated_ids.append(sid)
+    await db.commit()
+    if updated_ids:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": len(updated_ids), "not_found": not_found}
+
+
+@router.post("/spools/bulk-delete")
+async def bulk_delete_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Hard-delete every listed spool. Unknown IDs are returned in not_found."""
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    for spool in spools:
+        await db.delete(spool)
+    await db.commit()
+    if spools:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"deleted": len(spools), "not_found": not_found}
+
+
+@router.post("/spools/bulk-archive")
+async def bulk_archive_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
+    from datetime import datetime, timezone
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    archived: list[int] = []
+    already: list[int] = []
+    now = datetime.now(timezone.utc)
+    for spool in spools:
+        if spool.archived_at is not None:
+            already.append(spool.id)
+            continue
+        spool.archived_at = now
+        archived.append(spool.id)
+    await db.commit()
+    if archived:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"archived": len(archived), "already_archived": already, "not_found": not_found}
+
+
+@router.post("/spools/bulk-restore")
+async def bulk_restore_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    restored: list[int] = []
+    already: list[int] = []
+    for spool in spools:
+        if spool.archived_at is None:
+            already.append(spool.id)
+            continue
+        spool.archived_at = None
+        restored.append(spool.id)
+    await db.commit()
+    if restored:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"restored": len(restored), "already_active": already, "not_found": not_found}
+
+
 # ── K-Profiles ───────────────────────────────────────────────────────────────
 # ── K-Profiles ───────────────────────────────────────────────────────────────
 
 
 
 

+ 117 - 0
backend/app/api/routes/spoolman_inventory.py

@@ -956,6 +956,123 @@ async def reset_spool_consumed_counter(
     return mapped
     return mapped
 
 
 
 
+class SpoolmanBulkUpdateRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+    update: SpoolmanInventoryUpdate
+
+
+class SpoolmanBulkIdsRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+
+
+@router.post("/spools/bulk-update")
+async def bulk_update_spools(
+    payload: SpoolmanBulkUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Apply the same partial update to every listed Spoolman spool.
+
+    Loops the per-spool ``update_spool`` route so the filament re-linking +
+    extra-dict + location-resolution rules stay in sync with the single-spool
+    PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
+    batch.
+    """
+    update_fields = payload.update.model_dump(exclude_unset=True)
+    if not update_fields:
+        raise HTTPException(status_code=400, detail="update must include at least one field")
+
+    updated = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
+            updated += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-update failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if updated:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": updated, "errors": errors}
+
+
+@router.post("/spools/bulk-delete")
+async def bulk_delete_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    deleted = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.delete_spool(sid)
+            deleted += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-delete failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if deleted:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"deleted": deleted, "errors": errors}
+
+
+@router.post("/spools/bulk-archive")
+async def bulk_archive_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Archive every listed Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    archived = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.set_spool_archived(sid, archived=True)
+            archived += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-archive failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if archived:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"archived": archived, "errors": errors}
+
+
+@router.post("/spools/bulk-restore")
+async def bulk_restore_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Restore every listed archived Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    restored = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.set_spool_archived(sid, archived=False)
+            restored += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-restore failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if restored:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"restored": restored, "errors": errors}
+
+
 @router.post("/spools/reset-consumed-counter-bulk")
 @router.post("/spools/reset-consumed-counter-bulk")
 async def bulk_reset_spool_consumed_counter(
 async def bulk_reset_spool_consumed_counter(
     payload: dict = Body(...),
     payload: dict = Body(...),

+ 170 - 4
backend/app/services/virtual_printer/manager.py

@@ -6,6 +6,7 @@ bound to its dedicated IP address, regardless of mode.
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import time
 from collections.abc import Callable
 from collections.abc import Callable
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
@@ -110,6 +111,23 @@ DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001"  # X1C
 # the dict fills, so a long-running VP can't leak unbounded state.
 # the dict fills, so a long-running VP can't leak unbounded state.
 _SLICER_OPTIONS_CACHE_LIMIT = 128
 _SLICER_OPTIONS_CACHE_LIMIT = 128
 
 
+# How long ``_add_to_print_queue`` waits for the slicer's MQTT
+# ``project_file`` after the FTP upload completes (#1780 round 3).
+# Bambu Studio sends FTP first, then MQTT immediately after — but on
+# wireless / loaded setups the MQTT command can land 2+ s after FTP,
+# which used to time the wait out and silently drop ``nozzle_mapping``
+# + the other slicer-driven flags. The bumped window covers the
+# observed worst case in the field; the late-MQTT fallback in
+# ``on_print_command`` covers the rest.
+_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
+
+# How long ``on_print_command`` will retroactively stamp slicer fields
+# onto a recently-committed queue item when the MQTT print command
+# arrives after ``_SLICER_OPTIONS_WAIT_TIMEOUT`` expired. Covers
+# extra-late MQTT (slow wireless slicer, NIC drop+retry) and the
+# scheduler tick interval before dispatch picks the item up.
+_RECENT_QUEUE_ITEM_TTL = 30.0
+
 
 
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
     """Get serial number for the given model and suffix."""
@@ -200,6 +218,16 @@ class VirtualPrinterInstance:
         self._slicer_print_options: dict[str, dict] = {}
         self._slicer_print_options: dict[str, dict] = {}
         self._slicer_print_options_events: dict[str, asyncio.Event] = {}
         self._slicer_print_options_events: dict[str, asyncio.Event] = {}
 
 
+        # Queue items recently committed by `_add_to_print_queue`, keyed by
+        # FTP filename. Used by `on_print_command` to retroactively stamp the
+        # slicer's nozzle_mapping (and the other slicer-driven flags) onto a
+        # queue item when the MQTT `project_file` arrives after the queue-add
+        # wait timed out — the #1780 round-3 race. Value is
+        # (queue_item_ids, monotonic_committed_at); entries older than
+        # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
+        # queue-add.
+        self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
+
         # Per-instance services
         # Per-instance services
         self._proxy: SlicerProxyManager | None = None
         self._proxy: SlicerProxyManager | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
@@ -342,6 +370,114 @@ class VirtualPrinterInstance:
         event = self._slicer_print_options_events.get(stash_key)
         event = self._slicer_print_options_events.get(stash_key)
         if event:
         if event:
             event.set()
             event.set()
+            return
+        # No consumer waiting: `_add_to_print_queue` either already gave up
+        # (wait_for timed out) or hasn't started yet (FTP still uploading).
+        # If a queue item was committed within the last
+        # `_RECENT_QUEUE_ITEM_TTL`, the wait timed out and the row holds
+        # settings defaults instead of the slicer's pick — retroactively
+        # stamp the slicer-driven fields so the dispatcher honours the
+        # user's choice. Covers the #1780 round-3 race where Bambu Studio's
+        # MQTT lands just past the bumped wait ceiling.
+        await self._restamp_recent_queue_item(stash_key, data)
+
+    async def _restamp_recent_queue_item(self, stash_key: str, data: dict) -> None:
+        """Patch slicer-driven fields onto a queue item the MQTT command missed.
+
+        ``_add_to_print_queue`` waits up to ``_SLICER_OPTIONS_WAIT_TIMEOUT``
+        for the slicer's MQTT ``project_file`` before committing the queue
+        item. If the MQTT command arrives after that window — observed in
+        the field at ~2.1 s on H2C / wireless setups (#1780 round 3) — the
+        row was already written with settings defaults. This method runs
+        on the late MQTT path: it looks up the most recent queue items
+        committed for this filename and patches in the slicer's
+        ``nozzle_mapping`` + workflow flags, but only while the items are
+        still ``pending`` (scheduler hasn't dispatched them yet).
+        """
+        if not self._session_factory:
+            return
+        entry = self._recent_queue_items.get(stash_key)
+        if entry is None:
+            return
+        queue_item_ids, committed_at = entry
+        if time.monotonic() - committed_at > _RECENT_QUEUE_ITEM_TTL:
+            self._recent_queue_items.pop(stash_key, None)
+            return
+
+        import json
+
+        # Mirror the field set `_add_to_print_queue` reads off slicer_opts.
+        # MQTT uses `bed_leveling` (single L); the column is `bed_levelling`.
+        # `nozzles_info` is intentionally not stamped — column kept for
+        # legacy rows but never written; see PrintQueueItem.nozzles_info.
+        patch: dict = {}
+        for mqtt_field, column in (
+            ("bed_leveling", "bed_levelling"),
+            ("flow_cali", "flow_cali"),
+            ("vibration_cali", "vibration_cali"),
+            ("layer_inspect", "layer_inspect"),
+            ("timelapse", "timelapse"),
+            ("use_ams", "use_ams"),
+        ):
+            if mqtt_field in data:
+                patch[column] = bool(data[mqtt_field])
+
+        raw = data.get("nozzle_mapping")
+        if raw is not None:
+            if isinstance(raw, str):
+                try:
+                    raw = json.loads(raw)
+                except json.JSONDecodeError:
+                    logger.warning(
+                        "[VP %s] Late MQTT nozzle_mapping is unparseable JSON, dropping: %r",
+                        self.name,
+                        raw,
+                    )
+                    raw = None
+            if raw is not None:
+                patch["nozzle_mapping"] = json.dumps(raw)
+
+        if not patch:
+            self._recent_queue_items.pop(stash_key, None)
+            return
+
+        from sqlalchemy import select, update
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        try:
+            async with self._session_factory() as db:
+                # Only stamp items still pending; once the scheduler has
+                # picked the row up we can't safely race the dispatcher.
+                result = await db.execute(
+                    select(PrintQueueItem.id).where(
+                        PrintQueueItem.id.in_(queue_item_ids),
+                        PrintQueueItem.status == "pending",
+                    )
+                )
+                eligible_ids = [row[0] for row in result.all()]
+                if not eligible_ids:
+                    self._recent_queue_items.pop(stash_key, None)
+                    return
+                await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+                await db.commit()
+                logger.info(
+                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
+                    self.name,
+                    stash_key,
+                    sorted(patch.keys()),
+                    eligible_ids,
+                )
+        except Exception as e:
+            logger.error(
+                "[VP %s] Failed to retroactively stamp queue item(s) %s for %s: %s",
+                self.name,
+                queue_item_ids,
+                stash_key,
+                e,
+            )
+        finally:
+            self._recent_queue_items.pop(stash_key, None)
 
 
     def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
     def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
         """Re-set gcode_state=FINISH on the VP after the project_file ack.
         """Re-set gcode_state=FINISH on the VP after the project_file ack.
@@ -524,19 +660,21 @@ class VirtualPrinterInstance:
         # queue item can inherit the slicer-side print options the user
         # queue item can inherit the slicer-side print options the user
         # picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
         # picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
         # first and the MQTT command immediately after, so the typical lag
         # first and the MQTT command immediately after, so the typical lag
-        # is a few hundred ms; 2 s is conservative without making every
-        # VP-queue add visibly slow. Falls back to the global default_*
+        # is a few hundred ms. The window is generous enough to absorb
+        # wireless / loaded-Pi jitter without making every VP-queue add
+        # visibly slow — observed worst case in #1780 round 3 was 2.085 s,
+        # the previous 2.0 s ceiling. Falls back to the global default_*
         # settings if MQTT doesn't arrive in time (legacy behaviour for
         # settings if MQTT doesn't arrive in time (legacy behaviour for
         # users on a slicer that doesn't send a print command). #1403.
         # users on a slicer that doesn't send a print command). #1403.
         # The wait is skipped when there's no MQTT server attached — covers
         # The wait is skipped when there's no MQTT server attached — covers
         # unit tests that invoke `_add_to_print_queue` directly without
         # unit tests that invoke `_add_to_print_queue` directly without
-        # going through `on_print_command`, so they don't pay the 2 s tax.
+        # going through `on_print_command`, so they don't pay the wait tax.
         slicer_opts = self._slicer_print_options.pop(file_path.name, None)
         slicer_opts = self._slicer_print_options.pop(file_path.name, None)
         if slicer_opts is None and self._mqtt is not None:
         if slicer_opts is None and self._mqtt is not None:
             event = asyncio.Event()
             event = asyncio.Event()
             self._slicer_print_options_events[file_path.name] = event
             self._slicer_print_options_events[file_path.name] = event
             try:
             try:
-                await asyncio.wait_for(event.wait(), timeout=2.0)
+                await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT)
                 slicer_opts = self._slicer_print_options.pop(file_path.name, None)
                 slicer_opts = self._slicer_print_options.pop(file_path.name, None)
             except asyncio.TimeoutError:
             except asyncio.TimeoutError:
                 slicer_opts = None
                 slicer_opts = None
@@ -749,6 +887,34 @@ class VirtualPrinterInstance:
                         await db.flush()  # populate queue_item.id before logging
                         await db.flush()  # populate queue_item.id before logging
                         queue_item_ids.append(queue_item.id)
                         queue_item_ids.append(queue_item.id)
                     await db.commit()
                     await db.commit()
+                    # Track the freshly-committed queue items so
+                    # `on_print_command` can retroactively stamp slicer-side
+                    # fields if the MQTT `project_file` lands AFTER the
+                    # `_SLICER_OPTIONS_WAIT_TIMEOUT` window expired — the
+                    # #1780 round-3 race. Eviction of stale entries here
+                    # keeps the dict bounded; the queue path is the only
+                    # writer, so doing it on commit is enough.
+                    now = time.monotonic()
+                    cutoff = now - _RECENT_QUEUE_ITEM_TTL
+                    self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
+                    self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
+                    # Last-chance check: MQTT for this filename could have
+                    # arrived during ANY await between the initial pop and
+                    # now — wait_for itself, archive_print, db.flush,
+                    # db.commit. In all those cases `on_print_command`
+                    # stashed its data but neither the event-signal path nor
+                    # the retroactive `_recent_queue_items` path was in
+                    # place to consume it. Pop any late stash and apply
+                    # inline so the late MQTT never leaks past the queue-add.
+                    late_opts = self._slicer_print_options.pop(file_path.name, None)
+                    if late_opts is not None:
+                        logger.info(
+                            "[VP %s] Late slicer MQTT detected for %s during queue-add — "
+                            "applying inline (race vs commit/archive/flush yield)",
+                            self.name,
+                            file_path.name,
+                        )
+                        await self._restamp_recent_queue_item(file_path.name, late_opts)
                     if len(queue_item_ids) == 1:
                     if len(queue_item_ids) == 1:
                         logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
                         logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
                     else:
                     else:

+ 212 - 0
backend/tests/integration/test_inventory_bulk.py

@@ -0,0 +1,212 @@
+"""Bulk inventory endpoint coverage for the batch-edit feature (#1795).
+
+Endpoints under test:
+- POST /api/v1/inventory/spools/bulk-update
+- POST /api/v1/inventory/spools/bulk-delete
+- POST /api/v1/inventory/spools/bulk-archive
+- POST /api/v1/inventory/spools/bulk-restore
+
+The Spoolman-mode equivalents live in test_spoolman_inventory_api.py.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+            "weight_used_baseline": 0,
+            "weight_locked": False,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+class TestBulkUpdate:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_applies_patch_to_all_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory(brand="Bambu", note=None)
+        b = await spool_factory(brand="Bambu", note=None)
+        c = await spool_factory(brand="Bambu", note=None)
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id, b.id, c.id], "update": {"brand": "Sunlu", "note": "From bulk edit"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 3
+        assert body["not_found"] == []
+
+        for spool in (a, b, c):
+            await db_session.refresh(spool)
+            assert spool.brand == "Sunlu"
+            assert spool.note == "From bulk edit"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_unknown_ids_in_not_found(self, async_client: AsyncClient, spool_factory, db_session):
+        real = await spool_factory(brand="Bambu")
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [real.id, 999_999], "update": {"brand": "Sunlu"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 1
+        assert body["not_found"] == [999_999]
+
+        await db_session.refresh(real)
+        assert real.brand == "Sunlu"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_update_rejected(self, async_client: AsyncClient, spool_factory):
+        a = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id], "update": {}},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setting_weight_used_auto_locks(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory(weight_locked=False, weight_used=0.0)
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id], "update": {"weight_used": 250.5}},
+        )
+        assert resp.status_code == 200
+        await db_session.refresh(a)
+        assert a.weight_used == 250.5
+        assert a.weight_locked is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_ids_list_rejected(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [], "update": {"brand": "X"}},
+        )
+        assert resp.status_code == 422
+
+
+class TestBulkDelete:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deletes_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory()
+        b = await spool_factory()
+        kept = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-delete",
+            json={"ids": [a.id, b.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 2
+        assert body["not_found"] == []
+
+        remaining = (await db_session.execute(select(Spool.id))).scalars().all()
+        assert kept.id in remaining
+        assert a.id not in remaining
+        assert b.id not in remaining
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
+        a = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-delete",
+            json={"ids": [a.id, 999_999]},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 1
+        assert body["not_found"] == [999_999]
+
+
+class TestBulkArchiveRestore:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_sets_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory()
+        b = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-archive",
+            json={"ids": [a.id, b.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 2
+        assert body["already_archived"] == []
+        assert body["not_found"] == []
+
+        for s in (a, b):
+            await db_session.refresh(s)
+            assert s.archived_at is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_skips_already_archived(self, async_client: AsyncClient, spool_factory, db_session):
+        active = await spool_factory()
+        already = await spool_factory(archived_at=datetime.now(timezone.utc))
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-archive",
+            json={"ids": [active.id, already.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 1
+        assert body["already_archived"] == [already.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_restore_clears_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
+        archived = await spool_factory(archived_at=datetime.now(timezone.utc))
+        active = await spool_factory(archived_at=None)
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-restore",
+            json={"ids": [archived.id, active.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["restored"] == 1
+        assert body["already_active"] == [active.id]
+
+        await db_session.refresh(archived)
+        assert archived.archived_at is None

+ 200 - 0
backend/tests/integration/test_spoolman_inventory_bulk.py

@@ -0,0 +1,200 @@
+"""Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
+
+Endpoints under test:
+- POST /api/v1/spoolman/inventory/spools/bulk-update
+- POST /api/v1/spoolman/inventory/spools/bulk-delete
+- POST /api/v1/spoolman/inventory/spools/bulk-archive
+- POST /api/v1/spoolman/inventory/spools/bulk-restore
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from httpx import AsyncClient
+
+SAMPLE_SPOOLMAN_SPOOL = {
+    "id": 42,
+    "filament": {
+        "id": 7,
+        "name": "PLA Basic",
+        "material": "PLA",
+        "color_hex": "FF0000",
+        "weight": 1000,
+        "vendor": {"id": 3, "name": "Bambu Lab"},
+    },
+    "remaining_weight": 750.0,
+    "used_weight": 250.0,
+    "location": "Printer1 - AMS A1",
+    "comment": "test note",
+    "first_used": "2024-01-01T00:00:00+00:00",
+    "last_used": "2024-02-01T00:00:00+00:00",
+    "registered": "2024-01-01T00:00:00+00:00",
+    "archived": False,
+    "price": None,
+    "extra": {},
+}
+
+
+@pytest.fixture
+async def spoolman_settings(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.fixture
+def mock_spoolman_client():
+    mock = MagicMock()
+    mock.base_url = "http://localhost:7912"
+    mock.health_check = AsyncMock(return_value=True)
+    mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.delete_spool = AsyncMock(return_value=True)
+    mock.set_spool_archived = AsyncMock(
+        side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
+    )
+    mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.is_filament_shared = AsyncMock(return_value=False)
+    mock.patch_filament = AsyncMock(return_value={"id": 7})
+    mock.find_or_create_filament = AsyncMock(return_value=7)
+    mock.find_or_create_vendor = AsyncMock(return_value=3)
+    mock.ensure_extra_field = AsyncMock(return_value=True)
+    mock.get_distinct_locations = AsyncMock(return_value=[])
+
+    class _Lock:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *args):
+            return False
+
+    mock.extra_lock = lambda spool_id: _Lock()
+
+    with (
+        patch(
+            "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
+            AsyncMock(return_value=mock),
+        ),
+        patch(
+            "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
+            AsyncMock(return_value=mock),
+        ),
+    ):
+        yield mock
+
+
+class TestSpoolmanBulkUpdate:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calls_per_spool_update_for_each_id(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 3
+        assert body["errors"] == []
+        # update_spool route loops through each, which calls update_spool_full once per ID
+        assert mock_spoolman_client.update_spool_full.await_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_collects_per_spool_errors_without_aborting_batch(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        # First two succeed, third raises
+        mock_spoolman_client.update_spool_full.side_effect = [
+            SAMPLE_SPOOLMAN_SPOOL,
+            SAMPLE_SPOOLMAN_SPOOL,
+            HTTPException(status_code=404, detail="Spool 999 not found"),
+        ]
+
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 2
+        assert len(body["errors"]) == 1
+        assert body["errors"][0]["id"] == 999
+        assert body["errors"][0]["status"] == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42], "update": {}},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [], "update": {"note": "X"}},
+        )
+        assert resp.status_code == 422
+
+
+class TestSpoolmanBulkDelete:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-delete",
+            json={"ids": [42, 43, 44]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 3
+        assert body["errors"] == []
+        assert mock_spoolman_client.delete_spool.await_count == 3
+
+
+class TestSpoolmanBulkArchiveRestore:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_calls_per_spool(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-archive",
+            json={"ids": [42, 43]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 2
+        # set_spool_archived(spool_id, archived=True) called for each id
+        assert mock_spoolman_client.set_spool_archived.await_count == 2
+        for call in mock_spoolman_client.set_spool_archived.call_args_list:
+            assert call.kwargs.get("archived") is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_restore_calls_per_spool(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-restore",
+            json={"ids": [42, 43]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["restored"] == 2
+        assert mock_spoolman_client.set_spool_archived.await_count == 2
+        for call in mock_spoolman_client.set_spool_archived.call_args_list:
+            assert call.kwargs.get("archived") is False

+ 266 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1785,6 +1785,272 @@ class TestVirtualPrinterInstance:
         for item in added_items:
         for item in added_items:
             assert _json.loads(item.nozzle_mapping) == [16, 0]
             assert _json.loads(item.nozzle_mapping) == [16, 0]
 
 
+    @pytest.mark.asyncio
+    async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path):
+        """#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER
+        `_add_to_print_queue` already gave up waiting (observed at 2.085 s
+        on H2C wireless setups). The queue item was committed with settings
+        defaults; the slicer's nozzle_mapping + workflow flags must be
+        patched onto it when MQTT lands, otherwise the H2C firmware falls
+        back to auto-pick.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(
+            side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
+        )
+
+        async def _flush():
+            # added_items[-1].id was set by `add`; nothing else to do.
+            return None
+
+        mock_db.flush = AsyncMock(side_effect=_flush)
+        mock_db.commit = AsyncMock()
+
+        # First execute() call (the position-max SELECT inside _add_to_print_queue)
+        # returns None; second (the eligible-pending SELECT in
+        # _restamp_recent_queue_item) returns the committed queue id; third
+        # (the UPDATE) is fire-and-forget.
+        position_max_result = MagicMock()
+        position_max_result.scalar = MagicMock(return_value=None)
+        select_pending_result = MagicMock()
+        select_pending_result.all = MagicMock(return_value=[(101,)])
+        update_result = MagicMock()
+        mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=99,
+            name="LateMQTT",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="391800099",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        # MQTT server presence enables the wait_for path; we don't actually
+        # use any methods on it.
+        inst._mqtt = MagicMock()
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        # 1. _add_to_print_queue runs WITHOUT a prior on_print_command —
+        #    the wait_for times out (settings-default fallback) and the
+        #    queue item is committed.
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+            # Shorten the wait so the test isn't slow.
+            patch(
+                "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
+                0.05,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert added_items[0].nozzle_mapping is None  # MQTT was never received
+        assert file_path.name in inst._recent_queue_items
+
+        # 2. MQTT project_file arrives AFTER the wait expired — must
+        #    retroactively patch the queue item.
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "file": file_path.name,
+                "nozzle_mapping": [16, -1, -1, 1],
+                "timelapse": True,
+                "bed_leveling": False,
+            },
+        )
+
+        # The UPDATE call is the third execute. Inspect its values.
+        update_call = mock_db.execute.await_args_list[2]
+        update_stmt = update_call.args[0]
+        compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
+        params = dict(compiled.params)
+        assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
+        assert params["timelapse"] is True
+        assert params["bed_levelling"] is False  # MQTT bed_leveling → column bed_levelling
+        # Recent-queue tracking dict is cleared after the patch.
+        assert file_path.name not in inst._recent_queue_items
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
+        """The actual race-window scenario: wait_for times out, then MQTT
+        arrives and stashes options AFTER the wait but BEFORE the
+        post-commit re-check. The post-commit pop must catch it.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(
+            side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
+        )
+        mock_db.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+
+        position_max_result = MagicMock()
+        position_max_result.scalar = MagicMock(return_value=None)
+        select_pending_result = MagicMock()
+        select_pending_result.all = MagicMock(return_value=[(301,)])
+        update_result = MagicMock()
+        mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=96,
+            name="RaceCommitYield",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="391800096",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        inst._mqtt = MagicMock()
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        # Stash MQTT data on the FIRST commit (simulating MQTT arrival
+        # during _add_to_print_queue's commit yield); _restamp also calls
+        # db.commit later, so we one-shot the side effect.
+        commit_calls = {"n": 0}
+
+        async def _delayed_stash(*_args, **_kwargs):
+            commit_calls["n"] += 1
+            if commit_calls["n"] == 1:
+                inst._slicer_print_options[file_path.name] = {
+                    "command": "project_file",
+                    "file": file_path.name,
+                    "nozzle_mapping": [0, 16, -1, -1],
+                    "timelapse": False,
+                }
+            return None
+
+        mock_db.commit = AsyncMock(side_effect=_delayed_stash)
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
+                0.05,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        # Queue item INSERTed with defaults (wait timed out, no slicer_opts).
+        assert len(added_items) == 1
+        # But the post-commit pop caught the late stash and applied the
+        # slicer nozzle_mapping via _restamp's UPDATE.
+        update_call = mock_db.execute.await_args_list[2]
+        update_stmt = update_call.args[0]
+        compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
+        params = dict(compiled.params)
+        assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1]
+        assert params["timelapse"] is False
+        # _recent_queue_items entry was consumed by the post-commit
+        # _restamp call.
+        assert file_path.name not in inst._recent_queue_items
+        # And the stash is empty.
+        assert file_path.name not in inst._slicer_print_options
+
+    @pytest.mark.asyncio
+    async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path):
+        """Once the scheduler has picked the queue item up (status != pending),
+        the retroactive patch is a no-op — racing the dispatcher would be
+        unsafe.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        # The eligible-pending SELECT returns nothing — item is no longer pending.
+        empty_result = MagicMock()
+        empty_result.all = MagicMock(return_value=[])
+        mock_db.execute = AsyncMock(return_value=empty_result)
+        mock_db.commit = AsyncMock()
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=98,
+            name="LateMQTTDispatched",
+            mode="queue",
+            model="O1C2",
+            access_code="12345678",
+            serial_suffix="391800098",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+        inst._mqtt = MagicMock()
+        # Pre-seed the recent-queue dict — pretend _add_to_print_queue just
+        # committed item id 42.
+        inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0)
+        # Drive _restamp via on_print_command on the late-MQTT path.
+        with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
+            await inst.on_print_command(
+                "test.3mf",
+                {
+                    "command": "project_file",
+                    "file": "test.3mf",
+                    "nozzle_mapping": [16, -1],
+                },
+            )
+        # No UPDATE was issued — only the eligibility SELECT ran.
+        assert mock_db.execute.await_count == 1
+        mock_db.commit.assert_not_awaited()
+        assert "test.3mf" not in inst._recent_queue_items
+
 
 
 class TestVirtualPrinterManager:
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""
     """Tests for VirtualPrinterManager orchestrator."""

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

@@ -5129,6 +5129,26 @@ export const api = {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),
     }),
+  bulkUpdateSpools: (ids: number[], update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
+    request<{ updated: number; not_found: number[] }>(`/inventory/spools/bulk-update`, {
+      method: 'POST',
+      body: JSON.stringify({ ids, update }),
+    }),
+  bulkDeleteSpools: (ids: number[]) =>
+    request<{ deleted: number; not_found: number[] }>(`/inventory/spools/bulk-delete`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkArchiveSpools: (ids: number[]) =>
+    request<{ archived: number; already_archived: number[]; not_found: number[] }>(`/inventory/spools/bulk-archive`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkRestoreSpools: (ids: number[]) =>
+    request<{ restored: number; already_active: number[]; not_found: number[] }>(`/inventory/spools/bulk-restore`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
   getSpoolKProfiles: (spoolId: number) =>
   getSpoolKProfiles: (spoolId: number) =>
     request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
     request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
   saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
   saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
@@ -5308,6 +5328,26 @@ export const api = {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),
     }),
+  bulkUpdateSpoolmanInventorySpools: (ids: number[], update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
+    request<{ updated: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-update`, {
+      method: 'POST',
+      body: JSON.stringify({ ids, update }),
+    }),
+  bulkDeleteSpoolmanInventorySpools: (ids: number[]) =>
+    request<{ deleted: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-delete`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkArchiveSpoolmanInventorySpools: (ids: number[]) =>
+    request<{ archived: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-archive`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
+  bulkRestoreSpoolmanInventorySpools: (ids: number[]) =>
+    request<{ restored: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-restore`, {
+      method: 'POST',
+      body: JSON.stringify({ ids }),
+    }),
   linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
   linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
     request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
       method: 'PATCH',
       method: 'PATCH',

+ 525 - 0
frontend/src/components/BulkEditSpoolsModal.tsx

@@ -0,0 +1,525 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery } from '@tanstack/react-query';
+import { X, Loader2, ChevronDown } from 'lucide-react';
+import { api } from '../api/client';
+import type { InventorySpool } from '../api/client';
+import { Button } from './Button';
+import { MATERIALS, DEFAULT_BRANDS, KNOWN_VARIANTS } from './spool-form/constants';
+import { buildFilamentOptions } from './spool-form/utils';
+
+/** Subset of InventorySpool fields the bulk-edit modal can patch.
+ *  Mirrors the agreed set discussed for #1795 — flat per-spool fields only;
+ *  K-profile editing stays per-spool.
+ */
+type EditableField =
+  | 'material'
+  | 'subtype'
+  | 'brand'
+  | 'color_name'
+  | 'rgba'
+  | 'location_id'
+  | 'slicer_filament_name'
+  | 'slicer_filament'
+  | 'cost_per_kg'
+  | 'note'
+  | 'label_weight'
+  | 'core_weight'
+  | 'category'
+  | 'low_stock_threshold_pct';
+
+type FieldSpec = {
+  id: EditableField;
+  /** searchable = custom dropdown with text input + filtered options (free text allowed).
+   *  searchableClosed = same but no custom value (must pick from list — used for storage_location).
+   *  text = plain text input.
+   *  number = number input.
+   *  color = colour picker + hex input.
+   *  textarea = multi-line text. */
+  type: 'searchable' | 'searchableClosed' | 'text' | 'number' | 'color' | 'textarea';
+  labelKey: string;
+  min?: number;
+  max?: number;
+  step?: number;
+  /** Hex pattern for the rgba field. */
+  pattern?: string;
+};
+
+const FIELDS: FieldSpec[] = [
+  { id: 'material', type: 'searchable', labelKey: 'inventory.material' },
+  { id: 'subtype', type: 'searchable', labelKey: 'inventory.subtype' },
+  { id: 'brand', type: 'searchable', labelKey: 'inventory.brand' },
+  { id: 'color_name', type: 'text', labelKey: 'inventory.colorName' },
+  { id: 'rgba', type: 'color', labelKey: 'inventory.color', pattern: '^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$' },
+  { id: 'location_id', type: 'searchableClosed', labelKey: 'inventory.storageLocation' },
+  { id: 'slicer_filament_name', type: 'searchable', labelKey: 'inventory.slicerFilamentName' },
+  { id: 'slicer_filament', type: 'searchable', labelKey: 'inventory.slicerFilament' },
+  { id: 'cost_per_kg', type: 'number', labelKey: 'inventory.costPerKg', min: 0, step: 0.01 },
+  { id: 'note', type: 'textarea', labelKey: 'inventory.note' },
+  { id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 },
+  { id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 },
+  { id: 'category', type: 'searchable', labelKey: 'inventory.category' },
+  { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 },
+];
+
+export interface BulkEditSpoolsModalProps {
+  isOpen: boolean;
+  selectedCount: number;
+  isPending: boolean;
+  availableLocations: Array<{ id: number; name: string }>;
+  /** Materials seen in the user's inventory — combined with the MATERIALS constant for suggestions. */
+  availableMaterials: string[];
+  availableSubtypes: string[];
+  availableBrands: string[];
+  availableCategories: string[];
+  availableSlicerFilaments: string[];
+  availableSlicerFilamentNames: string[];
+  onClose: () => void;
+  onApply: (patch: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) => void;
+}
+
+interface Option {
+  value: string;
+  label: string;
+}
+
+interface SearchableSelectProps {
+  value: string;
+  onChange: (next: string) => void;
+  options: Option[];
+  /** When true the user can also type a value not present in the option list. */
+  allowCustom: boolean;
+  placeholderKey?: string;
+  disabled?: boolean;
+}
+
+/** Lightweight searchable dropdown matching the per-spool form's pattern —
+ *  text input + chevron + filtered list of buttons, click-outside closes.
+ *  Native `<select>` is intentionally avoided per the project's UI conventions. */
+function SearchableSelect({ value, onChange, options, allowCustom, placeholderKey, disabled }: SearchableSelectProps) {
+  const { t } = useTranslation();
+  const ref = useRef<HTMLDivElement>(null);
+  const [open, setOpen] = useState(false);
+  const [search, setSearch] = useState('');
+
+  useEffect(() => {
+    if (!open) return;
+    const onDocClick = (e: MouseEvent) => {
+      if (ref.current && !ref.current.contains(e.target as Node)) {
+        setOpen(false);
+        setSearch('');
+      }
+    };
+    const onEsc = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        setOpen(false);
+        setSearch('');
+      }
+    };
+    document.addEventListener('mousedown', onDocClick);
+    document.addEventListener('keydown', onEsc);
+    return () => {
+      document.removeEventListener('mousedown', onDocClick);
+      document.removeEventListener('keydown', onEsc);
+    };
+  }, [open]);
+
+  const displayValue = (() => {
+    if (open) return search;
+    const match = options.find((o) => o.value === value);
+    return match?.label ?? value;
+  })();
+
+  const filteredOptions = useMemo(() => {
+    if (!open) return options;
+    const q = search.trim().toLowerCase();
+    if (!q) return options;
+    return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
+  }, [open, search, options]);
+
+  const noOptionMatch = open && search.trim() && !options.some((o) => o.value.toLowerCase() === search.trim().toLowerCase());
+
+  return (
+    <div className="relative" ref={ref}>
+      <input
+        type="text"
+        disabled={disabled}
+        value={displayValue}
+        onChange={(e) => {
+          setSearch(e.target.value);
+          setOpen(true);
+          if (allowCustom) onChange(e.target.value);
+        }}
+        onFocus={() => {
+          setOpen(true);
+          setSearch('');
+        }}
+        placeholder={placeholderKey ? t(placeholderKey) : undefined}
+        className="w-full px-3 py-2 pr-9 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
+      />
+      <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+      {open && (
+        <div className="absolute z-50 left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
+          {filteredOptions.length === 0 && !allowCustom && (
+            <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+          )}
+          {filteredOptions.map((opt) => (
+            <button
+              key={opt.value}
+              type="button"
+              className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                value === opt.value ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+              }`}
+              onClick={() => {
+                onChange(opt.value);
+                setOpen(false);
+                setSearch('');
+              }}
+            >
+              {opt.label}
+            </button>
+          ))}
+          {allowCustom && noOptionMatch && (
+            <button
+              type="button"
+              className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
+              onClick={() => {
+                onChange(search.trim());
+                setOpen(false);
+                setSearch('');
+              }}
+            >
+              {t('inventory.bulk.useCustom', { value: search.trim() })}
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function combineUnique(...lists: string[][]): string[] {
+  const set = new Set<string>();
+  for (const list of lists) for (const v of list) {
+    const trimmed = v?.trim();
+    if (trimmed) set.add(trimmed);
+  }
+  return Array.from(set).sort((a, b) => a.localeCompare(b));
+}
+
+export function BulkEditSpoolsModal({
+  isOpen, selectedCount, isPending,
+  availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories,
+  availableSlicerFilaments, availableSlicerFilamentNames,
+  onClose, onApply,
+}: BulkEditSpoolsModalProps) {
+  const { t } = useTranslation();
+
+  // Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca
+  // + local + built-in). Gated on `isOpen` so closed modal doesn't fetch.
+  const { data: cloudPresets = [] } = useQuery({
+    queryKey: ['bulk-edit-cloud-presets'],
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+    queryFn: async () => {
+      const out: Awaited<ReturnType<typeof api.getFilamentPresets>> = [];
+      try {
+        const status = await api.getCloudStatus();
+        if (status.is_authenticated) {
+          const bambu = await api.getFilamentPresets();
+          out.push(...bambu);
+        }
+      } catch {/* cloud offline → empty */}
+      try {
+        const orca = await api.orcaCloudStatus();
+        if (orca.connected) {
+          const list = await api.orcaCloudListProfiles();
+          out.push(...(list.filament as unknown as typeof out));
+        }
+      } catch {/* orca offline → empty */}
+      return out;
+    },
+  });
+  const { data: localPresetsResp } = useQuery({
+    queryKey: ['bulk-edit-local-presets'],
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+    queryFn: api.getLocalPresets,
+  });
+  const { data: builtinFilaments = [] } = useQuery({
+    queryKey: ['builtin-filaments'],
+    enabled: isOpen,
+    staleTime: 5 * 60 * 1000,
+    queryFn: api.getBuiltinFilaments,
+  });
+  const filamentOptions = useMemo(
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresetsResp?.filament ?? [], builtinFilaments),
+    [cloudPresets, localPresetsResp, builtinFilaments],
+  );
+  // Per-field state: each entry is either undefined (leave unchanged) or
+  // the new value. Clearing fields in bulk is intentionally NOT supported
+  // (user decision on #1795): leave clearing to the per-spool editor so
+  // an accidental "blank everything" isn't a single mis-click away.
+  const [values, setValues] = useState<Record<string, string>>({});
+
+  // Merge inventory-seen values with the canonical option lists so users
+  // see the same dropdown choices the per-spool editor surfaces.
+  const materialOptions: Option[] = useMemo(
+    () => combineUnique(MATERIALS, availableMaterials).map((m) => ({ value: m, label: m })),
+    [availableMaterials],
+  );
+  const subtypeOptions: Option[] = useMemo(
+    () => combineUnique(KNOWN_VARIANTS, availableSubtypes).map((m) => ({ value: m, label: m })),
+    [availableSubtypes],
+  );
+  const brandOptions: Option[] = useMemo(
+    () => combineUnique(DEFAULT_BRANDS, availableBrands).map((m) => ({ value: m, label: m })),
+    [availableBrands],
+  );
+  const categoryOptions: Option[] = useMemo(
+    () => combineUnique(availableCategories).map((m) => ({ value: m, label: m })),
+    [availableCategories],
+  );
+  const slicerFilamentOptions: Option[] = useMemo(() => {
+    // value = preset code (what goes into spool.slicer_filament),
+    // label = display name so the user can find it by name.
+    const fromPresets = filamentOptions.map((p) => ({ value: p.code, label: p.displayName }));
+    const fromInventory = availableSlicerFilaments
+      .filter((code) => !fromPresets.some((p) => p.value === code))
+      .map((code) => ({ value: code, label: code }));
+    return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label));
+  }, [filamentOptions, availableSlicerFilaments]);
+  const slicerFilamentNameOptions: Option[] = useMemo(() => {
+    const fromPresets = filamentOptions.map((p) => ({ value: p.displayName, label: p.displayName }));
+    const fromInventory = availableSlicerFilamentNames
+      .filter((name) => !fromPresets.some((p) => p.value === name))
+      .map((name) => ({ value: name, label: name }));
+    return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label));
+  }, [filamentOptions, availableSlicerFilamentNames]);
+  const locationOptions: Option[] = useMemo(
+    () => availableLocations.map((l) => ({ value: String(l.id), label: l.name })),
+    [availableLocations],
+  );
+
+  if (!isOpen) return null;
+
+  const setField = (id: EditableField, value: string) => {
+    setValues((prev) => ({ ...prev, [id]: value }));
+  };
+
+  const unsetField = (id: EditableField) => {
+    setValues((prev) => {
+      const next = { ...prev };
+      delete next[id];
+      return next;
+    });
+  };
+
+  const buildPatch = (): Record<string, string | number> => {
+    const patch: Record<string, string | number> = {};
+    for (const f of FIELDS) {
+      const raw = values[f.id];
+      if (raw === undefined) continue;
+      const trimmed = typeof raw === 'string' ? raw.trim() : raw;
+      if (trimmed === '' || trimmed === null) continue;
+      if (f.type === 'number') {
+        const n = Number(trimmed);
+        if (Number.isFinite(n)) patch[f.id] = n;
+      } else if (f.id === 'location_id') {
+        const n = Number(trimmed);
+        if (Number.isFinite(n) && n > 0) patch[f.id] = n;
+      } else if (f.id === 'rgba') {
+        const hex = String(trimmed).replace(/^#/, '');
+        const normalized = hex.length === 6 ? `${hex}FF` : hex;
+        if (/^[0-9A-Fa-f]{8}$/.test(normalized)) patch[f.id] = normalized.toUpperCase();
+      } else {
+        patch[f.id] = String(trimmed);
+      }
+    }
+    return patch;
+  };
+
+  const patch = buildPatch();
+  const hasChanges = Object.keys(patch).length > 0;
+  // Block Apply when any ticked-and-non-empty field has invalid input that
+  // would be silently dropped from the patch — e.g. a malformed rgba hex.
+  // Without this guard the user clicks Apply, the field is dropped, and the
+  // success toast still fires for the OTHER fields.
+  const hasDroppedTickedField = FIELDS.some((f) => {
+    const raw = values[f.id];
+    if (raw === undefined) return false;
+    if (raw.trim() === '') return false;
+    return patch[f.id] === undefined;
+  });
+
+  const optionsFor = (id: EditableField): Option[] => {
+    if (id === 'material') return materialOptions;
+    if (id === 'subtype') return subtypeOptions;
+    if (id === 'brand') return brandOptions;
+    if (id === 'category') return categoryOptions;
+    if (id === 'slicer_filament') return slicerFilamentOptions;
+    if (id === 'slicer_filament_name') return slicerFilamentNameOptions;
+    if (id === 'location_id') return locationOptions;
+    return [];
+  };
+
+  const renderInput = (f: FieldSpec) => {
+    const value = values[f.id] ?? '';
+
+    if (f.type === 'searchable' || f.type === 'searchableClosed') {
+      return (
+        <SearchableSelect
+          value={value}
+          onChange={(next) => {
+            if (next === '') unsetField(f.id);
+            else setField(f.id, next);
+          }}
+          options={optionsFor(f.id)}
+          allowCustom={f.type === 'searchable'}
+          disabled={isPending}
+        />
+      );
+    }
+
+    if (f.type === 'textarea') {
+      return (
+        <textarea
+          disabled={isPending}
+          value={value}
+          onChange={(e) => setField(f.id, e.target.value)}
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none resize-none min-h-[60px]"
+        />
+      );
+    }
+
+    if (f.type === 'color') {
+      const hexCandidate = value.trim().replace(/^#/, '');
+      const normalized = hexCandidate.length === 6 ? `${hexCandidate}FF` : hexCandidate;
+      const isInvalid = value.trim() !== '' && !/^[0-9A-Fa-f]{8}$/.test(normalized);
+      return (
+        <div>
+          <div className="flex items-center gap-2">
+            <input
+              type="color"
+              disabled={isPending}
+              value={`#${(value || '808080').replace(/^#/, '').slice(0, 6)}`}
+              onChange={(e) => setField(f.id, e.target.value.replace(/^#/, '').toUpperCase())}
+              className="h-9 w-12 rounded cursor-pointer"
+            />
+            <input
+              type="text"
+              disabled={isPending}
+              value={value}
+              onChange={(e) => setField(f.id, e.target.value.replace(/^#/, '').toUpperCase())}
+              placeholder="RRGGBB or RRGGBBAA"
+              className={`flex-1 px-3 py-2 bg-bambu-dark border rounded-lg text-white placeholder-bambu-gray/50 focus:outline-none ${isInvalid ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'}`}
+              pattern={f.pattern}
+            />
+          </div>
+          {isInvalid && (
+            <p className="mt-1 text-xs text-red-400">{t('inventory.bulk.invalidHex')}</p>
+          )}
+        </div>
+      );
+    }
+
+    return (
+      <input
+        type={f.type === 'number' ? 'number' : 'text'}
+        disabled={isPending}
+        value={value}
+        onChange={(e) => setField(f.id, e.target.value)}
+        min={f.min}
+        max={f.max}
+        step={f.step}
+        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
+      />
+    );
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
+      onClick={isPending ? undefined : onClose}
+    >
+      <div
+        className="w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg max-h-[90vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between p-5 border-b border-bambu-dark-tertiary">
+          <div>
+            <h2 className="text-lg font-semibold text-white">
+              {t('inventory.bulk.editTitle')}
+            </h2>
+            <p className="text-sm text-bambu-gray mt-0.5">
+              {t('inventory.bulk.editSubtitle', { count: selectedCount })}
+            </p>
+          </div>
+          <button
+            onClick={onClose}
+            disabled={isPending}
+            className="p-1 text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <p className="px-5 pt-4 text-xs text-bambu-gray">
+          {t('inventory.bulk.editHint')}
+        </p>
+        <div className="flex-1 overflow-y-auto p-5 space-y-3">
+          {FIELDS.map((f) => {
+            const enabled = values[f.id] !== undefined;
+            return (
+              <div key={f.id} className={`flex items-start gap-3 rounded-md p-2 transition-colors ${enabled ? 'bg-bambu-green/5 border border-bambu-green/30' : 'border border-transparent'}`}>
+                <div className="pt-2">
+                  <input
+                    type="checkbox"
+                    className="h-4 w-4 cursor-pointer"
+                    checked={enabled}
+                    onChange={(e) => {
+                      if (e.target.checked) setField(f.id, '');
+                      else unsetField(f.id);
+                    }}
+                    aria-label={t('inventory.bulk.toggleField')}
+                  />
+                </div>
+                <div className="flex-1">
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t(f.labelKey)}
+                  </label>
+                  {renderInput(f)}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+
+        <div className="flex items-center gap-3 p-5 border-t border-bambu-dark-tertiary">
+          <span className="text-xs text-bambu-gray">
+            {t('inventory.bulk.changeCount', { count: Object.keys(patch).length })}
+          </span>
+          <div className="ml-auto flex gap-2">
+            <Button variant="secondary" onClick={onClose} disabled={isPending}>
+              {t('common.cancel')}
+            </Button>
+            <Button
+              onClick={() => onApply(patch as Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>)}
+              disabled={!hasChanges || isPending || hasDroppedTickedField}
+            >
+              {isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {t('inventory.bulk.applyPending')}
+                </>
+              ) : (
+                t('inventory.bulk.applyButton', { count: selectedCount })
+              )}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -3850,6 +3850,52 @@ export default {
     unknownSpoolTitle: 'Neues Filament erkannt',
     unknownSpoolTitle: 'Neues Filament erkannt',
     unknownSpoolMessage: 'An {{location}} wurde eine Spule mit unbekanntem RFID-Tag erkannt. Jetzt zum Inventar hinzufügen?',
     unknownSpoolMessage: 'An {{location}} wurde eine Spule mit unbekanntem RFID-Tag erkannt. Jetzt zum Inventar hinzufügen?',
     unknownSpoolSlot: 'Slot',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Alle sichtbaren auswählen',
+      selectRow: 'Zeile auswählen',
+      selectGroup: 'Gruppe auswählen',
+      selectionCount: '{{count}} ausgewählt',
+      edit: 'Bearbeiten',
+      printLabels: 'Etiketten drucken',
+      resetUsage: 'Verbrauch zurücksetzen',
+      restore: 'Wiederherstellen',
+      archive: 'Archivieren',
+      delete: 'Löschen',
+      clearSelection: 'Auswahl aufheben',
+      editTitle: 'Spulen sammelbearbeiten',
+      editSubtitle: 'Wird auf {{count}} ausgewählte Spulen angewendet. Nur angekreuzte Felder werden aktualisiert.',
+      editHint: 'In ein Feld tippen markiert es für die Aktualisierung — nur angekreuzte Zeilen werden gesendet. Leere Felder bleiben unverändert (Felder leeren geht nur pro Spule).',
+      useCustom: '„{{value}}" verwenden',
+      toggleField: 'Aktualisierung für dieses Feld umschalten',
+      changeCount: '{{count}} Felder werden aktualisiert.',
+      applyPending: 'Wird angewendet...',
+      applyButton: 'Auf {{count}} Spulen anwenden',
+      deleteTitle: 'Ausgewählte Spulen löschen',
+      archiveTitle: 'Ausgewählte Spulen archivieren',
+      restoreTitle: 'Ausgewählte Spulen wiederherstellen',
+      resetUsageTitle: 'Verbrauch ausgewählter Spulen zurücksetzen',
+      deleteMessage: '{{count}} Spulen dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.',
+      archiveMessage: '{{count}} Spulen archivieren? Sie können später wiederhergestellt werden.',
+      restoreMessage: '{{count}} archivierte Spulen wiederherstellen?',
+      resetUsageMessage: 'Den "Gesamtverbrauch"-Zähler auf {{count}} Spulen zurücksetzen? Die verbleibende Menge bleibt erhalten.',
+      updateSuccess: '{{count}} Spulen aktualisiert',
+      updateFailed: 'Sammelaktualisierung fehlgeschlagen',
+      updatePartial: '{{ok}} Spulen aktualisiert, {{failed}} fehlgeschlagen',
+      updateAllFailed: 'Alle {{count}} Aktualisierungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      deleteSuccess: '{{count}} Spulen gelöscht',
+      deleteFailed: 'Sammellöschung fehlgeschlagen',
+      deletePartial: '{{ok}} Spulen gelöscht, {{failed}} fehlgeschlagen',
+      deleteAllFailed: 'Alle {{count}} Löschungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      archiveSuccess: '{{count}} Spulen archiviert',
+      archiveFailed: 'Sammelarchivierung fehlgeschlagen',
+      archivePartial: '{{ok}} Spulen archiviert, {{failed}} fehlgeschlagen',
+      archiveAllFailed: 'Alle {{count}} Archivierungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      restoreSuccess: '{{count}} Spulen wiederhergestellt',
+      restoreFailed: 'Sammelwiederherstellung fehlgeschlagen',
+      restorePartial: '{{ok}} Spulen wiederhergestellt, {{failed}} fehlgeschlagen',
+      restoreAllFailed: 'Alle {{count}} Wiederherstellungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      invalidHex: '6 Hex-Zeichen (RRGGBB) oder 8 (RRGGBBAA) eingeben. Anderenfalls wird das Feld nicht übernommen.',
+    },
     spoolmanMixedContentTitle: 'Spoolman lässt sich nicht über HTTPS laden — Browser blockiert gemischte Inhalte',
     spoolmanMixedContentTitle: 'Spoolman lässt sich nicht über HTTPS laden — Browser blockiert gemischte Inhalte',
     spoolmanMixedContentBody: 'Bambuddy wird über HTTPS ausgeliefert (über deinen Reverse-Proxy), aber deine Spoolman-URL ist nach wie vor HTTP. Browser blockieren gemischte Inhalte aus Sicherheitsgründen, daher kann die eingebettete Spoolman-Oberfläche nicht geladen werden. Spoolman muss ebenfalls über HTTPS erreichbar sein.',
     spoolmanMixedContentBody: 'Bambuddy wird über HTTPS ausgeliefert (über deinen Reverse-Proxy), aber deine Spoolman-URL ist nach wie vor HTTP. Browser blockieren gemischte Inhalte aus Sicherheitsgründen, daher kann die eingebettete Spoolman-Oberfläche nicht geladen werden. Spoolman muss ebenfalls über HTTPS erreichbar sein.',
     spoolmanMixedContentFixReverseProxy: 'Stelle Spoolman hinter denselben Reverse-Proxy wie Bambuddy (Traefik / Nginx / Caddy) mit HTTPS und aktualisiere die Spoolman-URL in den Einstellungen auf die neue HTTPS-Adresse.',
     spoolmanMixedContentFixReverseProxy: 'Stelle Spoolman hinter denselben Reverse-Proxy wie Bambuddy (Traefik / Nginx / Caddy) mit HTTPS und aktualisiere die Spoolman-URL in den Einstellungen auf die neue HTTPS-Adresse.',

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

@@ -3865,6 +3865,52 @@ export default {
     unknownSpoolTitle: 'New filament detected',
     unknownSpoolTitle: 'New filament detected',
     unknownSpoolMessage: 'A spool with an unknown RFID tag was detected at {{location}}. Add it to your inventory now?',
     unknownSpoolMessage: 'A spool with an unknown RFID tag was detected at {{location}}. Add it to your inventory now?',
     unknownSpoolSlot: 'Slot',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Select all visible',
+      selectRow: 'Select row',
+      selectGroup: 'Select group',
+      selectionCount: '{{count}} selected',
+      edit: 'Edit',
+      printLabels: 'Print labels',
+      resetUsage: 'Reset usage',
+      restore: 'Restore',
+      archive: 'Archive',
+      delete: 'Delete',
+      clearSelection: 'Clear selection',
+      editTitle: 'Bulk edit spools',
+      editSubtitle: 'Applies to {{count}} selected spools. Only fields you tick get updated.',
+      editHint: 'Type into a field to mark it for update — only ticked rows are sent. Leaving a field empty leaves the spools unchanged (clearing fields is per-spool only).',
+      useCustom: 'Use "{{value}}"',
+      toggleField: 'Toggle update for this field',
+      changeCount: '{{count}} fields will be updated.',
+      applyPending: 'Applying...',
+      applyButton: 'Apply to {{count}} spools',
+      deleteTitle: 'Delete selected spools',
+      archiveTitle: 'Archive selected spools',
+      restoreTitle: 'Restore selected spools',
+      resetUsageTitle: 'Reset usage on selected spools',
+      deleteMessage: 'Permanently delete {{count}} spools? This cannot be undone.',
+      archiveMessage: 'Archive {{count}} spools? They can be restored later.',
+      restoreMessage: 'Restore {{count}} archived spools?',
+      resetUsageMessage: 'Reset the "Total Consumed" counter on {{count}} spools? Remaining weight is preserved.',
+      updateSuccess: '{{count}} spools updated',
+      updateFailed: 'Bulk update failed',
+      updatePartial: '{{ok}} spools updated, {{failed}} failed',
+      updateAllFailed: 'All {{count}} spool updates failed — selection kept so you can retry',
+      deleteSuccess: '{{count}} spools deleted',
+      deleteFailed: 'Bulk delete failed',
+      deletePartial: '{{ok}} spools deleted, {{failed}} failed',
+      deleteAllFailed: 'All {{count}} spool deletions failed — selection kept so you can retry',
+      archiveSuccess: '{{count}} spools archived',
+      archiveFailed: 'Bulk archive failed',
+      archivePartial: '{{ok}} spools archived, {{failed}} failed',
+      archiveAllFailed: 'All {{count}} spool archives failed — selection kept so you can retry',
+      restoreSuccess: '{{count}} spools restored',
+      restoreFailed: 'Bulk restore failed',
+      restorePartial: '{{ok}} spools restored, {{failed}} failed',
+      restoreAllFailed: 'All {{count}} spool restores failed — selection kept so you can retry',
+      invalidHex: 'Enter 6 hex characters (RRGGBB) or 8 (RRGGBBAA). The field will not be applied otherwise.',
+    },
     spoolmanMixedContentTitle: 'Spoolman can\'t load over HTTPS — mixed-content blocked by your browser',
     spoolmanMixedContentTitle: 'Spoolman can\'t load over HTTPS — mixed-content blocked by your browser',
     spoolmanMixedContentBody: 'Bambuddy is served over HTTPS (via your reverse proxy), but your Spoolman URL is still plain HTTP. Browsers block mixed content for security, so the embedded Spoolman UI can\'t render. Spoolman needs to be reachable over HTTPS for this to work.',
     spoolmanMixedContentBody: 'Bambuddy is served over HTTPS (via your reverse proxy), but your Spoolman URL is still plain HTTP. Browsers block mixed content for security, so the embedded Spoolman UI can\'t render. Spoolman needs to be reachable over HTTPS for this to work.',
     spoolmanMixedContentFixReverseProxy: 'Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.',
     spoolmanMixedContentFixReverseProxy: 'Put Spoolman behind the same reverse proxy as Bambuddy (Traefik / Nginx / Caddy) with HTTPS, then update the Spoolman URL in Settings to the new HTTPS address.',

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

@@ -3853,6 +3853,52 @@ export default {
     unknownSpoolTitle: 'Nuevo filamento detectado',
     unknownSpoolTitle: 'Nuevo filamento detectado',
     unknownSpoolMessage: 'Se ha detectado una bobina con una etiqueta RFID desconocida en {{location}}. ¿Añadirla a su inventario ahora?',
     unknownSpoolMessage: 'Se ha detectado una bobina con una etiqueta RFID desconocida en {{location}}. ¿Añadirla a su inventario ahora?',
     unknownSpoolSlot: 'Ranura',
     unknownSpoolSlot: 'Ranura',
+    bulk: {
+      selectAllVisible: 'Seleccionar todo lo visible',
+      selectRow: 'Seleccionar fila',
+      selectGroup: 'Seleccionar grupo',
+      selectionCount: '{{count}} seleccionadas',
+      edit: 'Editar',
+      printLabels: 'Imprimir etiquetas',
+      resetUsage: 'Restablecer uso',
+      restore: 'Restaurar',
+      archive: 'Archivar',
+      delete: 'Eliminar',
+      clearSelection: 'Limpiar selección',
+      editTitle: 'Edición masiva de bobinas',
+      editSubtitle: 'Se aplica a {{count}} bobinas seleccionadas. Solo se actualizan los campos marcados.',
+      editHint: 'Escriba en un campo para marcarlo para actualización — solo se envían las filas marcadas. Los campos vacíos no cambian las bobinas (vaciar campos solo se hace por bobina).',
+      useCustom: 'Usar "{{value}}"',
+      toggleField: 'Activar la actualización de este campo',
+      changeCount: 'Se actualizarán {{count}} campos.',
+      applyPending: 'Aplicando...',
+      applyButton: 'Aplicar a {{count}} bobinas',
+      deleteTitle: 'Eliminar bobinas seleccionadas',
+      archiveTitle: 'Archivar bobinas seleccionadas',
+      restoreTitle: 'Restaurar bobinas seleccionadas',
+      resetUsageTitle: 'Restablecer uso de bobinas seleccionadas',
+      deleteMessage: '¿Eliminar permanentemente {{count}} bobinas? Esto no se puede deshacer.',
+      archiveMessage: '¿Archivar {{count}} bobinas? Se pueden restaurar más tarde.',
+      restoreMessage: '¿Restaurar {{count}} bobinas archivadas?',
+      resetUsageMessage: '¿Restablecer el contador "Total consumido" en {{count}} bobinas? El peso restante se conserva.',
+      updateSuccess: '{{count}} bobinas actualizadas',
+      updateFailed: 'Actualización masiva fallida',
+      updatePartial: '{{ok}} bobinas actualizadas, {{failed}} fallidas',
+      updateAllFailed: 'Las {{count}} actualizaciones fallaron — la selección se mantiene para reintentar',
+      deleteSuccess: '{{count}} bobinas eliminadas',
+      deleteFailed: 'Eliminación masiva fallida',
+      deletePartial: '{{ok}} bobinas eliminadas, {{failed}} fallidas',
+      deleteAllFailed: 'Las {{count}} eliminaciones fallaron — la selección se mantiene para reintentar',
+      archiveSuccess: '{{count}} bobinas archivadas',
+      archiveFailed: 'Archivado masivo fallido',
+      archivePartial: '{{ok}} bobinas archivadas, {{failed}} fallidas',
+      archiveAllFailed: 'Los {{count}} archivados fallaron — la selección se mantiene para reintentar',
+      restoreSuccess: '{{count}} bobinas restauradas',
+      restoreFailed: 'Restauración masiva fallida',
+      restorePartial: '{{ok}} bobinas restauradas, {{failed}} fallidas',
+      restoreAllFailed: 'Las {{count}} restauraciones fallaron — la selección se mantiene para reintentar',
+      invalidHex: 'Introduzca 6 caracteres hex (RRGGBB) u 8 (RRGGBBAA). De lo contrario el campo no se aplicará.',
+    },
     spoolmanMixedContentTitle: 'Spoolman no se puede cargar por HTTPS — contenido mixto bloqueado por su navegador',
     spoolmanMixedContentTitle: 'Spoolman no se puede cargar por HTTPS — contenido mixto bloqueado por su navegador',
     spoolmanMixedContentBody: 'Bambuddy se sirve por HTTPS (mediante su proxy inverso), pero su URL de Spoolman sigue siendo HTTP sin cifrar. Los navegadores bloquean el contenido mixto por seguridad, por lo que la interfaz integrada de Spoolman no se puede mostrar. Spoolman debe ser accesible por HTTPS para que esto funcione.',
     spoolmanMixedContentBody: 'Bambuddy se sirve por HTTPS (mediante su proxy inverso), pero su URL de Spoolman sigue siendo HTTP sin cifrar. Los navegadores bloquean el contenido mixto por seguridad, por lo que la interfaz integrada de Spoolman no se puede mostrar. Spoolman debe ser accesible por HTTPS para que esto funcione.',
     spoolmanMixedContentFixReverseProxy: 'Ponga Spoolman tras el mismo proxy inverso que Bambuddy (Traefik / Nginx / Caddy) con HTTPS y luego actualice la URL de Spoolman en Ajustes a la nueva dirección HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Ponga Spoolman tras el mismo proxy inverso que Bambuddy (Traefik / Nginx / Caddy) con HTTPS y luego actualice la URL de Spoolman en Ajustes a la nueva dirección HTTPS.',

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

@@ -3839,6 +3839,52 @@ export default {
     unknownSpoolTitle: 'Nouveau filament détecté',
     unknownSpoolTitle: 'Nouveau filament détecté',
     unknownSpoolMessage: 'Une bobine avec un tag RFID inconnu a été détectée à {{location}}. L\'ajouter à votre inventaire maintenant ?',
     unknownSpoolMessage: 'Une bobine avec un tag RFID inconnu a été détectée à {{location}}. L\'ajouter à votre inventaire maintenant ?',
     unknownSpoolSlot: 'Emplacement',
     unknownSpoolSlot: 'Emplacement',
+    bulk: {
+      selectAllVisible: 'Tout sélectionner (visible)',
+      selectRow: 'Sélectionner la ligne',
+      selectGroup: 'Sélectionner le groupe',
+      selectionCount: '{{count}} sélectionnée(s)',
+      edit: 'Modifier',
+      printLabels: 'Imprimer les étiquettes',
+      resetUsage: 'Réinitialiser l\'utilisation',
+      restore: 'Restaurer',
+      archive: 'Archiver',
+      delete: 'Supprimer',
+      clearSelection: 'Effacer la sélection',
+      editTitle: 'Modification groupée des bobines',
+      editSubtitle: 'S\'applique à {{count}} bobines sélectionnées. Seuls les champs cochés seront mis à jour.',
+      editHint: 'Tapez dans un champ pour le marquer comme à mettre à jour — seules les lignes cochées sont envoyées. Un champ laissé vide laisse les bobines inchangées (vider un champ se fait par bobine).',
+      useCustom: 'Utiliser « {{value}} »',
+      toggleField: 'Activer la mise à jour de ce champ',
+      changeCount: '{{count}} champ(s) seront mis à jour.',
+      applyPending: 'Application...',
+      applyButton: 'Appliquer à {{count}} bobines',
+      deleteTitle: 'Supprimer les bobines sélectionnées',
+      archiveTitle: 'Archiver les bobines sélectionnées',
+      restoreTitle: 'Restaurer les bobines sélectionnées',
+      resetUsageTitle: 'Réinitialiser l\'utilisation des bobines sélectionnées',
+      deleteMessage: 'Supprimer définitivement {{count}} bobines ? Cette action est irréversible.',
+      archiveMessage: 'Archiver {{count}} bobines ? Elles pourront être restaurées plus tard.',
+      restoreMessage: 'Restaurer {{count}} bobines archivées ?',
+      resetUsageMessage: 'Réinitialiser le compteur « Total consommé » sur {{count}} bobines ? Le poids restant est préservé.',
+      updateSuccess: '{{count}} bobines mises à jour',
+      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',
+      deleteSuccess: '{{count}} bobines supprimées',
+      deleteFailed: 'Suppression groupée échouée',
+      deletePartial: '{{ok}} bobines supprimées, {{failed}} échouées',
+      deleteAllFailed: 'Les {{count}} suppressions ont échoué — la sélection est conservée pour réessayer',
+      archiveSuccess: '{{count}} bobines archivées',
+      archiveFailed: 'Archivage groupé échoué',
+      archivePartial: '{{ok}} bobines archivées, {{failed}} échouées',
+      archiveAllFailed: 'Les {{count}} archivages ont échoué — la sélection est conservée pour réessayer',
+      restoreSuccess: '{{count}} bobines restaurées',
+      restoreFailed: 'Restauration groupée échouée',
+      restorePartial: '{{ok}} bobines restaurées, {{failed}} échouées',
+      restoreAllFailed: 'Les {{count}} restaurations ont échoué — la sélection est conservée pour réessayer',
+      invalidHex: 'Saisissez 6 caractères hex (RRVVBB) ou 8 (RRVVBBAA). Sinon le champ ne sera pas appliqué.',
+    },
     spoolmanMixedContentTitle: 'Spoolman ne peut pas se charger en HTTPS — contenu mixte bloqué par votre navigateur',
     spoolmanMixedContentTitle: 'Spoolman ne peut pas se charger en HTTPS — contenu mixte bloqué par votre navigateur',
     spoolmanMixedContentBody: 'Bambuddy est servi en HTTPS (via votre reverse proxy), mais votre URL Spoolman est encore en HTTP. Les navigateurs bloquent le contenu mixte pour des raisons de sécurité, donc l\'interface Spoolman intégrée ne peut pas s\'afficher. Spoolman doit être accessible en HTTPS.',
     spoolmanMixedContentBody: 'Bambuddy est servi en HTTPS (via votre reverse proxy), mais votre URL Spoolman est encore en HTTP. Les navigateurs bloquent le contenu mixte pour des raisons de sécurité, donc l\'interface Spoolman intégrée ne peut pas s\'afficher. Spoolman doit être accessible en HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Placez Spoolman derrière le même reverse proxy que Bambuddy (Traefik / Nginx / Caddy) en HTTPS, puis mettez à jour l\'URL Spoolman dans les Paramètres avec la nouvelle adresse HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Placez Spoolman derrière le même reverse proxy que Bambuddy (Traefik / Nginx / Caddy) en HTTPS, puis mettez à jour l\'URL Spoolman dans les Paramètres avec la nouvelle adresse HTTPS.',

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

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: 'Nuovo filamento rilevato',
     unknownSpoolTitle: 'Nuovo filamento rilevato',
     unknownSpoolMessage: 'È stata rilevata una bobina con tag RFID sconosciuto in {{location}}. Aggiungerla all\'inventario ora?',
     unknownSpoolMessage: 'È stata rilevata una bobina con tag RFID sconosciuto in {{location}}. Aggiungerla all\'inventario ora?',
     unknownSpoolSlot: 'Slot',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Seleziona tutto il visibile',
+      selectRow: 'Seleziona riga',
+      selectGroup: 'Seleziona gruppo',
+      selectionCount: '{{count}} selezionate',
+      edit: 'Modifica',
+      printLabels: 'Stampa etichette',
+      resetUsage: 'Reimposta utilizzo',
+      restore: 'Ripristina',
+      archive: 'Archivia',
+      delete: 'Elimina',
+      clearSelection: 'Cancella selezione',
+      editTitle: 'Modifica bobine in blocco',
+      editSubtitle: 'Si applica a {{count}} bobine selezionate. Vengono aggiornati solo i campi spuntati.',
+      editHint: 'Digita in un campo per contrassegnarlo per l\'aggiornamento — vengono inviate solo le righe spuntate. Un campo vuoto lascia le bobine invariate (svuotare un campo si fa per singola bobina).',
+      useCustom: 'Usa "{{value}}"',
+      toggleField: 'Attiva l\'aggiornamento per questo campo',
+      changeCount: '{{count}} campi verranno aggiornati.',
+      applyPending: 'Applicazione in corso...',
+      applyButton: 'Applica a {{count}} bobine',
+      deleteTitle: 'Elimina bobine selezionate',
+      archiveTitle: 'Archivia bobine selezionate',
+      restoreTitle: 'Ripristina bobine selezionate',
+      resetUsageTitle: 'Reimposta utilizzo delle bobine selezionate',
+      deleteMessage: 'Eliminare definitivamente {{count}} bobine? L\'operazione non può essere annullata.',
+      archiveMessage: 'Archiviare {{count}} bobine? Possono essere ripristinate in seguito.',
+      restoreMessage: 'Ripristinare {{count}} bobine archiviate?',
+      resetUsageMessage: 'Reimpostare il contatore "Totale consumato" su {{count}} bobine? Il peso rimanente viene preservato.',
+      updateSuccess: '{{count}} bobine aggiornate',
+      updateFailed: 'Aggiornamento in blocco fallito',
+      updatePartial: '{{ok}} bobine aggiornate, {{failed}} fallite',
+      updateAllFailed: 'Tutti i {{count}} aggiornamenti sono falliti — la selezione viene mantenuta per riprovare',
+      deleteSuccess: '{{count}} bobine eliminate',
+      deleteFailed: 'Eliminazione in blocco fallita',
+      deletePartial: '{{ok}} bobine eliminate, {{failed}} fallite',
+      deleteAllFailed: 'Tutte le {{count}} eliminazioni sono fallite — la selezione viene mantenuta per riprovare',
+      archiveSuccess: '{{count}} bobine archiviate',
+      archiveFailed: 'Archiviazione in blocco fallita',
+      archivePartial: '{{ok}} bobine archiviate, {{failed}} fallite',
+      archiveAllFailed: 'Tutte le {{count}} archiviazioni sono fallite — la selezione viene mantenuta per riprovare',
+      restoreSuccess: '{{count}} bobine ripristinate',
+      restoreFailed: 'Ripristino in blocco fallito',
+      restorePartial: '{{ok}} bobine ripristinate, {{failed}} fallite',
+      restoreAllFailed: 'Tutti i {{count}} ripristini sono falliti — la selezione viene mantenuta per riprovare',
+      invalidHex: 'Inserisci 6 caratteri esadecimali (RRGGBB) o 8 (RRGGBBAA). Altrimenti il campo non verrà applicato.',
+    },
     spoolmanMixedContentTitle: 'Spoolman non può essere caricato tramite HTTPS — contenuto misto bloccato dal browser',
     spoolmanMixedContentTitle: 'Spoolman non può essere caricato tramite HTTPS — contenuto misto bloccato dal browser',
     spoolmanMixedContentBody: 'Bambuddy viene servito tramite HTTPS (dietro il tuo reverse proxy), ma l\'URL di Spoolman è ancora HTTP. I browser bloccano il contenuto misto per motivi di sicurezza, quindi l\'interfaccia Spoolman incorporata non può essere visualizzata. Anche Spoolman deve essere raggiungibile via HTTPS.',
     spoolmanMixedContentBody: 'Bambuddy viene servito tramite HTTPS (dietro il tuo reverse proxy), ma l\'URL di Spoolman è ancora HTTP. I browser bloccano il contenuto misto per motivi di sicurezza, quindi l\'interfaccia Spoolman incorporata non può essere visualizzata. Anche Spoolman deve essere raggiungibile via HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Metti Spoolman dietro lo stesso reverse proxy di Bambuddy (Traefik / Nginx / Caddy) in HTTPS, poi aggiorna l\'URL di Spoolman nelle Impostazioni con il nuovo indirizzo HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Metti Spoolman dietro lo stesso reverse proxy di Bambuddy (Traefik / Nginx / Caddy) in HTTPS, poi aggiorna l\'URL di Spoolman nelle Impostazioni con il nuovo indirizzo HTTPS.',

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

@@ -3850,6 +3850,52 @@ export default {
     unknownSpoolTitle: '新しいフィラメントを検出',
     unknownSpoolTitle: '新しいフィラメントを検出',
     unknownSpoolMessage: '{{location}} で不明なRFIDタグのスプールが検出されました。今すぐ在庫に追加しますか?',
     unknownSpoolMessage: '{{location}} で不明なRFIDタグのスプールが検出されました。今すぐ在庫に追加しますか?',
     unknownSpoolSlot: 'スロット',
     unknownSpoolSlot: 'スロット',
+    bulk: {
+      selectAllVisible: '表示中をすべて選択',
+      selectRow: '行を選択',
+      selectGroup: 'グループを選択',
+      selectionCount: '{{count}} 件選択中',
+      edit: '編集',
+      printLabels: 'ラベルを印刷',
+      resetUsage: '使用量をリセット',
+      restore: '復元',
+      archive: 'アーカイブ',
+      delete: '削除',
+      clearSelection: '選択を解除',
+      editTitle: 'スプールを一括編集',
+      editSubtitle: '選択中の {{count}} 件に適用されます。チェックを入れた項目のみ更新されます。',
+      editHint: '項目に入力すると更新対象としてマークされます。チェック済みの行のみ送信されます。空のままだとスプールは変更されません(項目を空にする操作はスプール個別の編集で行ってください)。',
+      useCustom: '"{{value}}" を使用',
+      toggleField: 'この項目の更新を切り替え',
+      changeCount: '{{count}} 件の項目が更新されます。',
+      applyPending: '適用中...',
+      applyButton: '{{count}} 件に適用',
+      deleteTitle: '選択したスプールを削除',
+      archiveTitle: '選択したスプールをアーカイブ',
+      restoreTitle: '選択したスプールを復元',
+      resetUsageTitle: '選択したスプールの使用量をリセット',
+      deleteMessage: '{{count}} 件のスプールを完全に削除しますか?元に戻せません。',
+      archiveMessage: '{{count}} 件のスプールをアーカイブしますか?後で復元できます。',
+      restoreMessage: '{{count}} 件のアーカイブ済みスプールを復元しますか?',
+      resetUsageMessage: '{{count}} 件のスプールの「累計使用量」カウンターをリセットしますか?残量は保持されます。',
+      updateSuccess: '{{count}} 件のスプールを更新しました',
+      updateFailed: '一括更新に失敗しました',
+      updatePartial: '{{ok}} 件更新、{{failed}} 件失敗',
+      updateAllFailed: '{{count}} 件すべての更新に失敗しました — 再試行のため選択は維持されます',
+      deleteSuccess: '{{count}} 件のスプールを削除しました',
+      deleteFailed: '一括削除に失敗しました',
+      deletePartial: '{{ok}} 件削除、{{failed}} 件失敗',
+      deleteAllFailed: '{{count}} 件すべての削除に失敗しました — 再試行のため選択は維持されます',
+      archiveSuccess: '{{count}} 件のスプールをアーカイブしました',
+      archiveFailed: '一括アーカイブに失敗しました',
+      archivePartial: '{{ok}} 件アーカイブ、{{failed}} 件失敗',
+      archiveAllFailed: '{{count}} 件すべてのアーカイブに失敗しました — 再試行のため選択は維持されます',
+      restoreSuccess: '{{count}} 件のスプールを復元しました',
+      restoreFailed: '一括復元に失敗しました',
+      restorePartial: '{{ok}} 件復元、{{failed}} 件失敗',
+      restoreAllFailed: '{{count}} 件すべての復元に失敗しました — 再試行のため選択は維持されます',
+      invalidHex: '16進数 6 文字(RRGGBB)または 8 文字(RRGGBBAA)を入力してください。それ以外の場合この項目は適用されません。',
+    },
     spoolmanMixedContentTitle: 'Spoolman を HTTPS で読み込めません — ブラウザが混在コンテンツをブロックしています',
     spoolmanMixedContentTitle: 'Spoolman を HTTPS で読み込めません — ブラウザが混在コンテンツをブロックしています',
     spoolmanMixedContentBody: 'Bambuddy はリバースプロキシ経由で HTTPS 配信されていますが、Spoolman の URL は HTTP のままです。ブラウザはセキュリティ上の理由で混在コンテンツをブロックするため、埋め込みの Spoolman UI を表示できません。Spoolman も HTTPS でアクセスできる必要があります。',
     spoolmanMixedContentBody: 'Bambuddy はリバースプロキシ経由で HTTPS 配信されていますが、Spoolman の URL は HTTP のままです。ブラウザはセキュリティ上の理由で混在コンテンツをブロックするため、埋め込みの Spoolman UI を表示できません。Spoolman も HTTPS でアクセスできる必要があります。',
     spoolmanMixedContentFixReverseProxy: 'Spoolman を Bambuddy と同じリバースプロキシ(Traefik / Nginx / Caddy)の後ろに HTTPS で配置し、設定で Spoolman URL を新しい HTTPS アドレスに更新してください。',
     spoolmanMixedContentFixReverseProxy: 'Spoolman を Bambuddy と同じリバースプロキシ(Traefik / Nginx / Caddy)の後ろに HTTPS で配置し、設定で Spoolman URL を新しい HTTPS アドレスに更新してください。',

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

@@ -3640,6 +3640,52 @@ export default {
     unknownSpoolTitle: '새 필라멘트 감지됨',
     unknownSpoolTitle: '새 필라멘트 감지됨',
     unknownSpoolMessage: '{{location}}에서 알 수 없는 RFID 태그가 있는 스풀이 감지되었습니다. 지금 인벤토리에 추가하시겠습니까?',
     unknownSpoolMessage: '{{location}}에서 알 수 없는 RFID 태그가 있는 스풀이 감지되었습니다. 지금 인벤토리에 추가하시겠습니까?',
     unknownSpoolSlot: '슬롯',
     unknownSpoolSlot: '슬롯',
+    bulk: {
+      selectAllVisible: '표시된 항목 모두 선택',
+      selectRow: '행 선택',
+      selectGroup: '그룹 선택',
+      selectionCount: '{{count}}개 선택됨',
+      edit: '편집',
+      printLabels: '라벨 인쇄',
+      resetUsage: '사용량 재설정',
+      restore: '복원',
+      archive: '보관',
+      delete: '삭제',
+      clearSelection: '선택 해제',
+      editTitle: '스풀 일괄 편집',
+      editSubtitle: '선택한 {{count}}개의 스풀에 적용됩니다. 체크한 필드만 업데이트됩니다.',
+      editHint: '필드에 입력하면 업데이트할 항목으로 표시됩니다 — 체크된 행만 전송됩니다. 빈 필드는 스풀을 변경하지 않습니다 (필드 비우기는 개별 스풀에서만 가능).',
+      useCustom: '"{{value}}" 사용',
+      toggleField: '이 필드의 업데이트 전환',
+      changeCount: '{{count}}개의 필드가 업데이트됩니다.',
+      applyPending: '적용 중...',
+      applyButton: '{{count}}개에 적용',
+      deleteTitle: '선택한 스풀 삭제',
+      archiveTitle: '선택한 스풀 보관',
+      restoreTitle: '선택한 스풀 복원',
+      resetUsageTitle: '선택한 스풀의 사용량 재설정',
+      deleteMessage: '{{count}}개의 스풀을 영구적으로 삭제하시겠습니까? 되돌릴 수 없습니다.',
+      archiveMessage: '{{count}}개의 스풀을 보관하시겠습니까? 나중에 복원할 수 있습니다.',
+      restoreMessage: '{{count}}개의 보관된 스풀을 복원하시겠습니까?',
+      resetUsageMessage: '{{count}}개의 스풀에서 "총 사용량" 카운터를 재설정하시겠습니까? 남은 무게는 보존됩니다.',
+      updateSuccess: '{{count}}개의 스풀 업데이트됨',
+      updateFailed: '일괄 업데이트 실패',
+      updatePartial: '{{ok}}개 업데이트, {{failed}}개 실패',
+      updateAllFailed: '{{count}}개의 업데이트가 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      deleteSuccess: '{{count}}개의 스풀 삭제됨',
+      deleteFailed: '일괄 삭제 실패',
+      deletePartial: '{{ok}}개 삭제, {{failed}}개 실패',
+      deleteAllFailed: '{{count}}개의 삭제가 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      archiveSuccess: '{{count}}개의 스풀 보관됨',
+      archiveFailed: '일괄 보관 실패',
+      archivePartial: '{{ok}}개 보관, {{failed}}개 실패',
+      archiveAllFailed: '{{count}}개의 보관이 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      restoreSuccess: '{{count}}개의 스풀 복원됨',
+      restoreFailed: '일괄 복원 실패',
+      restorePartial: '{{ok}}개 복원, {{failed}}개 실패',
+      restoreAllFailed: '{{count}}개의 복원이 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      invalidHex: '16진수 6자(RRGGBB) 또는 8자(RRGGBBAA)를 입력하세요. 그렇지 않으면 필드가 적용되지 않습니다.',
+    },
     spoolmanMixedContentTitle: 'HTTPS에서 Spoolman을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',
     spoolmanMixedContentTitle: 'HTTPS에서 Spoolman을 불러올 수 없음 — 브라우저가 혼합 콘텐츠를 차단함',
     spoolmanMixedContentBody: 'Bambuddy가 HTTPS로 서비스되고 있지만 Spoolman URL은 여전히 HTTP입니다. 브라우저는 보안상 혼합 콘텐츠를 차단하므로 내장된 Spoolman UI가 렌더링되지 않습니다. 이 기능이 작동하려면 Spoolman이 HTTPS로 접근 가능해야 합니다.',
     spoolmanMixedContentBody: 'Bambuddy가 HTTPS로 서비스되고 있지만 Spoolman URL은 여전히 HTTP입니다. 브라우저는 보안상 혼합 콘텐츠를 차단하므로 내장된 Spoolman UI가 렌더링되지 않습니다. 이 기능이 작동하려면 Spoolman이 HTTPS로 접근 가능해야 합니다.',
     spoolmanMixedContentFixReverseProxy: 'Spoolman을 Bambuddy와 같은 리버스 프록시(Traefik / Nginx / Caddy) 뒤에 HTTPS로 배치한 다음 설정에서 Spoolman URL을 새 HTTPS 주소로 업데이트하세요.',
     spoolmanMixedContentFixReverseProxy: 'Spoolman을 Bambuddy와 같은 리버스 프록시(Traefik / Nginx / Caddy) 뒤에 HTTPS로 배치한 다음 설정에서 Spoolman URL을 새 HTTPS 주소로 업데이트하세요.',

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

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: 'Novo filamento detectado',
     unknownSpoolTitle: 'Novo filamento detectado',
     unknownSpoolMessage: 'Um carretel com tag RFID desconhecido foi detectado em {{location}}. Adicioná-lo ao seu inventário agora?',
     unknownSpoolMessage: 'Um carretel com tag RFID desconhecido foi detectado em {{location}}. Adicioná-lo ao seu inventário agora?',
     unknownSpoolSlot: 'Slot',
     unknownSpoolSlot: 'Slot',
+    bulk: {
+      selectAllVisible: 'Selecionar todos os visíveis',
+      selectRow: 'Selecionar linha',
+      selectGroup: 'Selecionar grupo',
+      selectionCount: '{{count}} selecionados',
+      edit: 'Editar',
+      printLabels: 'Imprimir etiquetas',
+      resetUsage: 'Redefinir uso',
+      restore: 'Restaurar',
+      archive: 'Arquivar',
+      delete: 'Excluir',
+      clearSelection: 'Limpar seleção',
+      editTitle: 'Editar carretéis em massa',
+      editSubtitle: 'Aplica-se a {{count}} carretéis selecionados. Apenas os campos marcados serão atualizados.',
+      editHint: 'Digite em um campo para marcá-lo para atualização — apenas linhas marcadas são enviadas. Deixar um campo vazio mantém os carretéis inalterados (limpar campos só é feito por carretel).',
+      useCustom: 'Usar "{{value}}"',
+      toggleField: 'Ativar atualização deste campo',
+      changeCount: '{{count}} campos serão atualizados.',
+      applyPending: 'Aplicando...',
+      applyButton: 'Aplicar a {{count}} carretéis',
+      deleteTitle: 'Excluir carretéis selecionados',
+      archiveTitle: 'Arquivar carretéis selecionados',
+      restoreTitle: 'Restaurar carretéis selecionados',
+      resetUsageTitle: 'Redefinir uso dos carretéis selecionados',
+      deleteMessage: 'Excluir permanentemente {{count}} carretéis? Esta ação não pode ser desfeita.',
+      archiveMessage: 'Arquivar {{count}} carretéis? Eles podem ser restaurados depois.',
+      restoreMessage: 'Restaurar {{count}} carretéis arquivados?',
+      resetUsageMessage: 'Redefinir o contador "Total Consumido" em {{count}} carretéis? O peso restante é preservado.',
+      updateSuccess: '{{count}} carretéis atualizados',
+      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',
+      deleteSuccess: '{{count}} carretéis excluídos',
+      deleteFailed: 'Exclusão em massa falhou',
+      deletePartial: '{{ok}} carretéis excluídos, {{failed}} falharam',
+      deleteAllFailed: 'Todas as {{count}} exclusões falharam — a seleção é mantida para nova tentativa',
+      archiveSuccess: '{{count}} carretéis arquivados',
+      archiveFailed: 'Arquivamento em massa falhou',
+      archivePartial: '{{ok}} carretéis arquivados, {{failed}} falharam',
+      archiveAllFailed: 'Todos os {{count}} arquivamentos falharam — a seleção é mantida para nova tentativa',
+      restoreSuccess: '{{count}} carretéis restaurados',
+      restoreFailed: 'Restauração em massa falhou',
+      restorePartial: '{{ok}} carretéis restaurados, {{failed}} falharam',
+      restoreAllFailed: 'Todas as {{count}} restaurações falharam — a seleção é mantida para nova tentativa',
+      invalidHex: 'Digite 6 caracteres hex (RRGGBB) ou 8 (RRGGBBAA). Caso contrário o campo não será aplicado.',
+    },
     spoolmanMixedContentTitle: 'Spoolman não pode carregar em HTTPS — conteúdo misto bloqueado pelo navegador',
     spoolmanMixedContentTitle: 'Spoolman não pode carregar em HTTPS — conteúdo misto bloqueado pelo navegador',
     spoolmanMixedContentBody: 'O Bambuddy é servido via HTTPS (pelo seu reverse proxy), mas a URL do Spoolman ainda é HTTP. Os navegadores bloqueiam conteúdo misto por segurança, então a interface embutida do Spoolman não consegue carregar. O Spoolman também precisa estar acessível via HTTPS.',
     spoolmanMixedContentBody: 'O Bambuddy é servido via HTTPS (pelo seu reverse proxy), mas a URL do Spoolman ainda é HTTP. Os navegadores bloqueiam conteúdo misto por segurança, então a interface embutida do Spoolman não consegue carregar. O Spoolman também precisa estar acessível via HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Coloque o Spoolman atrás do mesmo reverse proxy do Bambuddy (Traefik / Nginx / Caddy) com HTTPS e atualize a URL do Spoolman em Configurações com o novo endereço HTTPS.',
     spoolmanMixedContentFixReverseProxy: 'Coloque o Spoolman atrás do mesmo reverse proxy do Bambuddy (Traefik / Nginx / Caddy) com HTTPS e atualize a URL do Spoolman em Configurações com o novo endereço HTTPS.',

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

@@ -3839,6 +3839,52 @@ export default {
     unknownSpoolTitle: 'Yeni filament algılandı',
     unknownSpoolTitle: 'Yeni filament algılandı',
     unknownSpoolMessage: '{{location}} konumunda bilinmeyen RFID etiketli bir makara algılandı. Şimdi envantere eklensin mi?',
     unknownSpoolMessage: '{{location}} konumunda bilinmeyen RFID etiketli bir makara algılandı. Şimdi envantere eklensin mi?',
     unknownSpoolSlot: 'Yuva',
     unknownSpoolSlot: 'Yuva',
+    bulk: {
+      selectAllVisible: 'Görünenleri seç',
+      selectRow: 'Satırı seç',
+      selectGroup: 'Grubu seç',
+      selectionCount: '{{count}} seçildi',
+      edit: 'Düzenle',
+      printLabels: 'Etiket yazdır',
+      resetUsage: 'Kullanımı sıfırla',
+      restore: 'Geri yükle',
+      archive: 'Arşivle',
+      delete: 'Sil',
+      clearSelection: 'Seçimi temizle',
+      editTitle: 'Makaraları toplu düzenle',
+      editSubtitle: 'Seçilen {{count}} makaraya uygulanır. Yalnızca işaretlediğiniz alanlar güncellenir.',
+      editHint: 'Bir alana yazmak onu güncelleme için işaretler — yalnızca işaretli satırlar gönderilir. Boş bırakılan alanlar makaraları değiştirmez (alanları boşaltmak yalnızca makara bazında yapılabilir).',
+      useCustom: '"{{value}}" kullan',
+      toggleField: 'Bu alanın güncellenmesini değiştir',
+      changeCount: '{{count}} alan güncellenecek.',
+      applyPending: 'Uygulanıyor...',
+      applyButton: '{{count}} makaraya uygula',
+      deleteTitle: 'Seçili makaraları sil',
+      archiveTitle: 'Seçili makaraları arşivle',
+      restoreTitle: 'Seçili makaraları geri yükle',
+      resetUsageTitle: 'Seçili makaraların kullanımını sıfırla',
+      deleteMessage: '{{count}} makara kalıcı olarak silinsin mi? Bu işlem geri alınamaz.',
+      archiveMessage: '{{count}} makara arşivlensin mi? Daha sonra geri yüklenebilir.',
+      restoreMessage: '{{count}} arşivlenmiş makara geri yüklensin mi?',
+      resetUsageMessage: '{{count}} makaranın "Toplam Tüketim" sayacı sıfırlansın mı? Kalan ağırlık korunur.',
+      updateSuccess: '{{count}} makara güncellendi',
+      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',
+      deleteSuccess: '{{count}} makara silindi',
+      deleteFailed: 'Toplu silme başarısız',
+      deletePartial: '{{ok}} makara silindi, {{failed}} başarısız',
+      deleteAllFailed: '{{count}} silmenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      archiveSuccess: '{{count}} makara arşivlendi',
+      archiveFailed: 'Toplu arşivleme başarısız',
+      archivePartial: '{{ok}} makara arşivlendi, {{failed}} başarısız',
+      archiveAllFailed: '{{count}} arşivlemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      restoreSuccess: '{{count}} makara geri yüklendi',
+      restoreFailed: 'Toplu geri yükleme başarısız',
+      restorePartial: '{{ok}} makara geri yüklendi, {{failed}} başarısız',
+      restoreAllFailed: '{{count}} geri yüklemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      invalidHex: '6 hex karakter (RRGGBB) veya 8 (RRGGBBAA) girin. Aksi takdirde alan uygulanmaz.',
+    },
     spoolmanMixedContentTitle: 'Spoolman HTTPS üzerinden yüklenemiyor — tarayıcınız tarafından karışık içerik engellendi',
     spoolmanMixedContentTitle: 'Spoolman HTTPS üzerinden yüklenemiyor — tarayıcınız tarafından karışık içerik engellendi',
     spoolmanMixedContentBody: 'Bambuddy HTTPS üzerinden sunuluyor (ters proxy\'niz aracılığıyla), ancak Spoolman URL\'niz hâlâ düz HTTP. Tarayıcılar güvenlik için karışık içeriği engeller, bu nedenle gömülü Spoolman arayüzü oluşturulamaz. Bunun çalışması için Spoolman\'in HTTPS üzerinden erişilebilir olması gerekiyor.',
     spoolmanMixedContentBody: 'Bambuddy HTTPS üzerinden sunuluyor (ters proxy\'niz aracılığıyla), ancak Spoolman URL\'niz hâlâ düz HTTP. Tarayıcılar güvenlik için karışık içeriği engeller, bu nedenle gömülü Spoolman arayüzü oluşturulamaz. Bunun çalışması için Spoolman\'in HTTPS üzerinden erişilebilir olması gerekiyor.',
     spoolmanMixedContentFixReverseProxy: "Spoolman'i Bambuddy ile aynı ters proxy'nin (Traefik / Nginx / Caddy) arkasına HTTPS ile koyun, ardından Ayarlardaki Spoolman URL'sini yeni HTTPS adresine güncelleyin.",
     spoolmanMixedContentFixReverseProxy: "Spoolman'i Bambuddy ile aynı ters proxy'nin (Traefik / Nginx / Caddy) arkasına HTTPS ile koyun, ardından Ayarlardaki Spoolman URL'sini yeni HTTPS adresine güncelleyin.",

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

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: '检测到新耗材',
     unknownSpoolTitle: '检测到新耗材',
     unknownSpoolMessage: '在 {{location}} 检测到带有未知 RFID 标签的料盘。是否立即添加到库存?',
     unknownSpoolMessage: '在 {{location}} 检测到带有未知 RFID 标签的料盘。是否立即添加到库存?',
     unknownSpoolSlot: '槽位',
     unknownSpoolSlot: '槽位',
+    bulk: {
+      selectAllVisible: '选择所有可见项',
+      selectRow: '选择行',
+      selectGroup: '选择组',
+      selectionCount: '已选 {{count}} 个',
+      edit: '编辑',
+      printLabels: '打印标签',
+      resetUsage: '重置使用量',
+      restore: '恢复',
+      archive: '归档',
+      delete: '删除',
+      clearSelection: '清除选择',
+      editTitle: '批量编辑料盘',
+      editSubtitle: '将应用于选中的 {{count}} 个料盘。仅更新勾选的字段。',
+      editHint: '在字段中输入即可标记为更新 — 仅发送已勾选的行。留空字段不会更改料盘(清空字段仅能在单个料盘中操作)。',
+      useCustom: '使用 "{{value}}"',
+      toggleField: '切换此字段的更新',
+      changeCount: '将更新 {{count}} 个字段。',
+      applyPending: '应用中...',
+      applyButton: '应用于 {{count}} 个料盘',
+      deleteTitle: '删除选中的料盘',
+      archiveTitle: '归档选中的料盘',
+      restoreTitle: '恢复选中的料盘',
+      resetUsageTitle: '重置选中料盘的使用量',
+      deleteMessage: '永久删除 {{count}} 个料盘?此操作无法撤销。',
+      archiveMessage: '归档 {{count}} 个料盘?以后可以恢复。',
+      restoreMessage: '恢复 {{count}} 个已归档的料盘?',
+      resetUsageMessage: '将 {{count}} 个料盘的"总消耗"计数器重置?剩余重量将被保留。',
+      updateSuccess: '已更新 {{count}} 个料盘',
+      updateFailed: '批量更新失败',
+      updatePartial: '已更新 {{ok}} 个,{{failed}} 个失败',
+      updateAllFailed: '{{count}} 个料盘更新全部失败 — 已保留选择以便重试',
+      deleteSuccess: '已删除 {{count}} 个料盘',
+      deleteFailed: '批量删除失败',
+      deletePartial: '已删除 {{ok}} 个,{{failed}} 个失败',
+      deleteAllFailed: '{{count}} 个料盘删除全部失败 — 已保留选择以便重试',
+      archiveSuccess: '已归档 {{count}} 个料盘',
+      archiveFailed: '批量归档失败',
+      archivePartial: '已归档 {{ok}} 个,{{failed}} 个失败',
+      archiveAllFailed: '{{count}} 个料盘归档全部失败 — 已保留选择以便重试',
+      restoreSuccess: '已恢复 {{count}} 个料盘',
+      restoreFailed: '批量恢复失败',
+      restorePartial: '已恢复 {{ok}} 个,{{failed}} 个失败',
+      restoreAllFailed: '{{count}} 个料盘恢复全部失败 — 已保留选择以便重试',
+      invalidHex: '请输入 6 位十六进制字符 (RRGGBB) 或 8 位 (RRGGBBAA),否则该字段不会应用。',
+    },
     spoolmanMixedContentTitle: 'Spoolman 无法通过 HTTPS 加载 — 浏览器已阻止混合内容',
     spoolmanMixedContentTitle: 'Spoolman 无法通过 HTTPS 加载 — 浏览器已阻止混合内容',
     spoolmanMixedContentBody: 'Bambuddy 通过您的反向代理以 HTTPS 提供服务,但您的 Spoolman 地址仍为 HTTP。出于安全考虑,浏览器会阻止混合内容,因此嵌入式 Spoolman 界面无法加载。Spoolman 也必须通过 HTTPS 访问。',
     spoolmanMixedContentBody: 'Bambuddy 通过您的反向代理以 HTTPS 提供服务,但您的 Spoolman 地址仍为 HTTP。出于安全考虑,浏览器会阻止混合内容,因此嵌入式 Spoolman 界面无法加载。Spoolman 也必须通过 HTTPS 访问。',
     spoolmanMixedContentFixReverseProxy: '请将 Spoolman 置于与 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之后并启用 HTTPS,然后在设置中将 Spoolman URL 更新为新的 HTTPS 地址。',
     spoolmanMixedContentFixReverseProxy: '请将 Spoolman 置于与 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之后并启用 HTTPS,然后在设置中将 Spoolman URL 更新为新的 HTTPS 地址。',

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

@@ -3838,6 +3838,52 @@ export default {
     unknownSpoolTitle: '偵測到新耗材',
     unknownSpoolTitle: '偵測到新耗材',
     unknownSpoolMessage: '在 {{location}} 偵測到具有未知 RFID 標籤的料盤。是否立即新增至庫存?',
     unknownSpoolMessage: '在 {{location}} 偵測到具有未知 RFID 標籤的料盤。是否立即新增至庫存?',
     unknownSpoolSlot: '插槽',
     unknownSpoolSlot: '插槽',
+    bulk: {
+      selectAllVisible: '選擇所有可見項',
+      selectRow: '選擇列',
+      selectGroup: '選擇群組',
+      selectionCount: '已選 {{count}} 個',
+      edit: '編輯',
+      printLabels: '列印標籤',
+      resetUsage: '重設使用量',
+      restore: '還原',
+      archive: '封存',
+      delete: '刪除',
+      clearSelection: '清除選擇',
+      editTitle: '批次編輯料盤',
+      editSubtitle: '將套用於選取的 {{count}} 個料盤。僅更新勾選的欄位。',
+      editHint: '在欄位中輸入即可標記為更新 — 僅傳送已勾選的列。留空欄位不會變更料盤(清空欄位僅能在單個料盤中操作)。',
+      useCustom: '使用「{{value}}」',
+      toggleField: '切換此欄位的更新',
+      changeCount: '將更新 {{count}} 個欄位。',
+      applyPending: '套用中...',
+      applyButton: '套用於 {{count}} 個料盤',
+      deleteTitle: '刪除選取的料盤',
+      archiveTitle: '封存選取的料盤',
+      restoreTitle: '還原選取的料盤',
+      resetUsageTitle: '重設選取料盤的使用量',
+      deleteMessage: '永久刪除 {{count}} 個料盤?此操作無法復原。',
+      archiveMessage: '封存 {{count}} 個料盤?之後可以還原。',
+      restoreMessage: '還原 {{count}} 個已封存的料盤?',
+      resetUsageMessage: '重設 {{count}} 個料盤的「總消耗」計數器?剩餘重量將被保留。',
+      updateSuccess: '已更新 {{count}} 個料盤',
+      updateFailed: '批次更新失敗',
+      updatePartial: '已更新 {{ok}} 個,{{failed}} 個失敗',
+      updateAllFailed: '{{count}} 個料盤更新全部失敗 — 已保留選擇以便重試',
+      deleteSuccess: '已刪除 {{count}} 個料盤',
+      deleteFailed: '批次刪除失敗',
+      deletePartial: '已刪除 {{ok}} 個,{{failed}} 個失敗',
+      deleteAllFailed: '{{count}} 個料盤刪除全部失敗 — 已保留選擇以便重試',
+      archiveSuccess: '已封存 {{count}} 個料盤',
+      archiveFailed: '批次封存失敗',
+      archivePartial: '已封存 {{ok}} 個,{{failed}} 個失敗',
+      archiveAllFailed: '{{count}} 個料盤封存全部失敗 — 已保留選擇以便重試',
+      restoreSuccess: '已還原 {{count}} 個料盤',
+      restoreFailed: '批次還原失敗',
+      restorePartial: '已還原 {{ok}} 個,{{failed}} 個失敗',
+      restoreAllFailed: '{{count}} 個料盤還原全部失敗 — 已保留選擇以便重試',
+      invalidHex: '請輸入 6 位十六進位字元 (RRGGBB) 或 8 位 (RRGGBBAA),否則此欄位不會套用。',
+    },
     spoolmanMixedContentTitle: 'Spoolman 無法透過 HTTPS 載入 — 瀏覽器已封鎖混合內容',
     spoolmanMixedContentTitle: 'Spoolman 無法透過 HTTPS 載入 — 瀏覽器已封鎖混合內容',
     spoolmanMixedContentBody: 'Bambuddy 透過您的反向代理以 HTTPS 提供服務,但您的 Spoolman 位址仍為 HTTP。基於安全考量,瀏覽器會封鎖混合內容,因此內嵌的 Spoolman 介面無法載入。Spoolman 也必須可透過 HTTPS 存取。',
     spoolmanMixedContentBody: 'Bambuddy 透過您的反向代理以 HTTPS 提供服務,但您的 Spoolman 位址仍為 HTTP。基於安全考量,瀏覽器會封鎖混合內容,因此內嵌的 Spoolman 介面無法載入。Spoolman 也必須可透過 HTTPS 存取。',
     spoolmanMixedContentFixReverseProxy: '請將 Spoolman 置於與 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之後並啟用 HTTPS,然後在設定中將 Spoolman URL 更新為新的 HTTPS 位址。',
     spoolmanMixedContentFixReverseProxy: '請將 Spoolman 置於與 Bambuddy 相同的反向代理(Traefik / Nginx / Caddy)之後並啟用 HTTPS,然後在設定中將 Spoolman URL 更新為新的 HTTPS 位址。',

+ 326 - 2
frontend/src/pages/InventoryPage.tsx

@@ -21,6 +21,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
 import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
 import { LocationsModal } from '../components/LocationsModal';
 import { LocationsModal } from '../components/LocationsModal';
+import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { resolveSpoolColorName } from '../utils/colors';
 import { resolveSpoolColorName } from '../utils/colors';
@@ -44,6 +45,17 @@ type DisplayItem =
   | { type: 'single'; spool: InventorySpool }
   | { type: 'single'; spool: InventorySpool }
   | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
   | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
 
 
+function dedupeAndSort(values: Array<string | null | undefined>): string[] {
+  const set = new Set<string>();
+  for (const v of values) {
+    if (typeof v === 'string') {
+      const trimmed = v.trim();
+      if (trimmed) set.add(trimmed);
+    }
+  }
+  return Array.from(set).sort((a, b) => a.localeCompare(b));
+}
+
 function spoolGroupKey(s: InventorySpool): string {
 function spoolGroupKey(s: InventorySpool): string {
   // Include extra_colors + effect_type so the "Group similar" toggle does
   // Include extra_colors + effect_type so the "Group similar" toggle does
   // not collapse two spools that share the base colour but differ on
   // not collapse two spools that share the base colour but differ on
@@ -505,6 +517,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   });
   });
   const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
   const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
 
 
+  // Bulk-selection state for batch actions on the spool list (#1795).
+  // Cleared when the user switches filter/tab/page because cross-page selection
+  // produces a confusing toolbar count vs. visible-row count delta.
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [bulkEditOpen, setBulkEditOpen] = useState(false);
+  const [bulkConfirmAction, setBulkConfirmAction] = useState<'delete' | 'archive' | 'restore' | 'reset-consumed-counter' | null>(null);
+  const toggleSelected = useCallback((id: number) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  }, []);
+  const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
+
+  // Clear selection on any filter/tab change so the toolbar count stays
+  // honest vs. what the user is actually looking at.
+  useEffect(() => {
+    setSelectedIds(new Set());
+  }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search]);
+
   // Pagination state (pageSize persisted to localStorage)
   // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
   const [pageIndex, setPageIndex] = useState(0);
   const [pageSize, setPageSize] = useState(() => {
   const [pageSize, setPageSize] = useState(() => {
@@ -751,12 +785,127 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     onSuccess: (data) => {
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
       queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
       showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
       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.
+      setBulkConfirmAction(null);
+      clearSelection();
     },
     },
     onError: () => {
     onError: () => {
       showToast(t('inventory.resetConsumedCounterFailed'), 'error');
       showToast(t('inventory.resetConsumedCounterFailed'), 'error');
     },
     },
   });
   });
 
 
+  // 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);
+      return api.bulkUpdateSpools(ids, update);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      const failed = failedCount(data);
+      if (data.updated === 0) {
+        showToast(t('inventory.bulk.updateAllFailed', { count: failed }), 'error');
+        return; // keep modal open + selection intact so user can retry
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.updatePartial', { ok: data.updated, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.updateSuccess', { count: data.updated }), 'success');
+      }
+      setBulkEditOpen(false);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.updateFailed'), 'error');
+    },
+  });
+
+  const bulkDeleteMutation = useMutation({
+    mutationFn: async (ids: number[]): Promise<{ deleted: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkDeleteSpoolmanInventorySpools(ids);
+      return api.bulkDeleteSpools(ids);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      const failed = failedCount(data);
+      if (data.deleted === 0) {
+        showToast(t('inventory.bulk.deleteAllFailed', { count: failed }), 'error');
+        return;
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.deletePartial', { ok: data.deleted, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.deleteSuccess', { count: data.deleted }), 'success');
+      }
+      setBulkConfirmAction(null);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.deleteFailed'), 'error');
+    },
+  });
+
+  const bulkArchiveMutation = useMutation({
+    mutationFn: async (ids: number[]): Promise<{ archived: number; already_archived?: number[]; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkArchiveSpoolmanInventorySpools(ids);
+      return api.bulkArchiveSpools(ids);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      // already-archived rows are NOT failures — they're correctly idempotent.
+      const failed = failedCount(data);
+      if (data.archived === 0 && failed > 0) {
+        showToast(t('inventory.bulk.archiveAllFailed', { count: failed }), 'error');
+        return;
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.archivePartial', { ok: data.archived, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.archiveSuccess', { count: data.archived }), 'success');
+      }
+      setBulkConfirmAction(null);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.archiveFailed'), 'error');
+    },
+  });
+
+  const bulkRestoreMutation = useMutation({
+    mutationFn: async (ids: number[]): Promise<{ restored: number; already_active?: number[]; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
+      if (spoolmanMode) return api.bulkRestoreSpoolmanInventorySpools(ids);
+      return api.bulkRestoreSpools(ids);
+    },
+    onSuccess: (data) => {
+      refreshSpoolQueries();
+      const failed = failedCount(data);
+      if (data.restored === 0 && failed > 0) {
+        showToast(t('inventory.bulk.restoreAllFailed', { count: failed }), 'error');
+        return;
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.restorePartial', { ok: data.restored, failed }), 'warning');
+      } else {
+        showToast(t('inventory.bulk.restoreSuccess', { count: data.restored }), 'success');
+      }
+      setBulkConfirmAction(null);
+      clearSelection();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('inventory.bulk.restoreFailed'), 'error');
+    },
+  });
+
   // Spool IDs the "Reset all usage" button bulk-targets. Includes archived
   // Spool IDs the "Reset all usage" button bulk-targets. Includes archived
   // spools too — without them, the broadened "Total Consumed" stat (which
   // spools too — without them, the broadened "Total Consumed" stat (which
   // sums archived consumption per the #1390 follow-up) would stay non-zero
   // sums archived consumption per the #1390 follow-up) would stay non-zero
@@ -1660,6 +1809,54 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         )}
         )}
       </div>
       </div>
 
 
+      {/* Bulk action toolbar (#1795). Appears as soon as at least one
+          spool is selected; sticky so it stays visible while the user
+          scrolls a long list. */}
+      {selectedIds.size > 0 && viewMode !== 'forecast' && (
+        <div className="sticky top-2 z-10 mb-4 flex items-center gap-2 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg backdrop-blur-sm">
+          <span className="text-sm text-bambu-green font-medium">
+            {t('inventory.bulk.selectionCount', { count: selectedIds.size })}
+          </span>
+          <div className="ml-auto flex flex-wrap items-center gap-2">
+            <Button size="sm" variant="secondary" onClick={() => setBulkEditOpen(true)}>
+              <Edit2 className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.edit')}
+            </Button>
+            <Button size="sm" variant="secondary" onClick={() => setLabelPickerSpoolIds([...selectedIds])}>
+              <Printer className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.printLabels')}
+            </Button>
+            <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('reset-consumed-counter')}>
+              <Eraser className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.resetUsage')}
+            </Button>
+            {archiveFilter === 'archived' ? (
+              <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('restore')}>
+                <RotateCcw className="w-3.5 h-3.5 mr-1.5" />
+                {t('inventory.bulk.restore')}
+              </Button>
+            ) : (
+              <Button size="sm" variant="secondary" onClick={() => setBulkConfirmAction('archive')}>
+                <Archive className="w-3.5 h-3.5 mr-1.5" />
+                {t('inventory.bulk.archive')}
+              </Button>
+            )}
+            <Button size="sm" variant="danger" onClick={() => setBulkConfirmAction('delete')}>
+              <Trash2 className="w-3.5 h-3.5 mr-1.5" />
+              {t('inventory.bulk.delete')}
+            </Button>
+            <button
+              className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
+              onClick={clearSelection}
+              title={t('inventory.bulk.clearSelection')}
+              aria-label={t('inventory.bulk.clearSelection')}
+            >
+              <X className="w-4 h-4" />
+            </button>
+          </div>
+        </div>
+      )}
+
       {/* Content */}
       {/* Content */}
       {isLoading ? (
       {isLoading ? (
         <div className="flex justify-center py-16">
         <div className="flex justify-center py-16">
@@ -1787,6 +1984,34 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
               <table className="w-full">
               <table className="w-full">
                 <thead>
                 <thead>
                   <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
                   <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
+                    <th className="w-10 px-3 py-3">
+                      <input
+                        type="checkbox"
+                        className="h-4 w-4 cursor-pointer"
+                        aria-label={t('inventory.bulk.selectAllVisible')}
+                        checked={pagedItems.length > 0 && pagedItems.every((item) => {
+                          const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
+                          return ids.every((id) => selectedIds.has(id));
+                        })}
+                        onChange={(e) => {
+                          if (e.target.checked) {
+                            const next = new Set(selectedIds);
+                            for (const item of pagedItems) {
+                              const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
+                              for (const id of ids) next.add(id);
+                            }
+                            setSelectedIds(next);
+                          } else {
+                            const next = new Set(selectedIds);
+                            for (const item of pagedItems) {
+                              const ids = item.type === 'group' ? item.spools.map((s) => s.id) : [item.spool.id];
+                              for (const id of ids) next.delete(id);
+                            }
+                            setSelectedIds(next);
+                          }
+                        }}
+                      />
+                    </th>
                     {visibleColumns.map((colId) => {
                     {visibleColumns.map((colId) => {
                       const sortable = !!columnSortValues[colId];
                       const sortable = !!columnSortValues[colId];
                       const isActive = sortState?.column === colId;
                       const isActive = sortState?.column === colId;
@@ -1833,6 +2058,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                           pct={pct}
                           pct={pct}
                           isExpanded={isExpanded}
                           isExpanded={isExpanded}
                           onToggle={() => toggleGroupExpand(key)}
                           onToggle={() => toggleGroupExpand(key)}
+                          selectedIds={selectedIds}
+                          onToggleSelected={toggleSelected}
+                          onToggleGroupSelected={(ids, select) => {
+                            setSelectedIds((prev) => {
+                              const next = new Set(prev);
+                              for (const id of ids) {
+                                if (select) next.add(id);
+                                else next.delete(id);
+                              }
+                              return next;
+                            });
+                          }}
                           onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
                           onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
                           onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
                           onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
@@ -1858,6 +2095,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                         spool={spool}
                         spool={spool}
                         remaining={remaining}
                         remaining={remaining}
                         pct={pct}
                         pct={pct}
+                        isSelected={selectedIds.has(spool.id)}
+                        onToggleSelected={() => toggleSelected(spool.id)}
                         onEdit={() => setFormModal({ spool, mode: 'edit' })}
                         onEdit={() => setFormModal({ spool, mode: 'edit' })}
                         onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                         onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                         onRestore={() => restoreMutation.mutate(spool.id)}
                         onRestore={() => restoreMutation.mutate(spool.id)}
@@ -2019,6 +2258,59 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         spoolmanMode={spoolmanMode}
         spoolmanMode={spoolmanMode}
       />
       />
 
 
+      <BulkEditSpoolsModal
+        isOpen={bulkEditOpen}
+        selectedCount={selectedIds.size}
+        isPending={bulkUpdateMutation.isPending}
+        availableLocations={storageLocations.map((l) => ({ id: l.id, name: l.name }))}
+        availableMaterials={dedupeAndSort((spools ?? []).map((s) => s.material))}
+        availableSubtypes={dedupeAndSort((spools ?? []).map((s) => s.subtype))}
+        availableBrands={dedupeAndSort((spools ?? []).map((s) => s.brand))}
+        availableCategories={dedupeAndSort((spools ?? []).map((s) => s.category))}
+        availableSlicerFilaments={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament))}
+        availableSlicerFilamentNames={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament_name))}
+        onClose={() => setBulkEditOpen(false)}
+        onApply={(patch) => bulkUpdateMutation.mutate({ ids: [...selectedIds], update: patch })}
+      />
+
+      {bulkConfirmAction && (
+        <ConfirmModal
+          title={
+            bulkConfirmAction === 'delete' ? t('inventory.bulk.deleteTitle') :
+            bulkConfirmAction === 'archive' ? t('inventory.bulk.archiveTitle') :
+            bulkConfirmAction === 'restore' ? t('inventory.bulk.restoreTitle') :
+            t('inventory.bulk.resetUsageTitle')
+          }
+          message={
+            bulkConfirmAction === 'delete' ? t('inventory.bulk.deleteMessage', { count: selectedIds.size }) :
+            bulkConfirmAction === 'archive' ? t('inventory.bulk.archiveMessage', { count: selectedIds.size }) :
+            bulkConfirmAction === 'restore' ? t('inventory.bulk.restoreMessage', { count: selectedIds.size }) :
+            t('inventory.bulk.resetUsageMessage', { count: selectedIds.size })
+          }
+          confirmText={
+            bulkConfirmAction === 'delete' ? t('common.delete') :
+            bulkConfirmAction === 'archive' ? t('inventory.archive') :
+            bulkConfirmAction === 'restore' ? t('inventory.restore') :
+            t('inventory.resetConsumedCounter')
+          }
+          variant={bulkConfirmAction === 'delete' ? 'danger' : 'warning'}
+          isLoading={
+            bulkDeleteMutation.isPending ||
+            bulkArchiveMutation.isPending ||
+            bulkRestoreMutation.isPending ||
+            bulkResetConsumedCounterMutation.isPending
+          }
+          onConfirm={() => {
+            const ids = [...selectedIds];
+            if (bulkConfirmAction === 'delete') bulkDeleteMutation.mutate(ids);
+            else if (bulkConfirmAction === 'archive') bulkArchiveMutation.mutate(ids);
+            else if (bulkConfirmAction === 'restore') bulkRestoreMutation.mutate(ids);
+            else bulkResetConsumedCounterMutation.mutate(ids);
+          }}
+          onCancel={() => setBulkConfirmAction(null)}
+        />
+      )}
+
       {csvImportOpen && (
       {csvImportOpen && (
         <SpoolCsvImportModal
         <SpoolCsvImportModal
           onClose={() => setCsvImportOpen(false)}
           onClose={() => setCsvImportOpen(false)}
@@ -2223,12 +2515,15 @@ function SpoolCard({
 
 
 /* Single spool row for table view */
 /* Single spool row for table view */
 function SpoolTableRow({
 function SpoolTableRow({
-  spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
+  spool, remaining, pct, isSelected, onToggleSelected,
+  onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
 }: {
   spool: InventorySpool;
   spool: InventorySpool;
   remaining: number;
   remaining: number;
   pct: number;
   pct: number;
+  isSelected?: boolean;
+  onToggleSelected?: () => void;
   onEdit: () => void;
   onEdit: () => void;
   onCopy?: () => void;
   onCopy?: () => void;
   onRestore: () => void;
   onRestore: () => void;
@@ -2248,9 +2543,20 @@ function SpoolTableRow({
     <tr
     <tr
       className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
       className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
         spool.archived_at ? 'opacity-50' : ''
         spool.archived_at ? 'opacity-50' : ''
-      }`}
+      } ${isSelected ? 'bg-bambu-green/10' : ''}`}
       onClick={onEdit}
       onClick={onEdit}
     >
     >
+      <td className="w-10 px-3 py-3" onClick={(e) => e.stopPropagation()}>
+        {onToggleSelected && (
+          <input
+            type="checkbox"
+            className="h-4 w-4 cursor-pointer"
+            aria-label={t('inventory.bulk.selectRow')}
+            checked={!!isSelected}
+            onChange={onToggleSelected}
+          />
+        )}
+      </td>
       {visibleColumns.map((colId) => (
       {visibleColumns.map((colId) => (
         <td key={colId} className="py-3 px-4">
         <td key={colId} className="py-3 px-4">
           {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
           {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
@@ -2303,6 +2609,7 @@ function SpoolTableGroup({
   spools, headerSpool, remaining, pct, isExpanded, onToggle,
   spools, headerSpool, remaining, pct, isExpanded, onToggle,
   onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
   onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
+  selectedIds, onToggleSelected, onToggleGroupSelected,
 }: {
 }: {
   spools: InventorySpool[];
   spools: InventorySpool[];
   // Aggregate of all members (summed quantities, shared identity) — rendered
   // Aggregate of all members (summed quantities, shared identity) — rendered
@@ -2325,7 +2632,11 @@ function SpoolTableGroup({
   dateFormat: DateFormat;
   dateFormat: DateFormat;
   t: TFn;
   t: TFn;
   onSyncWeight?: (spool: InventorySpool) => void;
   onSyncWeight?: (spool: InventorySpool) => void;
+  selectedIds?: Set<number>;
+  onToggleSelected?: (id: number) => void;
+  onToggleGroupSelected?: (ids: number[], select: boolean) => void;
 }) {
 }) {
+  const allMembersSelected = !!selectedIds && spools.every((s) => selectedIds.has(s.id));
   return (
   return (
     <>
     <>
       {/* Group header row */}
       {/* Group header row */}
@@ -2333,6 +2644,17 @@ function SpoolTableGroup({
         className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
         className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
         onClick={onToggle}
         onClick={onToggle}
       >
       >
+        <td className="w-10 px-3 py-3" onClick={(e) => e.stopPropagation()}>
+          {onToggleGroupSelected && (
+            <input
+              type="checkbox"
+              className="h-4 w-4 cursor-pointer"
+              aria-label={t('inventory.bulk.selectGroup')}
+              checked={allMembersSelected}
+              onChange={(e) => onToggleGroupSelected(spools.map((s) => s.id), e.target.checked)}
+            />
+          )}
+        </td>
         {visibleColumns.map((colId, idx) => (
         {visibleColumns.map((colId, idx) => (
           <td key={colId} className="py-3 px-4">
           <td key={colId} className="py-3 px-4">
             {idx === 0 ? (
             {idx === 0 ? (
@@ -2365,6 +2687,8 @@ function SpoolTableGroup({
             spool={spool}
             spool={spool}
             remaining={r}
             remaining={r}
             pct={p}
             pct={p}
+            isSelected={selectedIds?.has(spool.id)}
+            onToggleSelected={onToggleSelected ? () => onToggleSelected(spool.id) : undefined}
             onEdit={() => onEdit(spool)}
             onEdit={() => onEdit(spool)}
             onCopy={onCopy ? () => onCopy(spool) : undefined}
             onCopy={onCopy ? () => onCopy(spool) : undefined}
             onRestore={() => {}}
             onRestore={() => {}}

Некоторые файлы не были показаны из-за большого количества измененных файлов