Bläddra i källkod

Count used-up spools towards the stock forecast's rate (#3399)

- The forecast left archived spools out entirely, so after used-up
  spools were archived a SKU's rate counted only what the remaining
  spools had used: weeks of use showed as 0.2 g/day.
- A spool archived in the last 90 days now counts towards its SKU's
  rate (consumption, age and usage history), on the Forecast page
  and for the Reorder and Stock Break alerts, in both inventory
  modes. Spoolman mode dates archiving by the spool's last use.
- Archived spools are still not stock, and a SKU with only archived
  spools is still not forecast.
maziggy 1 dag sedan
förälder
incheckning
9b083e7b0b

+ 3 - 0
CHANGELOG.md

@@ -193,6 +193,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The stock forecast counts used-up spools towards the consumption rate (#3399, reported by @ojimpo)** — Archived spools were left out of the forecast entirely, so once used-up spools were archived, a SKU's rate counted only what its remaining spools had used. A SKU in weekly use could show 0.2 g/day and run out in decades, and the Reorder and Stock Break alerts came late or not at all.
+  - A spool archived in the last 90 days now counts towards its SKU's rate, in both inventory modes, on the Forecast page and for the alerts. Older ones don't, so a spool used up years ago doesn't drag the rate down.
+  - Archived spools still aren't stock, and a SKU whose spools are all archived still isn't forecast. Wiki updated.
 - **Reprinting a plate of a multi-plate file prints that plate, not plate 1 (#3395, reported by @Thomansky)** — **Print** on an archive, **Print again** in the print-outcome dialog and **Re-queue** in the queue history opened the print dialog with plate 1 selected, whichever plate the job had printed. In a file with many plates, plate 1 was the only one in view, so one click on Print printed the wrong part.
   - The dialog now opens on the plate that was printed and scrolls it into view. If the file no longer has that plate, it falls back to the first one.
   - The plates that will print are named above the buttons, e.g. "Plate 10 – Lid", so the choice stays visible however long the plate list is.

+ 31 - 11
backend/app/services/print_scheduler.py

@@ -131,6 +131,16 @@ _STOCK_ALERTS_SETTING_KEY = "stock_alerts_notified"
 _STOCK_FORECAST_HISTORY_LIMIT = 5000
 
 
+def _spoolman_time(value: object) -> datetime | None:
+    """A Spoolman ISO timestamp, or None when missing or unreadable."""
+    if not value:
+        return None
+    try:
+        return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+    except ValueError:
+        return None
+
+
 def _remaining_percent(label_weight: int | float | None, weight_used: float | None) -> float | None:
     """Remaining filament as a percentage of the label weight.
 
@@ -5760,7 +5770,14 @@ class PrintScheduler:
     async def _stock_spools_internal(self, db: AsyncSession) -> list[stock_forecast.StockSpool]:
         from backend.app.models.spool import Spool
 
-        rows = (await db.execute(select(Spool).where(Spool.archived_at.is_(None)))).scalars().all()
+        # Stock, and the spools archived recently enough to count towards the
+        # rate (stock_forecast.archived_counts decides; this only trims the read).
+        cutoff = utcnow_naive() - timedelta(days=stock_forecast.ARCHIVED_RATE_WINDOW_DAYS + 1)
+        rows = (
+            (await db.execute(select(Spool).where(or_(Spool.archived_at.is_(None), Spool.archived_at >= cutoff))))
+            .scalars()
+            .all()
+        )
         return [
             stock_forecast.StockSpool(
                 id=spool.id,
@@ -5772,6 +5789,7 @@ class PrintScheduler:
                 weight_used=float(spool.weight_used or 0),
                 weight_used_baseline=float(spool.weight_used_baseline or 0),
                 created_at=spool.created_at,
+                archived_at=spool.archived_at,
             )
             for spool in rows
         ]
@@ -5798,9 +5816,10 @@ class PrintScheduler:
     async def _stock_spools_spoolman(self) -> list[stock_forecast.StockSpool] | None:
         """Spoolman's spools in the forecast's shape, or None when Spoolman cannot be reached.
 
-        ``get_all_spools`` leaves archived spools out unless asked, which is the
-        exclusion the internal query applies explicitly. ``_map_spoolman_spool``
-        is the same mapping the Inventory page (and so the panel) reads.
+        Archived ones too: a recently used-up spool counts towards its SKU's
+        rate (#3399). ``_map_spoolman_spool`` is the same mapping the Inventory
+        page (and so the panel) reads; it dates archiving by the spool's last
+        use, as Spoolman records no archive date.
         """
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
         from backend.app.services.spoolman import SpoolmanUnavailableError, get_spoolman_client
@@ -5810,7 +5829,7 @@ class PrintScheduler:
             logger.debug("Stock forecast skipped, no Spoolman client (spoolman_enabled without a URL?)")
             return None
         try:
-            raw_spools = await client.get_all_spools()
+            raw_spools = await client.get_all_spools(allow_archived=True)
         except SpoolmanUnavailableError as e:
             logger.debug("Stock forecast skipped, Spoolman unreachable: %s", e)
             return None
@@ -5821,12 +5840,12 @@ class PrintScheduler:
                 mapped = _map_spoolman_spool(raw)
             except ValueError:
                 continue
-            created_at = None
-            if mapped.get("created_at"):
-                try:
-                    created_at = datetime.fromisoformat(str(mapped["created_at"]).replace("Z", "+00:00"))
-                except ValueError:
-                    created_at = None
+            created_at = _spoolman_time(mapped.get("created_at"))
+            archived_at = None
+            if raw.get("archived"):
+                # Archived without a date to tell when: not stock, and too old to
+                # count towards the rate.
+                archived_at = _spoolman_time(mapped.get("archived_at")) or datetime(1970, 1, 1, tzinfo=timezone.utc)
             spools.append(
                 stock_forecast.StockSpool(
                     id=mapped["id"],
@@ -5838,6 +5857,7 @@ class PrintScheduler:
                     weight_used=float(mapped.get("weight_used") or 0),
                     weight_used_baseline=float(mapped.get("weight_used_baseline") or 0),
                     created_at=created_at,
+                    archived_at=archived_at,
                     color_name_is_synthesized=bool(mapped.get("color_name_is_synthesized")),
                 )
             )

+ 41 - 10
backend/app/services/stock_forecast.py

@@ -8,6 +8,10 @@ pins the two to the same numbers on the same inputs.
 The pieces, in the order the panel applies them:
 
 * Spools are grouped by SKU -- ``(material, subtype, brand, color_name)``.
+  Archived spools are not stock, but one archived in the last
+  ``ARCHIVED_RATE_WINDOW_DAYS`` still counts towards how fast the SKU is used:
+  used-up spools are archived, and leaving out what they took makes a SKU in
+  weekly use look untouched (#3399). A SKU with no stock left is not forecast.
 * The daily rate comes from usage history when there are at least two distinct
   days of it (weighted by a 30-day half-life, so recent prints dominate), and
   from consumption since the spool baseline over the age of the oldest spool
@@ -47,6 +51,10 @@ _FALLBACK_RATE_G_PER_DAY = 5
 
 _SECONDS_PER_DAY = 86400.0
 
+# An archived spool counts towards the rate for this long after it was archived.
+# Older ones would stretch the delta rate's age over years and dilute it.
+ARCHIVED_RATE_WINDOW_DAYS = 90
+
 SkuKey = tuple[str, str, str, str]
 
 
@@ -68,6 +76,8 @@ class StockSpool:
     weight_used: float
     weight_used_baseline: float
     created_at: datetime | None
+    # When the spool was archived (Spoolman: its last use); None while it is stock.
+    archived_at: datetime | None = None
     # True when color_name is not a name the spool carries but the subtype standing in
     # for one (Spoolman mode does this so the list has no blank colours). The SKU key
     # keeps it, as the panel does; anything shown to a person should not.
@@ -124,6 +134,11 @@ def _utc(moment: datetime) -> datetime:
     return moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc)
 
 
+def archived_counts(archived_at: datetime, now: datetime) -> bool:
+    """Whether a spool archived then still counts towards its SKU's rate."""
+    return (_utc(now) - _utc(archived_at)).total_seconds() <= ARCHIVED_RATE_WINDOW_DAYS * _SECONDS_PER_DAY
+
+
 def history_rate(records: Sequence[UsageRecord], now: datetime) -> tuple[float, float] | None:
     """Daily consumption rate and its standard deviation from usage history.
 
@@ -191,16 +206,20 @@ def forecast_sku(
     settings: SkuSettings,
     global_lead_time_days: int,
     now: datetime,
+    used_up: Sequence[StockSpool] = (),
 ) -> SkuForecast:
-    """Forecast one SKU from its spools. ``spools`` must be non-empty and share a key."""
+    """Forecast one SKU from its spools. ``spools`` (its stock) must be non-empty
+    and share a key; ``used_up`` are its recently archived spools, which count
+    towards the rate only."""
     lead_time = max(global_lead_time_days, settings.lead_time_days)
     remaining = sum(s.remaining_g for s in spools)
+    rate_spools = [*spools, *used_up]
 
     # Only history from spools that were never reset: a reset spool's earlier
     # events have no anchor and would inflate the rate. A spool with no
     # baseline is clean and keeps its records.
     history: list[UsageRecord] = []
-    for spool in spools:
+    for spool in rate_spools:
         if spool.weight_used_baseline == 0:
             history.extend(history_by_spool.get(spool.id, ()))
 
@@ -210,7 +229,7 @@ def forecast_sku(
     if measured is not None:
         rate, std_dev = measured
     else:
-        rate = delta_rate(spools, now)
+        rate = delta_rate(rate_spools, now)
 
     sigma = std_dev if std_dev is not None else (rate * _FALLBACK_SIGMA_FRACTION if rate is not None else 0.0)
     statistical_safety_g = Z_95 * sigma * math.sqrt(lead_time)
@@ -257,20 +276,25 @@ def forecast_all(
     global_lead_time_days: int,
     now: datetime,
 ) -> ForecastMap:
-    """Forecast every SKU present in ``spools``.
+    """Forecast every SKU that has stock in ``spools``.
 
-    Returns each SKU's forecast with the first spool of its group, which is what
-    a caller needs to name the SKU (material, subtype, brand, colour) without a
-    second lookup. The caller passes only spools that count as stock -- archived
-    ones excluded -- because what is archived differs by inventory mode.
+    Returns each SKU's forecast with the first stock spool of its group, which is
+    what a caller needs to name the SKU (material, subtype, brand, colour)
+    without a second lookup. A spool with ``archived_at`` set is not stock; the
+    caller sets it however its inventory mode records archiving. One archived
+    within ``ARCHIVED_RATE_WINDOW_DAYS`` counts towards its SKU's rate.
 
     A colour-specific SKU with no settings row of its own falls back to the
     colourless row, which is where settings saved before colour became part of
     the key still live.
     """
     groups: dict[SkuKey, list[StockSpool]] = {}
+    used_up: dict[SkuKey, list[StockSpool]] = {}
     for spool in spools:
-        groups.setdefault(spool.key, []).append(spool)
+        if spool.archived_at is None:
+            groups.setdefault(spool.key, []).append(spool)
+        elif archived_counts(spool.archived_at, now):
+            used_up.setdefault(spool.key, []).append(spool)
 
     out: ForecastMap = {}
     for key, members in groups.items():
@@ -278,7 +302,14 @@ def forecast_all(
         if settings is None and key[3] != "":
             settings = sku_settings.get((key[0], key[1], key[2], ""))
         out[key] = (
-            forecast_sku(members, history_by_spool, settings or SkuSettings(), global_lead_time_days, now),
+            forecast_sku(
+                members,
+                history_by_spool,
+                settings or SkuSettings(),
+                global_lead_time_days,
+                now,
+                used_up=used_up.get(key, ()),
+            ),
             members[0],
         )
     return out

+ 69 - 1
backend/tests/unit/services/test_stock_alert_producer_2955.py

@@ -50,6 +50,7 @@ async def _spool(
     brand: str | None = "Bambu Lab",
     age_days: int = 100,
     archived: bool = False,
+    archived_days_ago: float | None = None,
     baseline: float = 0.0,
 ) -> Spool:
     spool = Spool(
@@ -62,7 +63,13 @@ async def _spool(
         weight_used=used,
         weight_used_baseline=baseline,
         created_at=utcnow_naive() - timedelta(days=age_days),
-        archived_at=utcnow_naive() if archived else None,
+        archived_at=(
+            utcnow_naive() - timedelta(days=archived_days_ago)
+            if archived_days_ago is not None
+            else utcnow_naive()
+            if archived
+            else None
+        ),
     )
     spool.k_profiles = []
     spool.assignments = []
@@ -533,6 +540,34 @@ async def test_an_archived_spool_is_not_stock(db_session, scheduler, notify):
     assert notify.on_stock_reorder_alert.await_args.args[2] == pytest.approx(80.0)
 
 
+@pytest.mark.asyncio
+async def test_a_used_up_spool_still_counts_towards_the_rate(db_session, scheduler, notify):
+    """#3399: 300 g left. On its own the spool ran 7 g/day, but a 1000 g spool of the
+    same SKU was used up and archived 10 days ago: 17 g/day, under the reorder point."""
+    await _sku(db_session)
+    await _spool(db_session, used=700.0)
+    await _spool(db_session, used=1000.0, archived_days_ago=10)
+    await db_session.commit()
+
+    await _pass(scheduler, db_session)
+
+    notify.on_stock_reorder_alert.assert_awaited_once()
+    # Its stock is still the 300 g of the spool that is left.
+    assert notify.on_stock_reorder_alert.await_args.args[2] == pytest.approx(300.0)
+
+
+@pytest.mark.asyncio
+async def test_a_spool_archived_over_90_days_ago_does_not_count(db_session, scheduler, notify):
+    await _sku(db_session)
+    await _spool(db_session, used=700.0)
+    await _spool(db_session, used=1000.0, archived_days_ago=91)
+    await db_session.commit()
+
+    await _pass(scheduler, db_session)
+
+    notify.on_stock_reorder_alert.assert_not_awaited()
+
+
 # -- failure and cost --------------------------------------------------------
 
 
@@ -836,6 +871,39 @@ async def test_spoolman_mode_alerts_from_one_collection_read(db_session, schedul
     client.get_all_spools.assert_awaited_once()
 
 
+@pytest.mark.asyncio
+async def test_spoolman_mode_counts_a_used_up_spool_towards_the_rate(db_session, scheduler):
+    """#3399 in Spoolman mode: archived spools are read too, dated by their last use.
+
+    The forecast itself is checked: an alert fires the same for any rate above the
+    reorder point, so it could not tell which archived spools counted.
+    """
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    await db_session.commit()
+    recent = _spoolman_spool(9, used=900.0)  # 100 g left on it: not stock either way
+    recent["archived"] = True
+    recent["last_used"] = (utcnow_naive() - timedelta(days=10)).isoformat() + "Z"
+    stale = _spoolman_spool(10, used=900.0)
+    stale["archived"] = True
+    stale["last_used"] = (utcnow_naive() - timedelta(days=91)).isoformat() + "Z"
+    never_used = _spoolman_spool(11, used=900.0)
+    never_used["archived"] = True  # dated by its registration 100 days ago: too old
+    undated = _spoolman_spool(12, used=900.0)
+    undated["archived"] = True
+    del undated["registered"]  # no date at all: not stock, and it doesn't count
+    client = _spoolman_client([_spoolman_spool(7, used=760.0), recent, stale, never_used, undated])
+
+    with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+        forecasts = await scheduler._stock_forecasts(db_session)
+
+    client.get_all_spools.assert_awaited_once_with(allow_archived=True)
+    ((forecast, named_by),) = forecasts.values()
+    # (760 + 900) g over the 100 days of the spools' age; the stale ones add nothing.
+    assert forecast.daily_rate_g == pytest.approx(16.6)
+    assert forecast.remaining_g == pytest.approx(240.0)
+    assert named_by.id == 7
+
+
 @pytest.mark.asyncio
 async def test_an_unreachable_spoolman_stays_quiet_and_does_not_forget(db_session, scheduler, notify, caplog):
     """An outage is an ordinary state for a third-party service. It must not raise, and it

+ 59 - 0
backend/tests/unit/services/test_stock_forecast_2955.py

@@ -232,3 +232,62 @@ def test_the_scenarios_cover_both_alerts_and_neither():
     """The pin means little if no scenario ever alerts."""
     flags = {(e["reorder_alert"], e["stock_break_alert"]) for e in EXPECTED.values()}
     assert flags == {(False, False), (True, False), (False, True)}
+
+
+# -- #3399: used-up spools still count towards the rate ------------------------
+
+
+def _archived(spool_id: int, used: float, age_days: float, archived_days_ago: float) -> StockSpool:
+    spool = _spool(spool_id, used, age_days)
+    return StockSpool(**{**spool.__dict__, "archived_at": _ago(archived_days_ago)})
+
+
+KEY = sku_key("PLA", "Basic", "Bambu Lab", "Black")
+
+
+def test_a_recently_archived_spool_counts_towards_the_rate_not_the_stock():
+    """The reporter's case: 900 g used on a spool archived since, 16 g on the one left."""
+    spools = [_spool(1, 16, 20), _archived(2, 900, 100, 10)]
+
+    forecast, named_by = forecast_all(spools, {}, {}, 0, NOW)[KEY]
+
+    # (16 + 900) g over the 100 days since the older spool, not 16 g over 20.
+    assert forecast.daily_rate_g == pytest.approx(9.16)
+    # The archived spool's 100 g left are not stock.
+    assert forecast.remaining_g == pytest.approx(984)
+    assert named_by.id == 1
+
+
+def test_a_spool_archived_over_90_days_ago_does_not_count():
+    spools = [_spool(1, 16, 20), _archived(2, 1000, 200, 91)]
+
+    forecast, _ = forecast_all(spools, {}, {}, 0, NOW)[KEY]
+
+    assert forecast.daily_rate_g == pytest.approx(0.8)
+
+
+def test_the_window_includes_its_last_day():
+    spools = [_spool(1, 16, 20), _archived(2, 1000, 100, 90)]
+
+    forecast, _ = forecast_all(spools, {}, {}, 0, NOW)[KEY]
+
+    assert forecast.daily_rate_g == pytest.approx(10.16)
+
+
+def test_a_sku_with_only_archived_spools_is_not_forecast():
+    """Every spool archived usually means the filament is no longer bought."""
+    assert forecast_all([_archived(2, 1000, 100, 10)], {}, {}, 0, NOW) == {}
+
+
+def test_a_recently_archived_spools_history_counts():
+    spools = [_spool(1, 100, 30), _archived(2, 900, 60, 5)]
+    history = _history((1, 2, 100), (2, 20, 300), (2, 10, 600))
+
+    with_archived, _ = forecast_all(spools, history, {}, 0, NOW)[KEY]
+    alone, _ = forecast_all(spools[:1], history, {}, 0, NOW)[KEY]
+
+    assert with_archived.daily_rate_g == pytest.approx(
+        history_rate([r for records in history.values() for r in records], NOW)[0]
+    )
+    # With only the stock spool's single day there is no history rate at all.
+    assert alone.daily_rate_g == pytest.approx(delta_rate(spools[:1], NOW))

+ 134 - 0
frontend/src/__tests__/components/ForecastPanelUsedUp.test.tsx

@@ -0,0 +1,134 @@
+/**
+ * Used-up spools still count towards a SKU's rate (#3399): an archived spool is
+ * not stock, but one archived in the last 90 days adds what it was used for.
+ * The backend's stock_forecast applies the same rule to the stock alerts.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { ForecastPanel } from '../../components/ForecastPanel';
+import { api } from '../../api/client';
+import type { InventorySpool, SpoolUsageRecord } from '../../api/client';
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return {
+    ...actual,
+    useAuth: () => ({ hasPermission: () => true, hasAnyPermission: () => true, authEnabled: false, user: null }),
+  };
+});
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getSettings: vi.fn(),
+      getUiSettings: vi.fn(),
+      getAuthStatus: vi.fn(),
+      getSkuSettings: vi.fn(),
+      getAllUsageHistory: vi.fn(),
+      getShoppingList: vi.fn(),
+      addToShoppingList: vi.fn(),
+      removeFromShoppingList: vi.fn(),
+      clearShoppingList: vi.fn(),
+      updateShoppingListStatus: vi.fn(),
+      bulkCreateSpools: vi.fn(),
+      upsertSkuSettings: vi.fn(),
+      updateSettings: vi.fn(),
+    },
+  };
+});
+
+const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+
+const spool = (id: number, over: Partial<InventorySpool> = {}): InventorySpool => ({
+  id,
+  material: 'PLA',
+  subtype: 'Matte',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 750,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: 'manual',
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  weight_locked: false,
+  category: 'Active',
+  low_stock_threshold_pct: null,
+  ...over,
+});
+
+const DAY = 86400000;
+const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
+
+// The reporter's case: 16 g on the spool that is left, 900 g on one archived since
+// (with 100 g still on it, which is not stock).
+const stock = spool(1, { label_weight: 1000, weight_used: 16, created_at: ago(20) });
+const usedUp = (archivedDaysAgo: number) =>
+  spool(2, { label_weight: 1000, weight_used: 900, created_at: ago(100), archived_at: ago(archivedDaysAgo) });
+
+const usage = (id: number, spoolId: number, daysAgo: number, grams: number): SpoolUsageRecord =>
+  ({ id, spool_id: spoolId, created_at: ago(daysAgo), weight_used: grams }) as SpoolUsageRecord;
+
+describe('ForecastPanel: used-up spools (#3399)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocked.getSettings.mockResolvedValue({ forecast_global_lead_time_days: 0 });
+    mocked.getUiSettings.mockResolvedValue({ forecast_global_lead_time_days: 0 });
+    mocked.getAuthStatus.mockResolvedValue({ auth_enabled: false, requires_setup: false });
+    mocked.getSkuSettings.mockResolvedValue([]);
+    mocked.getAllUsageHistory.mockResolvedValue([]);
+    mocked.getShoppingList.mockResolvedValue([]);
+  });
+
+  it('counts a spool archived 10 days ago towards the rate, not the stock', async () => {
+    render(<ForecastPanel spools={[stock, usedUp(10)]} />);
+
+    // (16 + 900) g over the 100 days since the older spool, not 16 g over 20.
+    expect(await screen.findByText('9.2g/d')).toBeInTheDocument();
+    expect(screen.getByText('984g')).toBeInTheDocument();
+  });
+
+  it('leaves out a spool archived over 90 days ago', async () => {
+    render(<ForecastPanel spools={[stock, usedUp(91)]} />);
+
+    expect(await screen.findByText('0.8g/d')).toBeInTheDocument();
+  });
+
+  it('does not forecast a SKU whose spools are all archived', async () => {
+    render(<ForecastPanel spools={[usedUp(10)]} />);
+
+    await waitFor(() => expect(mocked.getAllUsageHistory).toHaveBeenCalled());
+    expect(screen.queryByText(/g\/d$/)).toBeNull();
+    expect(screen.queryByText('Polymaker')).toBeNull();
+  });
+
+  it('uses the usage history of a recently archived spool', async () => {
+    // The stock spool alone has one day of history, too little for a trend.
+    mocked.getAllUsageHistory.mockResolvedValue([usage(1, 1, 2, 16), usage(2, 2, 20, 300), usage(3, 2, 10, 700)]);
+    render(<ForecastPanel spools={[stock, usedUp(5)]} />);
+
+    expect(await screen.findByText('Trend')).toBeInTheDocument();
+  });
+});

+ 29 - 4
frontend/src/components/ForecastPanel.tsx

@@ -18,6 +18,7 @@ import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { ConfirmModal } from './ConfirmModal';
+import { parseUTCDate } from '../utils/date';
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 
@@ -27,7 +28,21 @@ interface SkuGroup {
   subtype: string | null;
   brand: string | null;
   colorName: string | null;
+  /** The SKU's stock: its spools that are not archived. */
   spools: InventorySpool[];
+  /** Spools archived in the last ARCHIVED_RATE_WINDOW_DAYS: no stock, but what
+   *  they were used for counts towards the rate (#3399). */
+  usedUp: InventorySpool[];
+}
+
+// An archived spool counts towards its SKU's rate for this long after it was
+// archived; older ones would stretch the estimate over years and dilute it.
+// Same as stock_forecast.ARCHIVED_RATE_WINDOW_DAYS in the backend.
+const ARCHIVED_RATE_WINDOW_DAYS = 90;
+
+function archivedCounts(archivedAt: string, now: number): boolean {
+  const at = parseUTCDate(archivedAt)?.getTime();
+  return at !== undefined && !Number.isNaN(at) && now - at <= ARCHIVED_RATE_WINDOW_DAYS * 86400000;
 }
 
 interface SkuForecast {
@@ -232,10 +247,18 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
     for (const spool of spools) {
       if (spool.archived_at) continue;
       const key = skuKey(spool.material, spool.subtype, spool.brand, spool.color_name);
-      const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, colorName: spool.color_name, spools: [] };
+      const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, colorName: spool.color_name, spools: [], usedUp: [] };
       g.spools.push(spool);
       map.set(key, g);
     }
+    // Used-up spools are archived. They are not stock, and a SKU with nothing
+    // left is not forecast, but leaving out what they took makes a SKU in weekly
+    // use look untouched (#3399).
+    const now = Date.now();
+    for (const spool of spools) {
+      if (!spool.archived_at || !archivedCounts(spool.archived_at, now)) continue;
+      map.get(skuKey(spool.material, spool.subtype, spool.brand, spool.color_name))?.usedUp.push(spool);
+    }
     return [...map.values()];
   }, [spools]);
 
@@ -256,14 +279,16 @@ 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 rateSpools = [...group.spools, ...group.usedUp];
       // 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);
+      // Used-up spools included: it ranks the most-used SKUs.
+      const totalUsedG = rateSpools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
 
       // Only include history from spools that haven't been reset — pre-reset
       // events on a reset spool have no anchor timestamp so they'd inflate the
       // rate. Spools without a baseline are clean and keep their records.
       const groupHistory: SpoolUsageRecord[] = [];
-      for (const s of group.spools) {
+      for (const s of rateSpools) {
         if ((s.weight_used_baseline ?? 0) === 0) {
           groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
         }
@@ -279,7 +304,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
         dailyRateStdDev = histResult.stdDev;
         rateTier = 'history';
       } else {
-        const delta = computeDeltaRate(group.spools);
+        const delta = computeDeltaRate(rateSpools);
         if (delta !== null) { dailyRateG = delta; rateTier = 'delta'; }
       }