Browse Source

fix(inventory): "Reset usage to 0" works in Spoolman mode too (#1390)

  First cut of this action only wired the built-in inventory path, so the
  eraser buttons vanished when the user switched to Spoolman mode. Mirror
  the endpoints on the Spoolman router:

  - POST /spoolman/inventory/spools/{id}/reset-usage
  - POST /spoolman/inventory/spools/reset-usage-bulk

  Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
  /spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
  guard (rejects empty/missing spool_ids), and individual Spoolman failures
  are logged + counted out without aborting the batch.

  InventoryPage mutations now switch on spoolmanMode to pick the right
  client method, and the three "spoolmanMode ? undefined : ..." gates on
  the eraser buttons are gone.
maziggy 1 week ago
parent
commit
8b9efd0160

File diff suppressed because it is too large
+ 2 - 0
CHANGELOG.md


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

@@ -1065,6 +1065,58 @@ async def restore_spool(
     return result.scalar_one()
 
 
+@router.post("/spools/{spool_id}/reset-usage", response_model=SpoolResponse)
+async def reset_spool_usage(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Zero the spool's weight_used counter without locking the spool.
+
+    Unlike PATCH /spools/{id} with weight_used=0, this endpoint does NOT
+    auto-set weight_locked — the spool keeps receiving AMS auto-sync
+    updates from the next print onward. Used to clear the accumulated
+    "Total Consumed" stat so subsequent prints track from a clean zero.
+    """
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+
+    spool.weight_used = 0
+    await db.commit()
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return result.scalar_one()
+
+
+@router.post("/spools/reset-usage-bulk")
+async def bulk_reset_spool_usage(
+    payload: dict,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Bulk-reset weight_used to 0 across the given spool IDs.
+
+    Caller passes an explicit list of IDs — no "reset all" shortcut, since
+    a typo on a wildcard would wipe the entire inventory's tracking.
+    Same semantics as the per-spool endpoint: weight_locked is left alone.
+    """
+    spool_ids = payload.get("spool_ids")
+    if not isinstance(spool_ids, list) or not spool_ids:
+        raise HTTPException(400, "spool_ids must be a non-empty list")
+    if not all(isinstance(sid, int) for sid in spool_ids):
+        raise HTTPException(400, "spool_ids must contain integers")
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
+    spools = list(result.scalars().all())
+    for spool in spools:
+        spool.weight_used = 0
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"reset": len(spools)}
+
+
 # ── K-Profiles ───────────────────────────────────────────────────────────────
 
 

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

@@ -719,6 +719,54 @@ async def restore_spool(
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
 
 
+@router.post("/spools/{spool_id}/reset-usage")
+async def reset_spool_usage(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Zero the spool's used_weight in Spoolman without touching anything else."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        spool = await client.reset_spool_usage(spool_id)
+    try:
+        return _map_spoolman_spool(spool)
+    except ValueError as exc:
+        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
+        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
+
+
+@router.post("/spools/reset-usage-bulk")
+async def bulk_reset_spool_usage(
+    payload: dict = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Bulk-reset used_weight to 0 across the given Spoolman spool IDs.
+
+    Caller passes an explicit list of IDs — no "reset all" shortcut, since
+    a typo on a wildcard would wipe the entire inventory's tracking.
+    Returns the count of spools successfully reset; individual failures are
+    logged but do not abort the batch.
+    """
+    spool_ids = payload.get("spool_ids")
+    if not isinstance(spool_ids, list) or not spool_ids:
+        raise HTTPException(status_code=400, detail="spool_ids must be a non-empty list")
+    if not all(isinstance(sid, int) for sid in spool_ids):
+        raise HTTPException(status_code=400, detail="spool_ids must contain integers")
+
+    client = await _get_client(db)
+    reset_count = 0
+    for spool_id in spool_ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.reset_spool_usage(spool_id)
+            reset_count += 1
+        except HTTPException as exc:
+            logger.warning("Spoolman reset-usage failed for spool %s: %s", spool_id, exc.detail)
+    return {"reset": reset_count}
+
+
 @router.patch("/spools/{spool_id}/weight")
 async def sync_spool_weight(
     *,

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

@@ -558,6 +558,21 @@ class SpoolmanClient:
         )
         return response.json()
 
+    async def reset_spool_usage(self, spool_id: int) -> dict:
+        """Reset a spool's used_weight to 0 in Spoolman.
+
+        Used by the per-spool / bulk "Reset usage to 0" actions on the
+        Inventory page so the Total Consumed stat can be cleared without
+        touching the rest of the spool's data.
+        """
+        response = await self._request_spool(
+            "PATCH",
+            spool_id,
+            json_body={"used_weight": 0},
+            operation="reset-usage",
+        )
+        return response.json()
+
     async def update_spool_full(
         self,
         spool_id: int,

+ 157 - 0
backend/tests/integration/test_spool_reset_usage.py

@@ -0,0 +1,157 @@
+"""Reset-usage endpoint regressions (#1390 follow-up).
+
+The per-spool and bulk reset endpoints zero `weight_used` without touching
+`weight_locked`. They exist because PATCH /spools/{id} auto-locks the spool
+when weight_used is set explicitly, and that's wrong for the "clean-slate
+my Total Consumed stat" workflow — the user wants the spool to keep
+receiving AMS auto-sync updates from the next print onward.
+"""
+
+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):
+    """Create a Spool with sensible defaults."""
+
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 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 TestResetSpoolUsage:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_zeroes_weight_used(self, async_client: AsyncClient, spool_factory, db_session):
+        """Endpoint sets weight_used to 0."""
+        spool = await spool_factory(weight_used=234.5)
+
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        assert response.status_code == 200
+        assert response.json()["weight_used"] == 0
+        await db_session.refresh(spool)
+        assert spool.weight_used == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_does_not_lock_spool(self, async_client: AsyncClient, spool_factory, db_session):
+        """Reset must leave weight_locked alone.
+
+        PATCH /spools/{id} auto-locks when weight_used is set explicitly;
+        the dedicated reset endpoint must NOT, because the user's intent
+        is "track fresh from zero", not "freeze at zero forever".
+        """
+        spool = await spool_factory(weight_used=100.0, weight_locked=False)
+
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        assert response.status_code == 200
+        await db_session.refresh(spool)
+        assert spool.weight_used == 0
+        assert spool.weight_locked is False, "Reset must not auto-lock the spool"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_preserves_existing_lock(self, async_client: AsyncClient, spool_factory, db_session):
+        """If the user previously locked the spool, the lock is preserved."""
+        spool = await spool_factory(weight_used=500.0, weight_locked=True)
+
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        assert response.status_code == 200
+        await db_session.refresh(spool)
+        assert spool.weight_used == 0
+        assert spool.weight_locked is True, "Pre-existing lock must be preserved"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/inventory/spools/99999/reset-usage")
+        assert response.status_code == 404
+
+
+class TestBulkResetSpoolUsage:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_zeroes_only_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        """Only spools in the request are reset; others are untouched."""
+        target1 = await spool_factory(weight_used=100.0)
+        target2 = await spool_factory(weight_used=200.0)
+        untouched = await spool_factory(weight_used=300.0)
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": [target1.id, target2.id]},
+        )
+
+        assert response.status_code == 200
+        assert response.json() == {"reset": 2}
+
+        # The endpoint commits via its own session — expire our session so the
+        # next read pulls fresh values rather than the cached pre-reset state.
+        db_session.expire_all()
+        spools = (await db_session.execute(select(Spool))).scalars().all()
+        by_id = {s.id: s for s in spools}
+        assert by_id[target1.id].weight_used == 0
+        assert by_id[target2.id].weight_used == 0
+        assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
+        """Empty list must be rejected — guards against accidental wildcard wipes."""
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": []},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
+        """Missing spool_ids field must be rejected."""
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_does_not_lock_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        """Bulk reset preserves weight_locked across all targets."""
+        unlocked = await spool_factory(weight_used=100.0, weight_locked=False)
+        locked = await spool_factory(weight_used=200.0, weight_locked=True)
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": [unlocked.id, locked.id]},
+        )
+
+        assert response.status_code == 200
+        await db_session.refresh(unlocked)
+        await db_session.refresh(locked)
+        assert unlocked.weight_used == 0 and unlocked.weight_locked is False
+        assert locked.weight_used == 0 and locked.weight_locked is True

+ 51 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -63,6 +63,7 @@ def mock_spoolman_client():
     mock_client.set_spool_archived = AsyncMock(
         side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
     )
+    mock_client.reset_spool_usage = AsyncMock(return_value={**SAMPLE_SPOOLMAN_SPOOL, "used_weight": 0})
     mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
     mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
     mock_client.find_or_create_filament = AsyncMock(return_value=7)
@@ -487,6 +488,56 @@ class TestSpoolmanInventoryCRUD:
         assert response.status_code == 200
         mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_spool_usage(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman."""
+        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-usage")
+
+        assert response.status_code == 200
+        assert response.json()["weight_used"] == 0
+        mock_spoolman_client.reset_spool_usage.assert_called_once_with(42)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_spool_usage(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """Bulk endpoint resets each listed spool and returns the count."""
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": [1, 2, 3]},
+        )
+
+        assert response.status_code == 200
+        assert response.json() == {"reset": 3}
+        assert mock_spoolman_client.reset_spool_usage.call_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_rejects_empty_list(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """Empty list must be rejected — guards against accidental wildcard wipes."""
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": []},
+        )
+
+        assert response.status_code == 400
+        mock_spoolman_client.reset_spool_usage.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_sync_weight(

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

@@ -4651,6 +4651,13 @@ export const api = {
     request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
   restoreSpool: (id: number) =>
     request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
+  resetSpoolUsage: (id: number) =>
+    request<InventorySpool>(`/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
+  bulkResetSpoolUsage: (spoolIds: number[]) =>
+    request<{ reset: number }>(`/inventory/spools/reset-usage-bulk`, {
+      method: 'POST',
+      body: JSON.stringify({ spool_ids: spoolIds }),
+    }),
   getSpoolKProfiles: (spoolId: number) =>
     request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
   saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
@@ -4815,6 +4822,13 @@ export const api = {
     request<InventorySpool>(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }),
   restoreSpoolmanInventorySpool: (id: number) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }),
+  resetSpoolmanInventorySpoolUsage: (id: number) =>
+    request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
+  bulkResetSpoolmanInventorySpoolUsage: (spoolIds: number[]) =>
+    request<{ reset: number }>(`/spoolman/inventory/spools/reset-usage-bulk`, {
+      method: 'POST',
+      body: JSON.stringify({ spool_ids: spoolIds }),
+    }),
   linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
       method: 'PATCH',

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

@@ -3691,6 +3691,15 @@ export default {
     inPrinter: 'Im Drucker',
     lowStock: 'Niedriger Bestand',
     sinceTracking: 'Seit Beginn der Erfassung',
+    resetUsage: 'Verbrauch auf 0 zurücksetzen',
+    resetUsageTooltip: 'Den verbrauchten Gramm-Zähler dieser Spule auf null setzen',
+    resetUsageConfirm: 'Verbrauchten Gramm-Zähler dieser Spule auf 0 zurücksetzen? Künftige Drucke zählen wieder ab null. Die Spule selbst, ihre Restgewichtsberechnung und Ihre Einstellungen bleiben unverändert.',
+    resetAllUsage: 'Verbrauch aller Spulen zurücksetzen',
+    resetAllUsageTooltip: 'Den verbrauchten Gramm-Zähler auf jeder aktiven Spule auf null setzen',
+    resetAllUsageConfirm: 'Verbrauchten Gramm-Zähler auf allen {{count}} aktiven Spulen auf 0 zurücksetzen? Das löscht den Wert „Insgesamt verbraucht“, sodass künftige Drucke wieder ab null gezählt werden. Spulen und Restgewichte bleiben unverändert.',
+    usageReset: 'Spulenverbrauch auf 0 zurückgesetzt',
+    allUsageReset: '{{count}} Spule(n) zurückgesetzt',
+    resetUsageFailed: 'Zurücksetzen des Spulenverbrauchs fehlgeschlagen',
     loadedInAms: 'Im AMS/Ext geladen',
     remaining: 'Verbleibend',
     weightCheck: 'Gewichtskontrolle',

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

@@ -3703,6 +3703,15 @@ export default {
     inPrinter: 'In Printer',
     lowStock: 'Low Stock',
     sinceTracking: 'Since tracking started',
+    resetUsage: 'Reset usage to 0',
+    resetUsageTooltip: 'Zero the consumed-grams counter for this spool',
+    resetUsageConfirm: 'Reset this spool\'s consumed-grams counter to 0? Future prints will track from zero again. The spool itself, its remaining weight calculation, and your settings are not changed.',
+    resetAllUsage: 'Reset all spool usage',
+    resetAllUsageTooltip: 'Zero the consumed-grams counter on every active spool',
+    resetAllUsageConfirm: 'Reset the consumed-grams counter to 0 on all {{count}} active spools? This clears the "Total Consumed" stat so future prints track from zero. Spools and remaining weights are not changed.',
+    usageReset: 'Spool usage reset to 0',
+    allUsageReset: 'Reset {{count}} spool(s)',
+    resetUsageFailed: 'Failed to reset spool usage',
     loadedInAms: 'Loaded in AMS/Ext',
     remaining: 'Remaining',
     weightCheck: 'Weight Check',

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

@@ -3680,6 +3680,15 @@ export default {
     inPrinter: 'Dans Imprimante',
     lowStock: 'Stock Bas',
     sinceTracking: 'Depuis le début du suivi',
+    resetUsage: 'Réinitialiser l\'usage à 0',
+    resetUsageTooltip: 'Remettre à zéro le compteur de grammes consommés pour cette bobine',
+    resetUsageConfirm: 'Remettre à 0 le compteur de grammes consommés de cette bobine ? Les futures impressions repartiront de zéro. La bobine, son calcul de poids restant et vos paramètres ne sont pas modifiés.',
+    resetAllUsage: 'Réinitialiser l\'usage de toutes les bobines',
+    resetAllUsageTooltip: 'Remettre à zéro le compteur de grammes consommés sur chaque bobine active',
+    resetAllUsageConfirm: 'Remettre à 0 le compteur de grammes consommés sur les {{count}} bobines actives ? Cela efface la valeur « Total Consommé » pour que les futures impressions repartent de zéro. Les bobines et les poids restants ne sont pas modifiés.',
+    usageReset: 'Usage de la bobine remis à 0',
+    allUsageReset: '{{count}} bobine(s) réinitialisée(s)',
+    resetUsageFailed: 'Échec de la réinitialisation de l\'usage',
     loadedInAms: 'Chargé dans AMS/Ext',
     remaining: 'Restant',
     weightCheck: 'Vérification poids',

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

@@ -3679,6 +3679,15 @@ export default {
     inPrinter: 'In stampante',
     lowStock: 'Scorta bassa',
     sinceTracking: 'Dall\'inizio del tracciamento',
+    resetUsage: 'Azzera utilizzo',
+    resetUsageTooltip: 'Azzera il contatore di grammi consumati per questa bobina',
+    resetUsageConfirm: 'Azzerare il contatore di grammi consumati di questa bobina? Le stampe future ripartiranno da zero. La bobina, il calcolo del peso rimanente e le impostazioni non vengono modificati.',
+    resetAllUsage: 'Azzera utilizzo di tutte le bobine',
+    resetAllUsageTooltip: 'Azzera il contatore di grammi consumati su ogni bobina attiva',
+    resetAllUsageConfirm: 'Azzerare il contatore di grammi consumati su tutte le {{count}} bobine attive? La statistica "Totale Consumato" verrà azzerata e le stampe future ripartiranno da zero. Bobine e pesi rimanenti restano invariati.',
+    usageReset: 'Utilizzo della bobina azzerato',
+    allUsageReset: '{{count}} bobina/e azzerata/e',
+    resetUsageFailed: 'Impossibile azzerare l\'utilizzo della bobina',
     loadedInAms: 'Caricato in AMS/Est',
     remaining: 'Rimanente',
     weightCheck: 'Controllo Peso',

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

@@ -3691,6 +3691,15 @@ export default {
     inPrinter: 'プリンター内',
     lowStock: '残量少',
     sinceTracking: '追跡開始以降',
+    resetUsage: '使用量を0にリセット',
+    resetUsageTooltip: 'このスプールの消費量カウンタを0にする',
+    resetUsageConfirm: 'このスプールの消費量カウンタを0にリセットしますか?以降の印刷は再びゼロからカウントされます。スプール自体、残量計算、設定は変更されません。',
+    resetAllUsage: '全スプールの使用量をリセット',
+    resetAllUsageTooltip: 'すべてのアクティブなスプールの消費量カウンタを0にする',
+    resetAllUsageConfirm: '{{count}}件すべてのアクティブなスプールの消費量カウンタを0にリセットしますか?「累計消費量」の値がクリアされ、以降の印刷はゼロからカウントされます。スプール自体と残量は変更されません。',
+    usageReset: 'スプールの使用量を0にリセットしました',
+    allUsageReset: '{{count}}件のスプールをリセットしました',
+    resetUsageFailed: 'スプールの使用量リセットに失敗しました',
     loadedInAms: 'AMS/Extに装填中',
     remaining: '残り',
     weightCheck: '重量チェック',

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

@@ -3679,6 +3679,15 @@ export default {
     inPrinter: 'Na Impressora',
     lowStock: 'Estoque Baixo',
     sinceTracking: 'Desde o início do rastreamento',
+    resetUsage: 'Zerar uso',
+    resetUsageTooltip: 'Zerar o contador de gramas consumidas desta bobina',
+    resetUsageConfirm: 'Zerar o contador de gramas consumidas desta bobina? As impressões futuras voltarão a contar do zero. A bobina em si, o cálculo do peso restante e as configurações não são alterados.',
+    resetAllUsage: 'Zerar uso de todas as bobinas',
+    resetAllUsageTooltip: 'Zerar o contador de gramas consumidas em todas as bobinas ativas',
+    resetAllUsageConfirm: 'Zerar o contador de gramas consumidas nas {{count}} bobinas ativas? Isso limpa o "Total Consumido" para que as impressões futuras contem do zero. Bobinas e pesos restantes não são alterados.',
+    usageReset: 'Uso da bobina zerado',
+    allUsageReset: '{{count}} bobina(s) zerada(s)',
+    resetUsageFailed: 'Falha ao zerar o uso da bobina',
     loadedInAms: 'Carregado no AMS/Ext',
     remaining: 'Restante',
     weightCheck: 'Verificação de Peso',

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

@@ -3684,6 +3684,15 @@ export default {
     inPrinter: '在打印机中',
     lowStock: '库存不足',
     sinceTracking: '自开始追踪',
+    resetUsage: '将用量重置为 0',
+    resetUsageTooltip: '将此料盘的已消耗克数计数器清零',
+    resetUsageConfirm: '将此料盘的已消耗克数计数器重置为 0?后续打印将从零开始计数。料盘本身、剩余重量计算和您的设置不会改变。',
+    resetAllUsage: '重置所有料盘的用量',
+    resetAllUsageTooltip: '将每个活动料盘的已消耗克数计数器清零',
+    resetAllUsageConfirm: '将全部 {{count}} 个活动料盘的已消耗克数计数器重置为 0?这将清空"累计消耗"统计值,后续打印从零开始计数。料盘和剩余重量不会改变。',
+    usageReset: '料盘用量已重置为 0',
+    allUsageReset: '已重置 {{count}} 个料盘',
+    resetUsageFailed: '重置料盘用量失败',
     loadedInAms: '已装载到 AMS/外置',
     remaining: '剩余',
     weightCheck: '重量检查',

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

@@ -3684,6 +3684,15 @@ export default {
     inPrinter: '在印表機中',
     lowStock: '庫存不足',
     sinceTracking: '自開始追蹤',
+    resetUsage: '將用量重置為 0',
+    resetUsageTooltip: '將此料盤的已消耗克數計數器歸零',
+    resetUsageConfirm: '將此料盤的已消耗克數計數器重置為 0?後續列印將從零開始計算。料盤本身、剩餘重量計算與您的設定均不會變更。',
+    resetAllUsage: '重置所有料盤的用量',
+    resetAllUsageTooltip: '將每個有效料盤的已消耗克數計數器歸零',
+    resetAllUsageConfirm: '將全部 {{count}} 個有效料盤的已消耗克數計數器重置為 0?這將清空「累計消耗」統計值,後續列印從零開始計算。料盤與剩餘重量不會變更。',
+    usageReset: '料盤用量已重置為 0',
+    allUsageReset: '已重置 {{count}} 個料盤',
+    resetUsageFailed: '重置料盤用量失敗',
     loadedInAms: '已裝載到 AMS/外接',
     remaining: '剩餘',
     weightCheck: '重量檢查',

+ 86 - 13
frontend/src/pages/InventoryPage.tsx

@@ -6,7 +6,7 @@ import {
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
-  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy,
+  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser,
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -465,7 +465,11 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [searchParams, setSearchParams] = useSearchParams();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null);
   const deepLinkHandled = useRef(false);
-  const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
+  const [confirmAction, setConfirmAction] = useState<
+    | { type: 'delete' | 'archive' | 'reset-usage'; spoolId: number }
+    | { type: 'reset-all-usage' }
+    | null
+  >(null);
   // Label printing (#809). null = closed; otherwise the IDs to print labels for.
   const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState<number[] | null>(null);
 
@@ -682,6 +686,35 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     },
   });
 
+  const resetUsageMutation = useMutation({
+    mutationFn: (id: number) =>
+      spoolmanMode ? api.resetSpoolmanInventorySpoolUsage(id) : api.resetSpoolUsage(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      showToast(t('inventory.usageReset'), 'success');
+    },
+    onError: () => {
+      showToast(t('inventory.resetUsageFailed'), 'error');
+    },
+  });
+
+  const bulkResetUsageMutation = useMutation({
+    mutationFn: (ids: number[]) =>
+      spoolmanMode ? api.bulkResetSpoolmanInventorySpoolUsage(ids) : api.bulkResetSpoolUsage(ids),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      showToast(t('inventory.allUsageReset', { count: data.reset }), 'success');
+    },
+    onError: () => {
+      showToast(t('inventory.resetUsageFailed'), 'error');
+    },
+  });
+
+  const activeSpoolIds = useMemo(
+    () => (spools ?? []).filter((s) => !s.archived_at).map((s) => s.id),
+    [spools],
+  );
+
   const handleSyncWeight = async (spool: InventorySpool) => {
     if (spool.last_scale_weight == null) return;
     try {
@@ -1086,9 +1119,21 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
 
           {/* Total Consumed */}
           <div className="bg-bambu-dark-secondary rounded-lg p-4">
-            <div className="flex items-center gap-2 mb-1">
-              <TrendingDown className="w-4 h-4 text-blue-400" />
-              <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
+            <div className="flex items-center justify-between gap-2 mb-1">
+              <div className="flex items-center gap-2">
+                <TrendingDown className="w-4 h-4 text-blue-400" />
+                <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
+              </div>
+              {stats.totalConsumed > 0 && activeSpoolIds.length > 0 && (
+                <button
+                  onClick={() => setConfirmAction({ type: 'reset-all-usage' })}
+                  className="p-1 text-bambu-gray hover:text-red-400 rounded transition-colors"
+                  title={t('inventory.resetAllUsageTooltip')}
+                  aria-label={t('inventory.resetAllUsage')}
+                >
+                  <Eraser className="w-3.5 h-3.5" />
+                </button>
+              )}
             </div>
             <div className="text-xl font-bold text-white">{formatWeight(stats.totalConsumed, true)}</div>
             <div className="text-xs text-bambu-gray mt-1">{t('inventory.sinceTracking')}</div>
@@ -1672,6 +1717,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
                           onPrintLabel={(id) => setLabelPickerSpoolIds([id])}
+                          onResetUsage={(id) => setConfirmAction({ type: 'reset-usage', spoolId: id })}
                           visibleColumns={visibleColumns}
                           assignmentMap={assignmentMap}
                           catalogMap={catalogMap}
@@ -1697,6 +1743,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                         onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
                         onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
                         onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
+                        onResetUsage={() => setConfirmAction({ type: 'reset-usage', spoolId: spool.id })}
                         visibleColumns={visibleColumns}
                         assignmentMap={assignmentMap}
                         catalogMap={catalogMap}
@@ -1797,18 +1844,36 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         />
       )}
 
-      {/* Confirm Modal (delete / archive) */}
+      {/* Confirm Modal (delete / archive / reset-usage / reset-all-usage) */}
       {confirmAction && (
         <ConfirmModal
-          title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
-          message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}
-          confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
-          variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
+          title={
+            confirmAction.type === 'delete' ? t('common.delete') :
+            confirmAction.type === 'archive' ? t('inventory.archive') :
+            confirmAction.type === 'reset-usage' ? t('inventory.resetUsage') :
+            t('inventory.resetAllUsage')
+          }
+          message={
+            confirmAction.type === 'delete' ? t('inventory.deleteConfirm') :
+            confirmAction.type === 'archive' ? t('inventory.archiveConfirm') :
+            confirmAction.type === 'reset-usage' ? t('inventory.resetUsageConfirm') :
+            t('inventory.resetAllUsageConfirm', { count: activeSpoolIds.length })
+          }
+          confirmText={
+            confirmAction.type === 'delete' ? t('common.delete') :
+            confirmAction.type === 'archive' ? t('inventory.archive') :
+            t('inventory.resetUsage')
+          }
+          variant={confirmAction.type === 'archive' ? 'warning' : 'danger'}
           onConfirm={() => {
             if (confirmAction.type === 'delete') {
               deleteMutation.mutate(confirmAction.spoolId);
-            } else {
+            } else if (confirmAction.type === 'archive') {
               archiveMutation.mutate(confirmAction.spoolId);
+            } else if (confirmAction.type === 'reset-usage') {
+              resetUsageMutation.mutate(confirmAction.spoolId);
+            } else {
+              bulkResetUsageMutation.mutate(activeSpoolIds);
             }
             setConfirmAction(null);
           }}
@@ -2024,7 +2089,7 @@ function SpoolCard({
 
 /* Single spool row for table view */
 function SpoolTableRow({
-  spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel,
+  spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetUsage,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spool: InventorySpool;
@@ -2036,6 +2101,7 @@ function SpoolTableRow({
   onArchive: () => void;
   onDelete: () => void;
   onPrintLabel?: () => void;
+  onResetUsage?: () => void;
   visibleColumns: string[];
   assignmentMap: Record<number, LocationDisplay>;
   catalogMap: Record<number, SpoolCatalogEntry>;
@@ -2071,6 +2137,11 @@ function SpoolTableRow({
               <Printer className="w-4 h-4" />
             </button>
           )}
+          {onResetUsage && !spool.archived_at && spool.weight_used > 0 && (
+            <button onClick={onResetUsage} className="p-1.5 text-bambu-gray hover:text-orange-400 rounded transition-colors" title={t('inventory.resetUsageTooltip')}>
+              <Eraser className="w-4 h-4" />
+            </button>
+          )}
           {spool.archived_at ? (
             <button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
               <RotateCcw className="w-4 h-4" />
@@ -2092,7 +2163,7 @@ function SpoolTableRow({
 /* Grouped spool rows for table view */
 function SpoolTableGroup({
   spools, representative, remaining, pct, isExpanded, onToggle,
-  onEdit, onCopy, onArchive, onDelete, onPrintLabel,
+  onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetUsage,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spools: InventorySpool[];
@@ -2106,6 +2177,7 @@ function SpoolTableGroup({
   onArchive: (id: number) => void;
   onDelete: (id: number) => void;
   onPrintLabel?: (spoolId: number) => void;
+  onResetUsage?: (id: number) => void;
   visibleColumns: string[];
   assignmentMap: Record<number, LocationDisplay>;
   catalogMap: Record<number, SpoolCatalogEntry>;
@@ -2159,6 +2231,7 @@ function SpoolTableGroup({
             onArchive={() => onArchive(spool.id)}
             onDelete={() => onDelete(spool.id)}
             onPrintLabel={onPrintLabel ? () => onPrintLabel(spool.id) : undefined}
+            onResetUsage={onResetUsage ? () => onResetUsage(spool.id) : undefined}
             visibleColumns={visibleColumns}
             assignmentMap={assignmentMap}
             catalogMap={catalogMap}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BI09b1Nz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-KYwGxnG9.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CdDo4ToZ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CLI--QzS.css">
+    <script type="module" crossorigin src="/assets/index-BI09b1Nz.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff