فهرست منبع

fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)

  Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
  "Spools and remaining weights are not changed" - the dialog promised
  this; the implementation did the opposite. Root cause was an
  architectural conflation: `weight_used` did double duty as the
  resettable "consumed since tracking started" counter AND as the basis
  for the displayed remaining (`label_weight - weight_used`), so zeroing
  it correctly cleared the stat but unavoidably reset remaining to full.

  Spoolman has separate `used_weight` and `remaining_weight` fields, so
  the API call there was correct - but Bambuddy's frontend was also
  computing remaining as `label_weight - weight_used` for Spoolman
  spools (ignoring Spoolman's real `remaining_weight` field), so the
  same visual bug bit there too. Inventory-mode parity required fixing
  both halves in one drop.

  Internal mode

  - New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
  - Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
  - Displayed consumed = `weight_used - baseline`; remaining =
    `label_weight - weight_used` (unchanged).
  - Subsequent prints continue to grow `weight_used`, so the resettable
    counter naturally tracks post-reset delta and remaining keeps
    decrementing across the reset.

  Spoolman mode

  - `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
    and returns a synthetic `weight_used = label - remaining` so the
    frontend's remaining calc matches Spoolman's real stored value;
    `weight_used_baseline = synthetic - real_used_weight` so the consumed
    counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
  - Fallback path (no `remaining_weight` set) preserves the old behavior.
  - Related fix: `update_spool` (Spoolman PATCH) was deriving the default
    `weight_used` from `used_weight`, so editing unrelated fields AFTER
    a reset would patch Spoolman with `remaining_weight = label - 0 =
    label`, trampling the real value. Now derives from
    `remaining_weight` so non-weight edits preserve physical state.

  Frontend

  - `InventoryPage` `totalConsumed` aggregate switched to
    `Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
  - `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
    "consumed" table cell got the same treatment so forecast and
    inventory aggregates stay coherent across a reset.
  - `?? 0` keeps pre-migration installs rendering correctly until
    `init_db()` runs the idempotent ALTER TABLE.

  Migration

  - `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
    via `_safe_execute` - SQLite and Postgres both accept it; verified
    end-to-end on Postgres 16.
maziggy 1 هفته پیش
والد
کامیت
e61a454a0f

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 20 - 1
backend/app/api/routes/_spoolman_helpers.py

@@ -34,6 +34,7 @@ class MappedSpoolFields(TypedDict):
     core_weight: int | None
     core_weight_catalog_id: None
     weight_used: float | None
+    weight_used_baseline: float | None
     weight_locked: bool
     last_scale_weight: None
     last_weighed_at: None
@@ -247,7 +248,24 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     rgba: str = color_hex + "FF"
 
     label_weight: int = _safe_int(filament.get("weight"), 1000)
-    used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
+    real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
+    # Parity with internal mode (#1390): the InventorySpool shape lets the
+    # frontend compute `remaining = label_weight - weight_used` and
+    # `consumed = weight_used - weight_used_baseline`. Map Spoolman's two
+    # independent fields (used_weight, remaining_weight) onto that shape:
+    #   weight_used = label_weight - remaining_weight  (so remaining matches)
+    #   baseline    = weight_used - used_weight        (so consumed matches)
+    # When remaining_weight is unset (legacy spools, or filament linked but
+    # never primed), fall back to the old behaviour: weight_used =
+    # used_weight, baseline = 0.
+    remaining_raw = spool.get("remaining_weight")
+    if remaining_raw is not None:
+        remaining_weight: float = _safe_float(remaining_raw, 0.0)
+        used_weight: float = max(0.0, float(label_weight) - remaining_weight)
+        weight_used_baseline: float = max(0.0, used_weight - real_used_weight)
+    else:
+        used_weight = real_used_weight
+        weight_used_baseline = 0.0
 
     # Archived state – Spoolman uses a boolean ``archived`` field
     archived: bool = spool.get("archived", False)
@@ -299,6 +317,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         ),
         "core_weight_catalog_id": None,
         "weight_used": used_weight,
+        "weight_used_baseline": weight_used_baseline,
         "weight_locked": False,
         "last_scale_weight": None,
         "last_weighed_at": None,

+ 11 - 9
backend/app/api/routes/inventory.py

@@ -1071,19 +1071,20 @@ async def reset_spool_usage(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """Zero the spool's weight_used counter without locking the spool.
+    """Zero the displayed "Total Consumed" counter without touching remaining.
 
-    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.
+    Stamps `weight_used_baseline = weight_used` so the Inventory page's
+    `weight_used - baseline` display reads 0, while `label_weight -
+    weight_used` (remaining) is unchanged. weight_locked is also left
+    alone — the spool keeps receiving AMS auto-sync updates. Matches
+    Spoolman's split between used_weight and remaining_weight (#1390).
     """
     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
+    spool.weight_used_baseline = spool.weight_used or 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"})
@@ -1096,11 +1097,12 @@ async def bulk_reset_spool_usage(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """Bulk-reset weight_used to 0 across the given spool IDs.
+    """Bulk-stamp baseline = weight_used 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.
+    Same semantics as the per-spool endpoint: remaining is preserved,
+    weight_locked is left alone.
     """
     spool_ids = payload.get("spool_ids")
     if not isinstance(spool_ids, list) or not spool_ids:
@@ -1111,7 +1113,7 @@ async def bulk_reset_spool_usage(
     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
+        spool.weight_used_baseline = spool.weight_used or 0
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
     return {"reset": len(spools)}

+ 12 - 1
backend/app/api/routes/spoolman_inventory.py

@@ -580,7 +580,18 @@ async def update_spool(
     cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
     rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
     label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
-    weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0)
+    # Default weight_used from the synthetic mapping (label - remaining) so an
+    # edit that doesn't touch the weight field preserves Spoolman's real
+    # remaining_weight after a "Reset usage to 0" — the previous code read
+    # Spoolman's used_weight directly, which is 0 post-reset, so
+    # `remaining = label - 0 = 1000` would overwrite the real remaining
+    # the next time the user edited any other field (#1390).
+    cur_remaining_raw = current.get("remaining_weight")
+    if cur_remaining_raw is not None:
+        synthetic_used = max(0.0, float(label_weight) - float(cur_remaining_raw))
+    else:
+        synthetic_used = float(current.get("used_weight") or 0)
+    weight_used = data.weight_used if data.weight_used is not None else synthetic_used
     note = data.note if data.note is not None else current.get("comment")
     storage_location_changed = "storage_location" in data.model_fields_set
     storage_location = data.storage_location if storage_location_changed else None

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

@@ -1547,6 +1547,11 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
     # Migration: Add user-editable storage location to spool table
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
+    # Migration: Add weight_used_baseline anchor for the resettable "Total
+    # Consumed" stat (#1390). Existing spools default to 0 (no baseline),
+    # so the counter starts unaffected; pressing "Reset usage to 0" now
+    # stamps baseline = weight_used without touching remaining.
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0")
     # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
     # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
     # ALTER COLUMN ... TYPE is PostgreSQL-only syntax; SQLite ignores VARCHAR sizes so no-op there.

+ 6 - 0
backend/app/models/spool.py

@@ -31,6 +31,12 @@ class Spool(Base):
         Integer
     )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
+    # Anchor for the resettable "Total Consumed" stat. The displayed counter
+    # is `weight_used - weight_used_baseline`; the Inventory page's "Reset
+    # usage to 0" action stamps baseline = weight_used so the counter zeroes
+    # without disturbing remaining (= label_weight - weight_used). Matches
+    # Spoolman's split between used_weight and remaining_weight (#1390).
+    weight_used_baseline: Mapped[float] = mapped_column(Float, default=0)
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
     last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
     last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed

+ 5 - 0
backend/app/schemas/spool.py

@@ -100,6 +100,11 @@ class SpoolBase(BaseModel):
     core_weight: int = 250
     core_weight_catalog_id: int | None = None
     weight_used: float = 0
+    # Anchor for the resettable "Total Consumed" display. The Inventory
+    # page shows `weight_used - weight_used_baseline`; the per-spool /
+    # bulk "Reset usage to 0" action sets baseline = weight_used so the
+    # counter zeroes without touching remaining (#1390).
+    weight_used_baseline: float = 0
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
     nozzle_temp_min: int | None = None

+ 69 - 17
backend/tests/integration/test_spool_reset_usage.py

@@ -1,10 +1,15 @@
 """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.
+The per-spool and bulk reset endpoints stamp `weight_used_baseline =
+weight_used` instead of zeroing `weight_used` directly. This decouples
+the resettable "Total Consumed" display (computed as
+`weight_used - weight_used_baseline`) from remaining
+(`label_weight - weight_used`), so resetting the counter does NOT
+inflate remaining back to label_weight (which is what the previous
+implementation did — see the report at the end of #1390).
+
+`weight_locked` is left alone in both modes; the spool keeps receiving
+AMS auto-sync updates from the next print onward.
 """
 
 import pytest
@@ -28,6 +33,7 @@ async def spool_factory(db_session: AsyncSession):
             "rgba": "FF0000FF",
             "label_weight": 1000,
             "weight_used": 0,
+            "weight_used_baseline": 0,
             "weight_locked": False,
         }
         defaults.update(kwargs)
@@ -43,16 +49,31 @@ async def spool_factory(db_session: AsyncSession):
 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)
+    async def test_reset_stamps_baseline_without_touching_weight_used(
+        self, async_client: AsyncClient, spool_factory, db_session
+    ):
+        """Reset stamps baseline = weight_used; remaining stays the same.
+
+        Pre-bug behaviour zeroed weight_used and made
+        `label_weight - weight_used` (the displayed remaining) jump back
+        to label_weight — a 456 g spool would suddenly read 1000 g.
+        """
+        spool = await spool_factory(label_weight=1000, weight_used=456.0)
 
         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
+        body = response.json()
+        assert body["weight_used"] == 456.0, "weight_used must NOT be zeroed (drives remaining)"
+        assert body["weight_used_baseline"] == 456.0, "baseline must equal pre-reset weight_used"
+        # Displayed consumed = weight_used - baseline = 0
+        assert body["weight_used"] - body["weight_used_baseline"] == 0
+        # Displayed remaining = label_weight - weight_used = 544 (unchanged)
+        assert body["label_weight"] - body["weight_used"] == 544
+
         await db_session.refresh(spool)
-        assert spool.weight_used == 0
+        assert spool.weight_used == 456.0
+        assert spool.weight_used_baseline == 456.0
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -69,7 +90,8 @@ class TestResetSpoolUsage:
 
         assert response.status_code == 200
         await db_session.refresh(spool)
-        assert spool.weight_used == 0
+        assert spool.weight_used == 100.0
+        assert spool.weight_used_baseline == 100.0
         assert spool.weight_locked is False, "Reset must not auto-lock the spool"
 
     @pytest.mark.asyncio
@@ -82,9 +104,32 @@ class TestResetSpoolUsage:
 
         assert response.status_code == 200
         await db_session.refresh(spool)
-        assert spool.weight_used == 0
+        assert spool.weight_used == 500.0
+        assert spool.weight_used_baseline == 500.0
         assert spool.weight_locked is True, "Pre-existing lock must be preserved"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_then_print_advances_only_the_counter(
+        self, async_client: AsyncClient, spool_factory, db_session
+    ):
+        """After reset, a subsequent print delta shows up in the consumed
+        counter while remaining keeps decrementing normally.
+        """
+        spool = await spool_factory(label_weight=1000, weight_used=456.0)
+        await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        # Simulate a 50g print (usage_tracker increments weight_used).
+        await db_session.refresh(spool)
+        spool.weight_used = (spool.weight_used or 0) + 50.0
+        await db_session.commit()
+
+        await db_session.refresh(spool)
+        consumed = spool.weight_used - spool.weight_used_baseline
+        remaining = spool.label_weight - spool.weight_used
+        assert consumed == 50.0, "Consumed counter reflects only post-reset usage"
+        assert remaining == 494, "Remaining tracks physical depletion across reset"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
@@ -95,7 +140,9 @@ class TestResetSpoolUsage:
 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):
+    async def test_bulk_reset_stamps_baseline_only_for_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)
@@ -114,9 +161,12 @@ class TestBulkResetSpoolUsage:
         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[target1.id].weight_used == 100.0
+        assert by_id[target1.id].weight_used_baseline == 100.0
+        assert by_id[target2.id].weight_used == 200.0
+        assert by_id[target2.id].weight_used_baseline == 200.0
         assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
+        assert by_id[untouched.id].weight_used_baseline == 0, "Untouched baseline must stay at 0"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -153,5 +203,7 @@ class TestBulkResetSpoolUsage:
         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
+        assert (
+            unlocked.weight_used == 100.0 and unlocked.weight_used_baseline == 100.0 and unlocked.weight_locked is False
+        )
+        assert locked.weight_used == 200.0 and locked.weight_used_baseline == 200.0 and locked.weight_locked is True

+ 15 - 2
backend/tests/integration/test_spoolman_inventory_api.py

@@ -496,11 +496,24 @@ class TestSpoolmanInventoryCRUD:
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman."""
+        """POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman.
+
+        Parity with internal mode (#1390): the InventorySpool response
+        carries `weight_used = label - remaining` and
+        `weight_used_baseline = weight_used - real_used_weight`, so the
+        displayed consumed counter (weight_used - baseline) reads 0
+        while remaining (= label - weight_used) preserves Spoolman's
+        independent remaining_weight field.
+        """
         response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-usage")
 
         assert response.status_code == 200
-        assert response.json()["weight_used"] == 0
+        body = response.json()
+        # Sample spool: label=1000, remaining=750, used_weight=0 after Spoolman reset.
+        assert body["weight_used"] == 250.0, "synthetic weight_used = label - remaining"
+        assert body["weight_used_baseline"] == 250.0, "baseline absorbs the reset"
+        assert body["weight_used"] - body["weight_used_baseline"] == 0, "displayed consumed = 0"
+        assert body["label_weight"] - body["weight_used"] == 750, "remaining unchanged"
         mock_spoolman_client.reset_spool_usage.assert_called_once_with(42)
 
     @pytest.mark.asyncio

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

@@ -102,9 +102,36 @@ class TestMapSpoolmanSpool:
         assert result["material"] == "PLA"
         assert result["rgba"] == "FF0000FF"
         assert result["label_weight"] == 1000
+        # No remaining_weight set → fallback path: weight_used = used_weight, baseline = 0.
         assert result["weight_used"] == pytest.approx(250.0)
+        assert result["weight_used_baseline"] == pytest.approx(0.0)
         assert result["data_origin"] == "spoolman"
 
+    def test_remaining_weight_drives_synthetic_used_for_parity(self):
+        """When remaining_weight is set, weight_used = label - remaining and
+        the baseline absorbs the used_weight delta. This mirrors the internal
+        Spool model's split between consumed counter and physical depletion
+        so the frontend computes the same display in both modes (#1390).
+        """
+        spool = {**MINIMAL_SPOOL, "used_weight": 250.0, "remaining_weight": 544.0}
+        result = _map_spoolman_spool(spool)
+        # Remaining = label - weight_used must equal real remaining_weight.
+        assert result["label_weight"] - result["weight_used"] == pytest.approx(544.0)
+        # Consumed = weight_used - baseline must equal real used_weight.
+        assert result["weight_used"] - result["weight_used_baseline"] == pytest.approx(250.0)
+
+    def test_remaining_weight_after_reset(self):
+        """Spoolman reset: used_weight=0, remaining_weight unchanged. The
+        mapper produces baseline = weight_used so the displayed consumed
+        counter reads 0 while remaining stays at the real value.
+        """
+        spool = {**MINIMAL_SPOOL, "used_weight": 0.0, "remaining_weight": 544.0}
+        result = _map_spoolman_spool(spool)
+        assert result["weight_used"] == pytest.approx(456.0)
+        assert result["weight_used_baseline"] == pytest.approx(456.0)
+        assert result["weight_used"] - result["weight_used_baseline"] == pytest.approx(0.0)
+        assert result["label_weight"] - result["weight_used"] == pytest.approx(544.0)
+
     def test_missing_id_raises(self):
         spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"}
         with pytest.raises(ValueError, match="missing required 'id'"):

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

@@ -2408,6 +2408,12 @@ export interface InventorySpool {
   core_weight: number;
   core_weight_catalog_id: number | null;
   weight_used: number;
+  // Anchor for the resettable "Total Consumed" display (#1390). The
+  // counter shown on the Inventory page is `weight_used - weight_used_baseline`;
+  // remaining is still `label_weight - weight_used`, so "Reset usage to 0"
+  // zeroes the counter without disturbing remaining. Optional for back-compat
+  // with rows from a pre-migration DB snapshot — default to 0.
+  weight_used_baseline?: number;
   slicer_filament: string | null;
   slicer_filament_name: string | null;
   nozzle_temp_min: number | null;

+ 7 - 3
frontend/src/components/ForecastPanel.tsx

@@ -130,7 +130,10 @@ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev
 }
 
 function computeDeltaRate(spools: InventorySpool[]): number | null {
-  const totalUsed = spools.reduce((s, sp) => s + sp.weight_used, 0);
+  // Use weight_used - baseline so "Reset usage to 0" on the Inventory page
+  // makes forecast restart from zero rather than carrying stale lifetime
+  // consumption across the reset (#1390).
+  const totalUsed = spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
   if (totalUsed === 0) return null;
   const now = Date.now();
   const oldestMs = spools.reduce((min, sp) => {
@@ -228,7 +231,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
 
       const totalRemainingG = group.spools.reduce((s, sp) => s + Math.max(0, sp.label_weight - sp.weight_used), 0);
       const totalLabelG = group.spools.reduce((s, sp) => s + sp.label_weight, 0);
-      const totalUsedG = group.spools.reduce((s, sp) => s + sp.weight_used, 0);
+      // Consumed since baseline (post-reset); see InventoryPage stats calc (#1390).
+      const totalUsedG = group.spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
 
       const groupHistory: SpoolUsageRecord[] = [];
       for (const s of group.spools) groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
@@ -1012,7 +1016,7 @@ function ForecastRow({
                                 </div>
                               </td>
                               <td className="px-4 py-2">
-                                <span className="text-sm text-bambu-gray">{Math.round(s.weight_used)}g</span>
+                                <span className="text-sm text-bambu-gray">{Math.round(Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0)))}g</span>
                               </td>
                               <td className="px-4 py-2">
                                 <span className="text-sm text-bambu-gray">{s.label_weight}g</span>

+ 4 - 1
frontend/src/pages/InventoryPage.tsx

@@ -772,7 +772,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
       activeCount++;
       const remaining = Math.max(0, s.label_weight - s.weight_used);
       totalWeight += remaining;
-      totalConsumed += s.weight_used;
+      // "Total Consumed" is the resettable counter (weight_used - baseline)
+      // rather than raw weight_used so the per-spool / bulk eraser zeroes
+      // the stat without inflating remaining back to label_weight (#1390).
+      totalConsumed += Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0));
       const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
       const threshold = s.low_stock_threshold_pct ?? lowStockThreshold;
       if (pct < threshold) lowStock++;

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D4VfWhg_.js"></script>
+    <script type="module" crossorigin src="/assets/index-DbgmsH_e.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   <body>

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