Jelajahi Sumber

Spoolman mode: usage history and the stock forecast built-in has (issue #3407)

- Each Spoolman charge writes a spool_usage_history row under the new
  spoolman_spool_id (spool_id NULL), priced when the spool has a price
- Forecast panel and stock alerts read the active inventory's rows, so
  Spoolman mode gets the Trend rate; the spool dialog lists its usage
- Rows older than a Spoolman spool's registration are an earlier spool's
  that had the id, and are left out
- Fallback rate in both modes: each spool's grams spread over first to
  last use, counted over the last 90 days; shelf time is not use
- Usage history is backed up and restored without built-in spools too
maziggy 1 hari lalu
induk
melakukan
1d86cff5a2
100 mengubah file dengan 1330 tambahan dan 137 penghapusan
  1. 1 0
      CHANGELOG.md
  2. 3 0
      backend/app/api/routes/_spoolman_helpers.py
  3. 4 4
      backend/app/api/routes/archives.py
  4. 16 2
      backend/app/api/routes/inventory.py
  5. 38 1
      backend/app/api/routes/spoolman_inventory.py
  6. 4 0
      backend/app/core/database.py
  7. 1 0
      backend/app/main.py
  8. 5 0
      backend/app/models/spool_usage_history.py
  9. 1 0
      backend/app/schemas/spool_usage.py
  10. 11 4
      backend/app/services/github_backup.py
  11. 12 0
      backend/app/services/github_restore.py
  12. 28 16
      backend/app/services/print_scheduler.py
  13. 70 5
      backend/app/services/spoolman_tracking.py
  14. 83 23
      backend/app/services/stock_forecast.py
  15. 2 2
      backend/tests/integration/test_spoolman_cost_preserved_2591.py
  16. 383 0
      backend/tests/integration/test_spoolman_usage_history_3407.py
  17. 28 0
      backend/tests/unit/services/test_spoolman_print_cost_2591.py
  18. 15 7
      backend/tests/unit/services/test_stock_alert_producer_2955.py
  19. 18 13
      backend/tests/unit/services/test_stock_forecast_2955.py
  20. 134 0
      backend/tests/unit/services/test_stock_forecast_delta_rate_3407.py
  21. 191 0
      frontend/src/__tests__/components/ForecastPanelDeltaRate.test.tsx
  22. 15 1
      frontend/src/__tests__/components/ForecastPanelSpoolmanHistory.test.tsx
  23. 7 4
      frontend/src/__tests__/components/ForecastPanelUsedUp.test.tsx
  24. 20 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  25. 83 0
      frontend/src/__tests__/components/SpoolUsageHistorySpoolman.test.tsx
  26. 8 4
      frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx
  27. 13 2
      frontend/src/api/client.ts
  28. 94 32
      frontend/src/components/ForecastPanel.tsx
  29. 3 3
      frontend/src/components/SpoolFormModal.tsx
  30. 13 6
      frontend/src/components/SpoolUsageHistory.tsx
  31. 19 0
      frontend/src/utils/usageHistory.ts
  32. 0 0
      static/assets/AdditionalSection-2WFC952c.js
  33. 0 0
      static/assets/AmsUnitCard-Roh0GRe8.js
  34. 0 0
      static/assets/ArchivesPage-BYL-wceG.js
  35. 0 0
      static/assets/AreaChart-vJnRYZ8X.js
  36. 0 0
      static/assets/AssignToAmsModal-Dyz7w6Is.js
  37. 0 0
      static/assets/AuthContext-CmTN4q4U.js
  38. 1 1
      static/assets/Button-DZq9YkGs.js
  39. 0 0
      static/assets/CamWallPage-yFwPX4qE.js
  40. 0 0
      static/assets/CameraDiagnoseModal-BUy1BH5K.js
  41. 0 0
      static/assets/CameraPage-Cn0lyt6D.js
  42. 0 0
      static/assets/CameraWall-pndz7IrG.js
  43. 1 1
      static/assets/Card-DQ4O8sjc.js
  44. 0 0
      static/assets/CartesianChart-BtjX66WV.js
  45. 0 0
      static/assets/ColumnConfigModal-ro24Wsda.js
  46. 0 0
      static/assets/ConfigureAmsSlotModal-CCJBPE8O.js
  47. 0 0
      static/assets/ConfirmModal-BAiQSdF-.js
  48. 0 0
      static/assets/ConfirmOutcomeDialog-C2TGy01-.js
  49. 0 0
      static/assets/ConnectAuthorizePage-uppIxj7u.js
  50. 0 0
      static/assets/ConnectionDiagnostic-CirSjZ36.js
  51. 0 0
      static/assets/ContextMenu-CjSMQjlR.js
  52. 0 0
      static/assets/CopyButton-BL2Qtj5g.js
  53. 0 0
      static/assets/EmbeddedCameraViewer-1c0A6JuB.js
  54. 0 0
      static/assets/ErrorBarContext-BTcCz-yo.js
  55. 0 0
      static/assets/ExternalLinkPage-Cc0cY4MB.js
  56. 1 1
      static/assets/FilamentSwatch-b34zntYa.js
  57. 0 1
      static/assets/FileManagerPage-CRabD5zB.js
  58. 0 0
      static/assets/FileUploadModal-CmEPhkuE.js
  59. 0 0
      static/assets/FinancePage-C5VbzQ9e.js
  60. 0 0
      static/assets/GCodeViewerPage-DfvJSVm8.js
  61. 0 0
      static/assets/GcodeToolpathViewer-CYiE6IYk.js
  62. 0 0
      static/assets/GroupEditPage-7w-QTXS6.js
  63. 0 0
      static/assets/IconPicker-Cfn2hKEM.js
  64. 0 0
      static/assets/ImagePreviewModal-Btmk29cN.js
  65. 0 0
      static/assets/InventoryPage-DWdm7M1K.js
  66. 0 0
      static/assets/InventoryPage-nFXtC2i2.js
  67. 0 0
      static/assets/Layout-VDJYUcZm.js
  68. 0 0
      static/assets/LibraryTrashPage-BWuP1EJw.js
  69. 0 0
      static/assets/LoginPage-ur3Ey4w-.js
  70. 0 0
      static/assets/MaintenancePage-CUWJ8F9Q.js
  71. 0 0
      static/assets/ModelSourcesPage-Dyj1919n.js
  72. 0 0
      static/assets/NotificationsPage-DAa4jLgY.js
  73. 1 1
      static/assets/NumberInput-mKi1v1k8.js
  74. 1 1
      static/assets/OverlayFrame-Deb3W49X.js
  75. 0 0
      static/assets/PdfPreviewModal-C1DME1Bi.js
  76. 0 0
      static/assets/PhotoGalleryModal-Dt6zcy0a.js
  77. 0 0
      static/assets/PrintModal-DLimPejf.js
  78. 0 0
      static/assets/PrinterLocationsPage-XLXVfYis.js
  79. 0 0
      static/assets/PrintersPage-Do_mA8X5.js
  80. 0 0
      static/assets/ProfilesPage-CHrPvRhn.js
  81. 0 0
      static/assets/ProjectDetailPage-DmxC26IT.js
  82. 0 0
      static/assets/ProjectsPage-CMzjnHm5.js
  83. 0 0
      static/assets/QueryClientProvider-Cx3hLxdK.js
  84. 0 0
      static/assets/QueuePage-DPNaibs8.js
  85. 0 0
      static/assets/ReferenceLine-9Jin1_zQ.js
  86. 0 0
      static/assets/RichTextEditor-vzw_MOnk.js
  87. 0 0
      static/assets/SettingsPage-BRI7KDcc.js
  88. 0 0
      static/assets/SetupPage-CqLAI7ZM.js
  89. 0 0
      static/assets/SkipObjectsModal-Dn3ii47e.js
  90. 0 0
      static/assets/SliceModal-DJwnqzL-.js
  91. 0 0
      static/assets/SpoolBuddyAmsPage-BkcS4WRZ.js
  92. 0 0
      static/assets/SpoolBuddyDashboard-CiDIRwNV.js
  93. 0 0
      static/assets/SpoolBuddyInventoryPage-DBY1Z7DZ.js
  94. 1 1
      static/assets/SpoolBuddySettingsPage-DkwYU1Vh.js
  95. 0 0
      static/assets/SpoolBuddyWriteTagPage-ydY-PyZe.js
  96. 1 1
      static/assets/SpoolIcon-CiFF8I-M.js
  97. 0 0
      static/assets/SpreadsheetPreviewModal-Dc7yhxGy.js
  98. 0 0
      static/assets/StatsPage-SABPnjhR.js
  99. 0 0
      static/assets/StreamOverlayPage-DcmoC58k.js
  100. 0 0
      static/assets/SystemHealthPanel-C4UjWQN2.js

+ 1 - 0
CHANGELOG.md

@@ -205,6 +205,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman mode gets the same usage history and stock forecast as built-in inventory (#3407, reported by @ojimpo)** — Bambuddy charges Spoolman for every print but kept no record of it, and Spoolman itself keeps only per-spool totals. So Spoolman mode had no per-print usage at all: the stock forecast could never use its **Trend** rate, and the spool dialog showed no usage history. Each charge is now recorded like a built-in spool's: grams, print, status, and the cost when the spool has a price. So the forecast reaches **Trend** once two days of prints are recorded, and the spool dialog lists its prints. The rows are part of the GitHub backup. The fallback rate used before that was also wrong in both modes. It divided a spool's grams by the days since it was added, so shelf time counted as use. A spool used up inside that span but last used more than 90 days ago dropped out with all its grams while its days stayed in. On the reporter's SKU it showed 9.2 g/d. Each spool's grams are now spread over the time it was actually in use, and what falls inside the last 90 days is divided by those days (12.2 g/d there). The stock alerts use the same rule. Wiki updated.
 - **A Spoolman slot change made elsewhere now reaches open pages without a reload (#3402, reported by @Eifel-Joe)** — In Spoolman mode, an AMS slot's hover card kept showing the spool assigned before when the change came from another tab or client, here a filament scale using the API. The page only learns about slot changes that the server announces, and unassigning a slot, linking or unlinking a spool's tag, adding a spool from a slot, and writing a tag on SpoolBuddy did not announce theirs. They do now, as built-in inventory always has. Assigning a slot has announced since the move-on-scan change (#1606), but did so before taking the slot's tag off the previous spool, so a page could read that spool back and keep its fill level on the slot. It now announces after.
 - **A timelapse baseline the printer's card could not give no longer attaches an old video to the print (#3412, reported by @ojimpo)** — When the card could not be read at print start (here during the FTPS handshake cool-off), Bambuddy recorded the empty listing as a card holding no videos. If the card was readable again by the end, every video still on the printer counted as new: the scan attached the first one to the print, deleted it from the printer, and took the archive's finish photo from its last frame. A baseline now records whether the card was actually read, including when a listing failed for another reason. With an unread one, several unclaimed videos are left on the printer for **Scan for Timelapse**, and a single one is still attached. The card is also read again every two minutes while the print runs, because the printer writes the video only after the print ends. Wiki updated.
 - **SpoolBuddy reads Bambu tags reliably on the first scan (#3377, reported and contributed by @hibikipr in #3378)** — A Bambu tag's sector reads sometimes failed on the first try (authentication not answered, or a block read that came back empty), and the scan then went out without its tray UUID. Each step is now retried before giving up, a block is accepted only when two reads agree and the reader flags no error, and a reader fault during the read no longer stops SpoolBuddy from polling: the tag is still reported, matched by its UID.

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

@@ -536,6 +536,9 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "note": spool.get("comment") or None,
         "added_full": None,
         "last_used": spool.get("last_used"),
+        # When the spool was first printed with; the stock forecast spreads
+        # its consumption from here (#3407).
+        "first_used": spool.get("first_used"),
         "last_dried_at": _extract_extra_str(extra, BAMBU_LAST_DRIED_AT_KEY) or None,
         "last_dried_temp": _last_dried_temp(extra),
         "last_dried_hours": _extract_extra_float(extra, BAMBU_LAST_DRIED_HOURS_KEY),

+ 4 - 4
backend/app/api/routes/archives.py

@@ -2113,10 +2113,10 @@ async def _spoolman_owns_cost(db: AsyncSession) -> bool:
 
     Both cost recalculations below rebuild a print's cost from
     ``SpoolUsageHistory``, and fall back to the built-in Filament catalogue or
-    the global default rate when there are no rows for it. In Spoolman mode
-    there are never any rows -- the built-in usage tracker is handed
-    ``spoolman_owns_usage`` at print start and writes none -- so that fallback
-    is not a recalculation, it is a downgrade: it would overwrite the
+    the global default rate when there are no rows for it. In Spoolman mode a
+    print has rows only since #3407, when Spoolman charges started being
+    recorded; for an older print, or one whose charges Spoolman refused, that
+    fallback is not a recalculation, it is a downgrade: it would overwrite the
     Spoolman-priced figure ``spoolman_tracking`` recorded at completion with a
     default-rate one, and the per-slot spool resolution it came from is
     transient and cannot be rebuilt here (#2591).

+ 16 - 2
backend/app/api/routes/inventory.py

@@ -3079,14 +3079,28 @@ async def get_material_number_stats(
 async def get_all_usage_history(
     limit: int = 100,
     printer_id: int | None = None,
+    spoolman: bool = False,
     db: AsyncSession = Depends(get_db, scope="function"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Get global usage history, optionally filtered by printer."""
+    """Get global usage history, optionally filtered by printer.
+
+    One inventory's at a time: ``spoolman`` for the Spoolman spools'
+    (#3407), otherwise the built-in ones'. Each keys its spools by its own ids.
+    """
     from backend.app.models.spool_usage_history import SpoolUsageHistory
 
-    query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
+    query = (
+        select(SpoolUsageHistory)
+        .where(
+            SpoolUsageHistory.spoolman_spool_id.is_not(None)
+            if spoolman
+            else SpoolUsageHistory.spoolman_spool_id.is_(None)
+        )
+        .order_by(SpoolUsageHistory.created_at.desc())
+        .limit(limit)
+    )
     if printer_id is not None:
         query = query.where(SpoolUsageHistory.printer_id == printer_id)
     if (clause := printer_scope.where(SpoolUsageHistory.printer_id)) is not None:

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

@@ -18,7 +18,7 @@ from datetime import datetime
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel, Field, field_validator, model_validator
-from sqlalchemy import delete, select, text, tuple_
+from sqlalchemy import delete, select, text, tuple_, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -48,11 +48,13 @@ from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
+from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
+from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
@@ -1144,10 +1146,16 @@ async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
     reference count non-zero, so deleting that supplier answers 409 forever
     with no way for the user to find the phantom reference (#2988).
 
+    Its usage history is kept, as a deleted built-in spool's is, but no longer
+    names it: Spoolman can hand the id to a new spool, which would inherit it.
+
     The caller commits.
     """
     for model in (SpoolmanKProfile, SpoolmanFilamentPreset, SpoolmanSpoolSupplier):
         await db.execute(delete(model).where(model.spoolman_spool_id == spool_id))
+    await db.execute(
+        update(SpoolUsageHistory).where(SpoolUsageHistory.spoolman_spool_id == spool_id).values(spoolman_spool_id=None)
+    )
 
 
 @router.delete("/spools/{spool_id}")
@@ -2404,6 +2412,35 @@ def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict:
     }
 
 
+@router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
+async def get_spoolman_spool_usage_history(
+    spool_id: int = Path(..., gt=0),
+    limit: int = 50,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """A Spoolman spool's per-print usage, as Bambuddy charged it (#3407)."""
+    result = await db.execute(
+        select(SpoolUsageHistory)
+        .where(SpoolUsageHistory.spoolman_spool_id == spool_id)
+        .order_by(SpoolUsageHistory.created_at.desc())
+        .limit(limit)
+    )
+    return list(result.scalars().all())
+
+
+@router.delete("/spools/{spool_id}/usage")
+async def clear_spoolman_spool_usage_history(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Clear a Spoolman spool's usage history. Spoolman's own totals are untouched."""
+    await db.execute(delete(SpoolUsageHistory).where(SpoolUsageHistory.spoolman_spool_id == spool_id))
+    await db.commit()
+    return {"status": "cleared"}
+
+
 @router.get("/spools/{spool_id}/filament-presets")
 async def get_spoolman_filament_presets(
     spool_id: int = Path(..., gt=0),

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

@@ -5896,6 +5896,10 @@ async def _run_migrations(conn):
             {"off": False},
         )
 
+    # Migration: per-print usage of Spoolman spools (#3407), so Spoolman mode
+    # has the usage history the built-in inventory has.
+    await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN spoolman_spool_id INTEGER")
+
     # Migration: mark a timelapse baseline taken off an unreadable card
     # (#3412). Off for every existing row, which is what they were treated as.
     if is_sqlite():

+ 1 - 0
backend/app/main.py

@@ -9084,6 +9084,7 @@ async def _close_out_print(printer_id: int, data: dict):
                         db,
                         last_layer_num=data.get("last_layer_num"),
                         last_progress=data.get("last_progress"),
+                        status=data.get("status") or "failed",
                     )
             except Exception as e:
                 logger.debug("[SPOOLMAN] Cleanup failed: %s", e)

+ 5 - 0
backend/app/models/spool_usage_history.py

@@ -15,6 +15,11 @@ class SpoolUsageHistory(Base):
     # Kept when the spool is deleted, like the archive link below: the usage is
     # what the prints cost, and what recalculating their cost reads.
     spool_id: Mapped[int | None] = mapped_column(ForeignKey("spool.id", ondelete="SET NULL"), nullable=True)
+    # The Spoolman spool charged, in Spoolman mode, where ``spool_id`` stays
+    # NULL: Spoolman's ids are its own and would collide with built-in ones
+    # (#3406). No foreign key, as the spool lives in Spoolman; cleared when
+    # the spool is deleted through Bambuddy (#3407).
+    spoolman_spool_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"))
     print_name: Mapped[str | None] = mapped_column(String(500))
     # The spool's consumption outlives the archive: deleting the archive only

+ 1 - 0
backend/app/schemas/spool_usage.py

@@ -6,6 +6,7 @@ from pydantic import BaseModel
 class SpoolUsageHistoryResponse(BaseModel):
     id: int
     spool_id: int | None = None  # None: the spool was deleted, its usage kept
+    spoolman_spool_id: int | None = None  # Spoolman mode: the Spoolman spool charged
     printer_id: int | None = None
     print_name: str | None = None
     weight_used: float

+ 11 - 4
backend/app/services/github_backup.py

@@ -778,9 +778,14 @@ class GitHubBackupService:
         result = await db.execute(select(Spool))
         spools = result.scalars().all()
 
-        if not spools:
-            return
+        if spools:
+            self._collect_spool_list(spools, files)
+
+        # Collected without built-in spools too: in Spoolman mode the usage
+        # history names Spoolman's spools, and there are no built-in ones (#3407).
+        await self._collect_spool_usage(db, files)
 
+    def _collect_spool_list(self, spools, files: dict) -> None:
         spool_list = []
         for s in spools:
             spool_data = {
@@ -832,8 +837,9 @@ class GitHubBackupService:
             "version": "1.0",
             "spools": spool_list,
         }
+        logger.info("Collected %d spools", len(spool_list))
 
-        # Collect usage history
+    async def _collect_spool_usage(self, db: AsyncSession, files: dict) -> None:
         usage_result = await db.execute(select(SpoolUsageHistory))
         usages = usage_result.scalars().all()
 
@@ -844,6 +850,7 @@ class GitHubBackupService:
                     {
                         "id": u.id,
                         "spool_id": u.spool_id,
+                        "spoolman_spool_id": u.spoolman_spool_id,
                         "printer_id": u.printer_id,
                         "print_name": u.print_name,
                         "archive_id": u.archive_id,
@@ -859,7 +866,7 @@ class GitHubBackupService:
                 "usage_history": usage_list,
             }
 
-        logger.info("Collected %d spools and %d usage records", len(spool_list), len(usages))
+        logger.info("Collected %d usage records", len(usages))
 
     async def _collect_archives(self, db: AsyncSession, files: dict):
         """Collect print archive metadata (no binary files)."""

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

@@ -1648,6 +1648,12 @@ class GitHubRestoreService:
     ) -> None:
         spools = inventory.get("spools") if isinstance(inventory, dict) else None
         if not isinstance(spools, list):
+            usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+            if isinstance(usage, list) and usage:
+                # A Spoolman-mode backup: usage of Spoolman spools, no built-in
+                # spools to go with it (#3407).
+                await self._restore_spool_usage(db, usage_payload, tally, {}, archive_id_map)
+                return
             tally.note("noData", "No data of this kind in this backup")
             return
 
@@ -1880,9 +1886,14 @@ class GitHubRestoreService:
             # cannot be recognised and is re-inserted, which is what the
             # IS NULL comparison this replaced did too: the column is
             # non-nullable, so it never matched either.
+            # Spoolman's ids are its own, so they come back as they were.
+            spoolman_spool_id = entry.get("spoolman_spool_id")
+            if not isinstance(spoolman_spool_id, int) or isinstance(spoolman_spool_id, bool):
+                spoolman_spool_id = None
             existing = await db.execute(
                 select(SpoolUsageHistory).where(
                     SpoolUsageHistory.spool_id == spool_id,
+                    SpoolUsageHistory.spoolman_spool_id == spoolman_spool_id,
                     SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
                     SpoolUsageHistory.print_name == entry.get("print_name"),
                 )
@@ -1921,6 +1932,7 @@ class GitHubRestoreService:
                 )
             row = SpoolUsageHistory(
                 spool_id=spool_id,
+                spoolman_spool_id=spoolman_spool_id,
                 printer_id=printer_id,
                 print_name=entry.get("print_name"),
                 archive_id=archive_id,

+ 28 - 16
backend/app/services/print_scheduler.py

@@ -5864,13 +5864,16 @@ class PrintScheduler:
             spools = await self._stock_spools_spoolman()
             if spools is None:
                 return None
-            # Spoolman owns the usage in this mode and Bambuddy's own history
-            # table holds nothing for those spools, so the rate is always the
-            # delta rate. Bambuddy's table is deliberately not consulted: its ids
-            # are local spool ids, and a Spoolman id that happens to match one
+            # The Spoolman spools' own rows (#3407), keyed by Spoolman id. Never
+            # the built-in ones: a Spoolman id that matches a local spool id
             # (left over from before Spoolman was switched on) would borrow an
-            # unrelated spool's history.
-            history: dict[int, list[stock_forecast.UsageRecord]] = {}
+            # unrelated spool's history (#3406).
+            history = await self._stock_usage_history(db, spoolman=True)
+            history = {
+                spool.id: stock_forecast.own_records(spool, history[spool.id])
+                for spool in spools
+                if spool.id in history
+            }
         else:
             spools = await self._stock_spools_internal(db)
             history = await self._stock_usage_history(db)
@@ -5880,14 +5883,10 @@ class PrintScheduler:
     async def _stock_spools_internal(self, db: AsyncSession) -> list[stock_forecast.StockSpool]:
         from backend.app.models.spool import Spool
 
-        # 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()
-        )
+        # Every spool: stock, the recently archived ones that count towards the
+        # rate, and the older ones that tell it the SKU was in use before its
+        # window (#3407). stock_forecast.forecast_all sorts them.
+        rows = (await db.execute(select(Spool))).scalars().all()
         return [
             stock_forecast.StockSpool(
                 id=spool.id,
@@ -5900,16 +5899,27 @@ class PrintScheduler:
                 weight_used_baseline=float(spool.weight_used_baseline or 0),
                 created_at=spool.created_at,
                 archived_at=spool.archived_at,
+                last_used_at=spool.last_used,
             )
             for spool in rows
         ]
 
-    async def _stock_usage_history(self, db: AsyncSession) -> dict[int, list[stock_forecast.UsageRecord]]:
+    async def _stock_usage_history(
+        self, db: AsyncSession, *, spoolman: bool = False
+    ) -> dict[int, list[stock_forecast.UsageRecord]]:
+        """Usage rows by spool id, for one inventory: its own ids, as the forecast
+        panel reads them from the usage endpoint."""
         from backend.app.models.spool_usage_history import SpoolUsageHistory
 
+        spool_id = SpoolUsageHistory.spoolman_spool_id if spoolman else SpoolUsageHistory.spool_id
         rows = (
             await db.execute(
-                select(SpoolUsageHistory.spool_id, SpoolUsageHistory.created_at, SpoolUsageHistory.weight_used)
+                select(spool_id, SpoolUsageHistory.created_at, SpoolUsageHistory.weight_used)
+                .where(
+                    SpoolUsageHistory.spoolman_spool_id.is_not(None)
+                    if spoolman
+                    else SpoolUsageHistory.spoolman_spool_id.is_(None)
+                )
                 .order_by(SpoolUsageHistory.created_at.desc())
                 .limit(_STOCK_FORECAST_HISTORY_LIMIT)
             )
@@ -5968,6 +5978,8 @@ class PrintScheduler:
                     weight_used_baseline=float(mapped.get("weight_used_baseline") or 0),
                     created_at=created_at,
                     archived_at=archived_at,
+                    first_used_at=_spoolman_time(mapped.get("first_used")),
+                    last_used_at=_spoolman_time(mapped.get("last_used")),
                     color_name_is_synthesized=bool(mapped.get("color_name_is_synthesized")),
                 )
             )

+ 70 - 5
backend/app/services/spoolman_tracking.py

@@ -8,7 +8,7 @@ Supports accurate partial usage reporting for failed/cancelled prints.
 import json
 import logging
 import math
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import cast
 
 from sqlalchemy import delete, select
@@ -690,9 +690,12 @@ async def cleanup_tracking(
     db,
     last_layer_num: int | None = None,
     last_progress: int | None = None,
+    status: str = "failed",
 ) -> float | None:
     """Report partial usage and clean up Spoolman tracking data for failed/aborted prints.
 
+    ``status`` is the print's, recorded on its usage-history rows.
+
     Returns what the charged spools' prices make the run cost, or None when
     none of them carries a price (or nothing was tracked)."""
     from backend.app.models.active_print_spoolman import ActivePrintSpoolman
@@ -722,6 +725,12 @@ async def cleanup_tracking(
         )
     except Exception as e:
         logger.warning("[SPOOLMAN] Partial usage report failed: %s", e)
+    try:
+        await _record_usage_history(db, printer_id, archive_id, status, print_cost)
+    except Exception as e:
+        # The charges landed in Spoolman; only the local record is missing.
+        await db.rollback()
+        logger.warning("[SPOOLMAN] Could not record the usage history for archive %s: %s", archive_id, e)
 
     # Delete tracking data
     await db.execute(
@@ -922,6 +931,52 @@ def _spool_cost_per_gram(spool: dict | None) -> float | None:
     return rate if math.isfinite(rate) else None
 
 
+@dataclass
+class _Charge:
+    """One Spoolman spool charged for a print: what the usage history records."""
+
+    spool_id: int
+    grams: float
+    # Per gram, from the spool; None when it carries no usable price.
+    rate: float | None
+    net_weight: float | None
+
+
+async def _record_usage_history(db, printer_id: int, archive_id: int, status: str, print_cost: "_PrintCost") -> None:
+    """Write a usage-history row per charged Spoolman spool, as the built-in
+    inventory does per charged spool (#3407).
+
+    Spoolman keeps only per-spool totals, so without these Spoolman mode had no
+    per-print usage at all: no trend for the stock forecast, and no usage list
+    on the spool. Priced from the spool, as the archive's cost is: a charge to a
+    spool without a price is recorded without one, so recalculating the
+    archive's cost keeps the figure completion left it at rather than pricing
+    those grams at the default rate (``_apply_spool_cost_to_archive``).
+    """
+    if not print_cost.charges:
+        return
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    archive = await db.get(PrintArchive, archive_id)
+    print_name = archive.print_name if archive is not None else None
+    for charge in print_cost.charges:
+        db.add(
+            SpoolUsageHistory(
+                spool_id=None,
+                spoolman_spool_id=charge.spool_id,
+                printer_id=printer_id,
+                print_name=print_name,
+                archive_id=archive_id,
+                weight_used=round(charge.grams, 1),
+                percent_used=round(charge.grams / charge.net_weight * 100) if charge.net_weight else 0,
+                status=status,
+                cost=round(charge.grams * charge.rate, 2) if charge.rate is not None else None,
+            )
+        )
+    await db.commit()
+
+
 @dataclass
 class _PrintCost:
     """What a print cost, accumulated as each slot is actually charged.
@@ -940,12 +995,16 @@ class _PrintCost:
     unpriced_grams: float = 0.0
     priced: int = 0
     unpriced: int = 0
+    # Each charge as it landed, for the usage history (#3407).
+    charges: list[_Charge] = field(default_factory=list)
 
-    def add(self, grams: float, spool: dict | None, label: str) -> None:
+    def add(self, grams: float, spool: dict | None, label: str, *, spool_id: int | None = None) -> None:
         """Price ``grams`` off ``spool``. Call only after the charge succeeded."""
         if grams <= 0:
             return
         rate = _spool_cost_per_gram(spool)
+        if spool_id is not None:
+            self.charges.append(_Charge(spool_id, grams, rate, _as_positive_number(spoolman_net_weight(spool or {}))))
         if rate is None:
             self.unpriced += 1
             self.unpriced_grams += grams
@@ -1138,7 +1197,7 @@ async def _report_spool_usage_for_slots(
                 # Priced only after the charge landed, so a spool Spoolman refused
                 # cannot contribute to what the print is said to have cost.
                 if cost_out is not None:
-                    cost_out.add(charge_grams, charge_obj, f"Slot {slot_id}")
+                    cost_out.add(charge_grams, charge_obj, f"Slot {slot_id}", spool_id=charge_spool_id)
             except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
                 logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", charge_spool_id, exc)
 
@@ -1305,7 +1364,12 @@ async def _report_spool_usage_split_by_tray_changes(
                     )
                     spools_updated += 1
                     if cost_out is not None:
-                        cost_out.add(round(charge_grams, 2), charge_obj, f"Split slot {slot_id} seg {seg_idx}")
+                        cost_out.add(
+                            round(charge_grams, 2),
+                            charge_obj,
+                            f"Split slot {slot_id} seg {seg_idx}",
+                            spool_id=charge_spool_id,
+                        )
                 except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
                     logger.warning(
                         "[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s",
@@ -1863,6 +1927,7 @@ async def report_usage(printer_id: int, archive_id: int):
         # -- and the grams have already been deducted from these spools, so the
         # archive should say what that deduction was worth.
         await _apply_spool_cost_to_archive(db, archive_id, print_cost)
+        await _record_usage_history(db, printer_id, archive_id, "completed", print_cost)
 
 
 def _print_used_tray_keys(
@@ -2040,7 +2105,7 @@ async def _report_remain_delta_for_slots(
         # ``spool`` here is the full row fetched above for its net weight,
         # so the price is already in hand (#2591).
         if cost_out is not None:
-            cost_out.add(grams_used, spool, f"AMS{ams_id}-T{tray_id}")
+            cost_out.add(grams_used, spool, f"AMS{ams_id}-T{tray_id}", spool_id=spool_id)
         # No 3MF slot_id for this path — use the AMS slot key so the maps can
         # still be inspected by callers if needed. The archive rewrites
         # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only

+ 83 - 23
backend/app/services/stock_forecast.py

@@ -14,8 +14,9 @@ The pieces, in the order the panel applies them:
   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
-  otherwise.
+  otherwise from the consumption since each spool's baseline, spread over the
+  time the spool was in use and counted over the last
+  ``ARCHIVED_RATE_WINDOW_DAYS`` (#3407).
 * ``reorder point = rate * lead time + safety stock``, where the safety stock
   is a statistical term (``Z_95 * sigma * sqrt(lead time)``) plus a margin the
   user sets in days or grams.
@@ -32,7 +33,7 @@ from __future__ import annotations
 import math
 from collections.abc import Mapping, Sequence
 from dataclasses import dataclass
-from datetime import date, datetime, timezone
+from datetime import date, datetime, timedelta, timezone
 
 # One-sided 95 % z-score, as in the panel.
 Z_95 = 1.65
@@ -78,6 +79,10 @@ class StockSpool:
     created_at: datetime | None
     # When the spool was archived (Spoolman: its last use); None while it is stock.
     archived_at: datetime | None = None
+    # When it was first and last used. Spoolman records both; the built-in
+    # inventory only the last, and its first use is its earliest usage record.
+    first_used_at: datetime | None = None
+    last_used_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.
@@ -176,28 +181,72 @@ def history_rate(records: Sequence[UsageRecord], now: datetime) -> tuple[float,
     return mean, math.sqrt(variance)
 
 
-def delta_rate(spools: Sequence[StockSpool], now: datetime) -> float | None:
-    """Daily rate from consumption since baseline over the age of the oldest spool.
+def own_records(spool: StockSpool, records: Sequence[UsageRecord]) -> list[UsageRecord]:
+    """The records of this spool, not of an earlier one that had its id.
 
-    The fallback when there is not enough usage history, which is always the
-    case in Spoolman mode: Spoolman owns the usage there and Bambuddy's own
-    ``spool_usage_history`` table holds nothing for those spools. Returns None
-    with nothing consumed, or with under a day of age to divide by.
+    Spoolman can give a deleted spool's id to a new one -- deleted in
+    Spoolman's own UI, or a Spoolman instance replaced -- and the usage rows
+    Bambuddy recorded under it stay. A row from before the spool was created
+    is not its own (#3407). Built-in ids are never handed on.
+    """
+    if spool.created_at is None:
+        return list(records)
+    created = _utc(spool.created_at)
+    return [r for r in records if _utc(r.created_at) >= created]
+
+
+def delta_rate(
+    spools: Sequence[StockSpool],
+    now: datetime,
+    first_use: Mapping[int, datetime] | None = None,
+    used_before: bool = False,
+) -> float | None:
+    """Daily rate from each spool's consumption since its baseline.
+
+    The fallback when there is not enough usage history: a spool's total is
+    all there is, so it is spread evenly over the time the spool was in use,
+    from its first use (``first_used_at``, else ``first_use[id]``, else its
+    creation) to its last (else its archiving, else now). What falls inside the
+    last ``ARCHIVED_RATE_WINDOW_DAYS`` is divided by the days of that window,
+    which starts no earlier than the SKU's first use; ``used_before`` says the
+    SKU has spools archived before the window, so it was in use all along. The
+    grams and the days
+    then cover the same span: before #3407 the days ran from the oldest
+    spool's creation, so shelf time counted as use, and a spool used up
+    inside them but last used before the window was left out whole.
+
+    Returns None with nothing consumed, or with under a day to divide by.
     """
-    total_used = sum(s.consumed_g for s in spools)
-    if total_used == 0:
-        return None
     now = _utc(now)
-    oldest = now
+    window_start = now - timedelta(days=ARCHIVED_RATE_WINDOW_DAYS)
+    spans: list[tuple[float, datetime, datetime]] = []
     for spool in spools:
-        if spool.created_at is not None:
-            created = _utc(spool.created_at)
-            if created < oldest:
-                oldest = created
-    age_days = (now - oldest).total_seconds() / _SECONDS_PER_DAY
-    if age_days < 1:
+        consumed = spool.consumed_g
+        if consumed <= 0:
+            continue
+        end = spool.last_used_at or spool.archived_at
+        end = min(_utc(end), now) if end is not None else now
+        start = spool.first_used_at or (first_use or {}).get(spool.id) or spool.created_at
+        start = min(_utc(start), end) if start is not None else end
+        spans.append((consumed, start, end))
+    if not spans:
+        return None
+
+    observed_from = window_start if used_before else max(window_start, min(start for _, start, _ in spans))
+    days = (now - observed_from).total_seconds() / _SECONDS_PER_DAY
+    if days < 1:
         return None
-    return total_used / age_days
+    grams = 0.0
+    for consumed, start, end in spans:
+        length = (end - start).total_seconds()
+        if length < _SECONDS_PER_DAY:
+            # Used up within a day: all of it at its end.
+            grams += consumed if end >= observed_from else 0.0
+            continue
+        inside = (end - max(start, observed_from)).total_seconds()
+        if inside > 0:
+            grams += consumed * inside / length
+    return grams / days
 
 
 def forecast_sku(
@@ -207,13 +256,16 @@ def forecast_sku(
     global_lead_time_days: int,
     now: datetime,
     used_up: Sequence[StockSpool] = (),
+    used_before: bool = False,
 ) -> SkuForecast:
     """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."""
+    towards the rate only; ``used_before`` says it has spools archived longer
+    ago than that."""
     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]
+    own = {spool.id: list(history_by_spool.get(spool.id, ())) for spool in rate_spools}
 
     # 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
@@ -221,7 +273,7 @@ def forecast_sku(
     history: list[UsageRecord] = []
     for spool in rate_spools:
         if spool.weight_used_baseline == 0:
-            history.extend(history_by_spool.get(spool.id, ()))
+            history.extend(own[spool.id])
 
     rate: float | None = None
     std_dev: float | None = None
@@ -229,7 +281,10 @@ def forecast_sku(
     if measured is not None:
         rate, std_dev = measured
     else:
-        rate = delta_rate(rate_spools, now)
+        # A built-in spool's first use is its earliest record, kept or not:
+        # a reset drops records from the rate, not from when use began.
+        first_use = {spool.id: min(r.created_at for r in own[spool.id]) for spool in rate_spools if own[spool.id]}
+        rate = delta_rate(rate_spools, now, first_use, used_before)
 
     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)
@@ -290,11 +345,15 @@ def forecast_all(
     """
     groups: dict[SkuKey, list[StockSpool]] = {}
     used_up: dict[SkuKey, list[StockSpool]] = {}
+    used_before: set[SkuKey] = set()
     for spool in spools:
         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)
+        elif spool.weight_used > 0:
+            # Only a spool that was used says the SKU was in use before the window.
+            used_before.add(spool.key)
 
     out: ForecastMap = {}
     for key, members in groups.items():
@@ -309,6 +368,7 @@ def forecast_all(
                 global_lead_time_days,
                 now,
                 used_up=used_up.get(key, ()),
+                used_before=key in used_before,
             ),
             members[0],
         )

+ 2 - 2
backend/tests/integration/test_spoolman_cost_preserved_2591.py

@@ -2,8 +2,8 @@
 
 ``spoolman_tracking`` prices an archive from the linked spools at completion.
 Both cost recalculations rebuild a print's cost from ``SpoolUsageHistory``, and
-Spoolman mode never writes rows there -- the built-in usage tracker is handed
-``spoolman_owns_usage`` at print start. Their catalogue-or-default fallback
+a Spoolman print from before #3407 has no rows there -- the built-in usage
+tracker is handed ``spoolman_owns_usage`` at print start. Their catalogue-or-default fallback
 would therefore overwrite the Spoolman figure with a default-rate one on the
 next bulk recalculate, silently undoing the fix, and the per-slot
 spool resolution it came from is transient and cannot be rebuilt from the

+ 383 - 0
backend/tests/integration/test_spoolman_usage_history_3407.py

@@ -0,0 +1,383 @@
+"""Spoolman spools get the per-print usage history built-in spools have (#3407).
+
+Bambuddy charges Spoolman for every print but kept no record of it, so Spoolman
+mode had no usage history: no trend for the stock forecast and no usage list
+on the spool. Each charge now writes a ``spool_usage_history`` row naming the
+Spoolman spool in ``spoolman_spool_id``, with ``spool_id`` NULL so a Spoolman
+id never reads as a built-in spool's (#3406).
+"""
+
+from datetime import timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.utils.local_time import utcnow_naive
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _row(spoolman_spool_id=None, spool_id=None, *, grams=10.0, days_ago=1.0, archive_id=None, cost=None):
+    return SpoolUsageHistory(
+        spool_id=spool_id,
+        spoolman_spool_id=spoolman_spool_id,
+        weight_used=grams,
+        percent_used=1,
+        status="completed",
+        archive_id=archive_id,
+        cost=cost,
+        created_at=utcnow_naive() - timedelta(days=days_ago),
+    )
+
+
+async def _built_in_spool(db) -> Spool:
+    spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=0)
+    db.add(spool)
+    await db.commit()
+    await db.refresh(spool)
+    return spool
+
+
+async def _rows(db) -> list[SpoolUsageHistory]:
+    return list((await db.execute(select(SpoolUsageHistory).execution_options(populate_existing=True))).scalars().all())
+
+
+async def test_a_failed_print_records_its_partial_usage_with_its_status(db_session, printer_factory, archive_factory):
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+    from backend.app.services import spoolman_tracking
+
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, print_name="Bracket", status="aborted")
+    db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, filament_usage=[], ams_trays={}))
+    await db_session.commit()
+
+    async def _partial(printer_id, tracking, *, last_layer_num=None, last_progress=None, cost_out=None):
+        cost_out.add(40.0, {"id": 5, "price": 20.0, "filament": {"weight": 1000}}, "slot 1", spool_id=5)
+
+    with patch.object(spoolman_tracking, "_report_partial_usage", _partial):
+        await spoolman_tracking.cleanup_tracking(printer.id, archive.id, db_session, status="aborted")
+
+    ((row,),) = [await _rows(db_session)]
+    assert (row.spool_id, row.spoolman_spool_id, row.weight_used, row.status, row.print_name) == (
+        None,
+        5,
+        40.0,
+        "aborted",
+        "Bracket",
+    )
+    assert (row.percent_used, row.cost) == (4, 0.8)
+
+
+async def test_an_unpriced_spool_is_recorded_without_a_price(db_session, printer_factory, archive_factory):
+    """As completion leaves the archive's cost alone for it, so a recalculation
+    keeps that figure too (see the recalculation test below)."""
+    from backend.app.services.spoolman_tracking import _PrintCost, _record_usage_history
+
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id)
+    cost = _PrintCost()
+    cost.add(100.0, {"id": 6, "filament": {"weight": 1000}}, "slot 2", spool_id=6)
+
+    await _record_usage_history(db_session, printer.id, archive.id, "completed", cost)
+
+    ((row,),) = [await _rows(db_session)]
+    assert (row.weight_used, row.cost) == (100.0, None)
+
+
+async def test_recalculating_keeps_the_cost_of_a_print_from_unpriced_spools(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """The archive keeps what it was priced at (here the catalogue's 30/kg),
+    not the 25/kg default the unpriced row would otherwise stand for."""
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="default_filament_cost", value="25"))
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, status="completed", cost=3.0)
+    archive.filament_used_grams = 100.0
+    db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=None))
+    await db_session.commit()
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 3.0
+
+
+async def test_the_global_usage_list_is_one_inventorys(async_client: AsyncClient, db_session):
+    spool = await _built_in_spool(db_session)
+    db_session.add_all([_row(spool_id=spool.id), _row(spoolman_spool_id=spool.id)])
+    await db_session.commit()
+
+    built_in = (await async_client.get("/api/v1/inventory/usage")).json()
+    spoolman = (await async_client.get("/api/v1/inventory/usage", params={"spoolman": "true"})).json()
+
+    assert [(r["spool_id"], r["spoolman_spool_id"]) for r in built_in] == [(spool.id, None)]
+    assert [(r["spool_id"], r["spoolman_spool_id"]) for r in spoolman] == [(None, spool.id)]
+
+
+async def test_a_spoolman_spool_lists_and_clears_its_own_usage(async_client: AsyncClient, db_session):
+    """Never a built-in spool's that shares its id."""
+    spool = await _built_in_spool(db_session)
+    db_session.add_all([_row(spoolman_spool_id=spool.id, grams=12.0), _row(spool_id=spool.id, grams=99.0)])
+    await db_session.commit()
+    url = f"/api/v1/spoolman/inventory/spools/{spool.id}/usage"
+
+    listed = (await async_client.get(url)).json()
+    assert [r["weight_used"] for r in listed] == [12.0]
+
+    assert (await async_client.delete(url)).status_code == 200
+    assert [(r.spool_id, r.weight_used) for r in await _rows(db_session)] == [(spool.id, 99.0)]
+
+
+async def test_deleting_the_spoolman_spool_keeps_its_usage_without_its_id(async_client: AsyncClient, db_session):
+    """Spoolman can give the id to a new spool, which must not inherit it."""
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    db_session.add_all([_row(spoolman_spool_id=5), _row(spoolman_spool_id=6)])
+    await db_session.commit()
+    client = MagicMock()
+    client.delete_spool = AsyncMock()
+
+    with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)):
+        response = await async_client.delete("/api/v1/spoolman/inventory/spools/5")
+
+    assert response.status_code == 200, response.text
+    assert sorted((r.spoolman_spool_id or 0) for r in await _rows(db_session)) == [0, 6]
+
+
+async def test_the_stock_alerts_use_the_spoolman_rows_in_spoolman_mode(db_session):
+    """And with them the trend, as built-in spools have it."""
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.app.services.stock_forecast import UsageRecord, history_rate
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    days = (30, 20, 10)
+    db_session.add_all([_row(spoolman_spool_id=7, grams=30.0, days_ago=d) for d in days])
+    # A built-in spool 7, from before the switch: not the Spoolman spool's.
+    db_session.add_all([_row(spool_id=None, grams=500.0, days_ago=d) for d in (5, 4)])
+    await db_session.commit()
+    spoolman = MagicMock()
+    spoolman.get_all_spools = AsyncMock(
+        return_value=[
+            {
+                "id": 7,
+                "remaining_weight": 900.0,
+                "used_weight": 100.0,
+                "registered": (utcnow_naive() - timedelta(days=100)).isoformat() + "Z",
+                "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
+            }
+        ]
+    )
+
+    with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
+        ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
+
+    trend = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in days], utcnow_naive())
+    assert forecast.daily_rate_g == pytest.approx(trend[0], rel=1e-3)
+
+
+async def test_recalculating_costs_rebuilds_a_spoolman_archive_from_its_rows(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """The rows carry what the charges were priced at, so the figure stays."""
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="default_filament_cost", value="25"))
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, status="completed", cost=4.0)
+    archive.filament_used_grams = 100.0
+    db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=4.0))
+    await db_session.commit()
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 4.0
+
+
+async def test_a_backup_restores_the_spoolman_spool(db_session):
+    from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
+
+    usage = {
+        "usage_history": [
+            {
+                "id": 1,
+                "spool_id": None,
+                "spoolman_spool_id": 41,
+                "weight_used": 12.5,
+                "created_at": "2026-01-06 09:00:00",
+            },
+            {
+                "id": 2,
+                "spool_id": None,
+                "spoolman_spool_id": True,
+                "weight_used": 1.0,
+                "created_at": "2026-01-06 10:00:00",
+            },
+        ]
+    }
+    tally = _CategoryTally()
+    await GitHubRestoreService()._restore_spool_usage(db_session, usage, tally, {}, {})
+    await db_session.commit()
+
+    assert sorted((r.spoolman_spool_id or 0, r.weight_used) for r in await _rows(db_session)) == [(0, 1.0), (41, 12.5)]
+
+    # Two Spoolman spools charged the same at the same moment are two rows.
+    twins = {
+        "usage_history": [
+            {"id": 3, "spoolman_spool_id": 50, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
+            {"id": 4, "spoolman_spool_id": 51, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
+        ]
+    }
+    await GitHubRestoreService()._restore_spool_usage(db_session, twins, _CategoryTally(), {}, {})
+    await db_session.commit()
+    assert sorted(r.spoolman_spool_id or 0 for r in await _rows(db_session) if r.weight_used == 7.0) == [50, 51]
+    usage["usage_history"] += twins["usage_history"]
+
+    # Restored again: recognised, not doubled.
+    await GitHubRestoreService()._restore_spool_usage(db_session, usage, _CategoryTally(), {}, {})
+    await db_session.commit()
+    assert len(await _rows(db_session)) == 4
+
+
+async def test_a_backup_without_built_in_spools_restores_the_usage(db_session):
+    """A Spoolman-mode backup has no spools/inventory.json to go with it."""
+    from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
+
+    usage = {"usage_history": [{"id": 1, "spoolman_spool_id": 41, "weight_used": 5.0, "created_at": "2026-01-06"}]}
+    await GitHubRestoreService()._restore_spools(db_session, None, usage, False, _CategoryTally(), {})
+    await db_session.commit()
+
+    assert [r.spoolman_spool_id for r in await _rows(db_session)] == [41]
+
+
+async def test_a_backup_carries_the_spoolman_spool(db_session):
+    from backend.app.services.github_backup import GitHubBackupService
+
+    db_session.add(_row(spoolman_spool_id=41))
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+    assert [u["spoolman_spool_id"] for u in files["spools/usage_history.json"]["usage_history"]] == [41]
+
+
+async def test_each_inventory_reads_its_own_rows_up_to_the_limit(db_session, monkeypatch):
+    """The read is capped; newer rows of the other inventory must not use it up."""
+    from backend.app.services import print_scheduler
+
+    built_in = await _built_in_spool(db_session)
+    db_session.add(_row(spool_id=built_in.id, days_ago=5))
+    db_session.add_all([_row(spoolman_spool_id=9, days_ago=d) for d in (1, 2)])
+    await db_session.commit()
+    monkeypatch.setattr(print_scheduler, "_STOCK_FORECAST_HISTORY_LIMIT", 1)
+
+    scheduler = print_scheduler.PrintScheduler()
+    assert list(await scheduler._stock_usage_history(db_session)) == [built_in.id]
+    assert list(await scheduler._stock_usage_history(db_session, spoolman=True)) == [9]
+
+
+async def test_the_spoolman_stock_alerts_spread_a_spool_from_its_first_use(db_session):
+    """Registered 120 days ago, first printed with 21 days ago, last 2 days ago."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    await db_session.commit()
+
+    def _iso(days):
+        return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
+
+    spoolman = MagicMock()
+    spoolman.get_all_spools = AsyncMock(
+        return_value=[
+            {
+                "id": 3,
+                "remaining_weight": 900.0,
+                "used_weight": 100.0,
+                "registered": _iso(120),
+                "first_used": _iso(21),
+                "last_used": _iso(2),
+                "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
+            }
+        ]
+    )
+    with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
+        ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
+
+    assert forecast.daily_rate_g == pytest.approx(100 / 21, rel=1e-3)
+
+
+async def test_the_built_in_stock_alerts_end_a_spool_at_its_last_use(db_session):
+    """Created 120 days ago, last used 100 days ago: nothing in the last 90."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    spool = await _built_in_spool(db_session)
+    spool.weight_used = 300.0
+    spool.created_at = utcnow_naive() - timedelta(days=120)
+    spool.last_used = utcnow_naive() - timedelta(days=100)
+    await db_session.commit()
+
+    ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
+
+    assert forecast.daily_rate_g == 0
+
+
+async def test_the_spoolman_stock_alerts_end_a_spool_at_its_last_use(db_session):
+    """Used from 150 to 60 days ago: 30 of its 90 days of use are in the window,
+    so 30 of its 90 g. Same as the panel's test."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    await db_session.commit()
+
+    def _iso(days):
+        return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
+
+    spoolman = MagicMock()
+    spoolman.get_all_spools = AsyncMock(
+        return_value=[
+            {
+                "id": 4,
+                "remaining_weight": 910.0,
+                "used_weight": 90.0,
+                "registered": _iso(200),
+                "first_used": _iso(150),
+                "last_used": _iso(60),
+                "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
+            }
+        ]
+    )
+    with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
+        ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
+
+    assert forecast.daily_rate_g == pytest.approx(30 / 90, rel=1e-3)
+
+
+async def test_rows_of_an_earlier_spool_with_the_same_id_are_not_its_own(db_session):
+    """Spoolman handed id 6 to a new spool, registered 15 days ago; rows from
+    before that are the deleted spool's."""
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.app.services.stock_forecast import UsageRecord, history_rate
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add_all([_row(spoolman_spool_id=6, grams=30.0, days_ago=d) for d in (40, 30, 10, 5)])
+    await db_session.commit()
+    spoolman = MagicMock()
+    spoolman.get_all_spools = AsyncMock(
+        return_value=[
+            {
+                "id": 6,
+                "remaining_weight": 940.0,
+                "used_weight": 60.0,
+                "registered": (utcnow_naive() - timedelta(days=15)).isoformat() + "Z",
+                "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
+            }
+        ]
+    )
+    with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
+        ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
+
+    own = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in (10, 5)], utcnow_naive())
+    assert forecast.daily_rate_g == pytest.approx(own[0], rel=1e-3)

+ 28 - 0
backend/tests/unit/services/test_spoolman_print_cost_2591.py

@@ -157,6 +157,8 @@ class TestReportUsagePricesTheArchive:
             side_effect=lambda sid: next((s for s in spools_by_tag.values() if s["id"] == sid), None)
         )
         client.use_spool = AsyncMock()
+        # For the usage-history test: what the run wrote (#3407).
+        client.test_db = db
 
         pm = MagicMock()
         pm.get_status.return_value = state
@@ -219,6 +221,32 @@ class TestReportUsagePricesTheArchive:
         client.use_spool.assert_awaited_once_with(41, 100.0)
         assert archive.cost == pytest.approx(4.0)
 
+    @pytest.mark.asyncio
+    async def test_each_charge_is_recorded_in_the_usage_history(self):
+        """Spoolman keeps per-spool totals only; Bambuddy now keeps the print's
+        usage per spool, as the built-in inventory does (#3407)."""
+        from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
+            ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+            tray_now_at_start=0,
+        )
+        archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=2.5)
+
+        run, client = self._run(tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive)
+        await run()
+
+        rows = [c.args[0] for c in client.test_db.add.call_args_list if isinstance(c.args[0], SpoolUsageHistory)]
+        assert [(r.spool_id, r.spoolman_spool_id, r.weight_used, r.percent_used, r.status, r.cost) for r in rows] == [
+            (None, 41, 100.0, 10, "completed", 4.0)
+        ]
+        assert rows[0].printer_id == 1 and rows[0].archive_id == 7
+
     @pytest.mark.asyncio
     async def test_multi_material_bills_each_slot_at_its_own_price(self):
         """archive.py charged the whole print at the primary type's rate. Two

+ 15 - 7
backend/tests/unit/services/test_stock_alert_producer_2955.py

@@ -346,10 +346,12 @@ async def test_usage_history_beats_the_delta_rate_when_there_is_enough_of_it(db_
 
 
 @pytest.mark.asyncio
-async def test_history_of_a_reset_spool_is_left_out(db_session, scheduler, notify):
+async def test_history_of_a_reset_spool_is_left_out(db_session, scheduler):
     """Same history, but the spool was reset (baseline set): its earlier events have no
-    anchor, so the rate falls back to the delta rate -- 3 g/day, nothing to alert."""
+    anchor, so the rate falls back to the delta rate. They still date when the
+    spool came into use (#3407): 200 g since the reset over those 3 days."""
     from backend.app.models.spool_usage_history import SpoolUsageHistory
+    from backend.app.services.stock_forecast import UsageRecord, history_rate
 
     await _sku(db_session)
     spool = await _spool(db_session, used=300.0, baseline=100.0)
@@ -361,10 +363,14 @@ async def test_history_of_a_reset_spool_is_left_out(db_session, scheduler, notif
         )
     await db_session.commit()
 
-    await _pass(scheduler, db_session)
+    ((forecast, _),) = (await scheduler._stock_forecasts(db_session)).values()
 
-    notify.on_stock_break_alert.assert_not_awaited()
-    notify.on_stock_reorder_alert.assert_not_awaited()
+    trend = history_rate(
+        [UsageRecord(utcnow_naive() - timedelta(days=d), 300.0) for d in (3, 2, 1)],
+        utcnow_naive(),
+    )
+    assert trend is not None and forecast.daily_rate_g != pytest.approx(trend[0])
+    assert forecast.daily_rate_g == pytest.approx(200 / 3, rel=1e-3)
 
 
 # -- once per transition -----------------------------------------------------
@@ -898,8 +904,10 @@ async def test_spoolman_mode_counts_a_used_up_spool_towards_the_rate(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)
+    # What falls inside the last 90 days (#3407): 684 of the stock spool's 760 g
+    # (used over 100 days) and 800 of the used-up one's 900 (used over 90, up to
+    # 10 days ago). The stale ones add nothing.
+    assert forecast.daily_rate_g == pytest.approx((684 + 800) / 90)
     assert forecast.remaining_g == pytest.approx(240.0)
     assert named_by.id == 7
 

+ 18 - 13
backend/tests/unit/services/test_stock_forecast_2955.py

@@ -106,7 +106,8 @@ SCENARIOS = {
         SkuSettings(lead_time_days=6, safety_margin_value=3),
         0,
     ),
-    # Two spools of one SKU: the delta rate divides by the age of the oldest.
+    # Two spools of one SKU: the delta rate counts what falls inside the last 90
+    # days -- 90 of the older spool's 400 g, all of the newer one's 100 (#3407).
     "J": ([_spool(1, 400, 400), _spool(2, 100, 5)], {}, SkuSettings(lead_time_days=10), 0),
 }
 
@@ -203,11 +204,11 @@ EXPECTED = {
     },
     "J": {
         "remaining_g": 1500,
-        "daily_rate_g": 1.25,
+        "daily_rate_g": 2.111111111111111,
         "effective_lead_time_days": 10,
-        "reorder_point_g": 31.304439534819455,
-        "days_remaining": 1200,
-        "days_until_reorder_point": 1174,
+        "reorder_point_g": 52.86972010325064,
+        "days_remaining": 710,
+        "days_until_reorder_point": 685,
         "stock_break_alert": False,
         "reorder_alert": False,
     },
@@ -251,27 +252,30 @@ def test_a_recently_archived_spool_counts_towards_the_rate_not_the_stock():
 
     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)
+    # 16 g plus the 800 of the 900 g that fall inside the last 90 days, over
+    # those 90 days (#3407), not 16 g over 20.
+    assert forecast.daily_rate_g == pytest.approx(816 / 90)
     # 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():
+def test_a_spool_archived_over_90_days_ago_adds_no_grams():
+    """But it was in use before the window, so the window counts in full (#3407):
+    16 g over 90 days, not over the 20 since the newer spool was added."""
     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)
+    assert forecast.daily_rate_g == pytest.approx(16 / 90)
 
 
-def test_the_window_includes_its_last_day():
+def test_a_spool_whose_use_ended_at_the_windows_start_adds_no_grams():
     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)
+    assert forecast.daily_rate_g == pytest.approx(16 / 90)
 
 
 def test_a_sku_with_only_archived_spools_is_not_forecast():
@@ -289,5 +293,6 @@ def test_a_recently_archived_spools_history_counts():
     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))
+    # With only the stock spool's single day there is no history rate at all;
+    # that day is still when its use began (#3407).
+    assert alone.daily_rate_g == pytest.approx(delta_rate(spools[:1], NOW, {1: _ago(2)}))

+ 134 - 0
backend/tests/unit/services/test_stock_forecast_delta_rate_3407.py

@@ -0,0 +1,134 @@
+"""The stock forecast's fallback rate counts grams over the time they were used (#3407).
+
+Spoolman keeps per-spool totals only, so the rate there was the grams used
+since each spool's baseline over the days since the oldest spool was
+registered. Shelf time before a spool's first use counted as use, and a spool
+used up inside that span but last used before the 90-day window was left out
+whole while its days stayed in. The reporter's SKU of three spools, all
+registered 120 days ago, came out at 9.2 g/d.
+
+Now each spool's grams are spread over its own first to last use, and what
+falls inside the last 90 days is divided by those days. The panel computes the
+same (ForecastPanelDeltaRate.test.tsx pins the same numbers).
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from backend.app.services.stock_forecast import SkuSettings, StockSpool, UsageRecord, delta_rate, forecast_all
+
+NOW = datetime(2026, 10, 28, tzinfo=timezone.utc)
+
+
+def _ago(days: float) -> datetime:
+    return (NOW - timedelta(days=days)).replace(tzinfo=None)
+
+
+def _spool(spool_id, used, *, registered, first=None, last=None, archived=False) -> StockSpool:
+    """In Spoolman's terms: archiving is dated by the last use, as the mapping does."""
+    return StockSpool(
+        id=spool_id,
+        material="PLA",
+        subtype="Silk",
+        brand="eSun",
+        color_name="Red",
+        label_weight=1000,
+        weight_used=used,
+        weight_used_baseline=0,
+        created_at=_ago(registered),
+        archived_at=_ago(last) if archived else None,
+        first_used_at=_ago(first) if first is not None else None,
+        last_used_at=_ago(last) if last is not None else None,
+    )
+
+
+# The reporter's table: A 07-01..07-19, B 08-28..10-07 (both used up), C from 10-07.
+A = _spool(1, 1000, registered=120, first=119, last=101, archived=True)
+B = _spool(2, 1000, registered=120, first=61, last=21, archived=True)
+C = _spool(3, 100, registered=120, first=21, last=2)
+
+
+def _rate(spools):
+    forecast, _ = forecast_all(spools, {}, {}, 0, NOW)[C.key]
+    return forecast.daily_rate_g
+
+
+def test_the_reporters_sku():
+    """1100 g in the last 90 days. A was used before them, so the SKU was in
+    use for all 90 (the gap between A and B is real zero use)."""
+    assert _rate([A, B, C]) == pytest.approx(1100 / 90)
+
+
+def test_shelf_time_before_the_first_use_is_not_use():
+    """Registered 120 days ago, first printed with 21 days ago."""
+    assert _rate([C]) == pytest.approx(100 / 21)
+
+
+def test_a_spool_used_across_the_windows_start_counts_its_share():
+    """Used evenly over 100 days, 90 of them inside the window."""
+    spool = _spool(1, 1000, registered=150, first=100, last=0)
+    assert _rate([spool]) == pytest.approx(900 / 90)
+
+
+def test_without_first_use_the_span_starts_at_creation():
+    """The built-in inventory records no first use and no usage history here:
+    the creation date stands in, as before."""
+    spool = _spool(1, 300, registered=20)
+    assert _rate([spool]) == pytest.approx(15)
+
+
+def test_a_built_in_spools_first_use_is_its_earliest_record():
+    """One day of history is not enough for the trend, but says when use began."""
+    spool = _spool(1, 300, registered=120)
+    history = {1: [UsageRecord(created_at=_ago(30), weight_used=100)]}
+    forecast, _ = forecast_all([spool], history, {}, 0, NOW)[spool.key]
+    assert forecast.daily_rate_g == pytest.approx(300 / 30)
+
+
+def test_a_spool_used_up_within_a_day_counts_at_its_end():
+    spool = _spool(1, 200, registered=60, first=10.2, last=10)
+    assert delta_rate([spool], NOW) == pytest.approx(200 / 10.2)
+
+
+def test_a_spool_of_one_print_is_not_dropped():
+    """First and last use the same moment: a span of nothing, all of it at once."""
+    spool = _spool(1, 200, registered=60, first=10, last=10)
+    assert delta_rate([spool], NOW) == pytest.approx(200 / 10)
+
+
+def test_a_point_before_the_window_adds_nothing():
+    """A built-in spool archived lately but last used long before: its grams
+    are older than the window, though it counts as recently used up."""
+    stock = _spool(1, 90, registered=200, first=200, last=0)
+    old = _spool(2, 1000, registered=100, last=100)
+    old = StockSpool(**{**old.__dict__, "archived_at": _ago(10), "first_used_at": None})
+    assert delta_rate([stock, old], NOW) == pytest.approx(90 * 90 / 200 / 90)
+
+
+def test_nothing_consumed_is_no_rate():
+    assert delta_rate([_spool(1, 0, registered=50)], NOW) is None
+
+
+def test_nothing_used_in_the_window_is_a_rate_of_zero():
+    """Used, but not lately: zero, not unknown."""
+    stock = _spool(1, 500, registered=300, first=200, last=150)
+    assert _rate([stock]) == 0
+
+
+def test_settings_do_not_change_the_rate():
+    forecast, _ = forecast_all([A, B, C], {}, {C.key: SkuSettings(lead_time_days=7)}, 0, NOW)[C.key]
+    assert forecast.daily_rate_g == pytest.approx(1100 / 90)
+
+
+def test_a_spool_archived_long_ago_and_never_used_does_not_stretch_the_window():
+    never = StockSpool(**{**_spool(9, 0, registered=400).__dict__, "archived_at": _ago(400)})
+    assert _rate([never, C]) == pytest.approx(100 / 21)
+
+
+def test_a_built_in_spools_records_count_whatever_its_creation_date():
+    """Built-in ids are never handed on, so the reused-id guard is Spoolman's."""
+    spool = _spool(1, 300, registered=10)
+    history = {1: [UsageRecord(created_at=_ago(d), weight_used=100) for d in (30, 20)]}
+    forecast, _ = forecast_all([spool], history, {}, 0, NOW)[spool.key]
+    assert forecast.daily_rate_g == pytest.approx(10)

+ 191 - 0
frontend/src/__tests__/components/ForecastPanelDeltaRate.test.tsx

@@ -0,0 +1,191 @@
+/**
+ * The forecast's rate when there is not enough usage history (#3407): each
+ * spool's grams spread over the time it was in use, counted over the last 90
+ * days. Spoolman mode always used it, and it counted shelf time as use.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent } 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 table, all registered 120 days ago: A 07-01..07-19 and B
+// 08-28..10-07 used up (archived, dated by the last use as Spoolman's mapping
+// does), C in use since 10-07. Same numbers as test_stock_forecast_delta_rate_3407.py.
+const sm = (id: number, used: number, first: number, last: number, archived: boolean) =>
+  spool(id, {
+    label_weight: 1000,
+    weight_used: used,
+    created_at: ago(120),
+    first_used: ago(first),
+    last_used: ago(last),
+    archived_at: archived ? ago(last) : null,
+  });
+const A = sm(1, 1000, 119, 101, true);
+const B = sm(2, 1000, 61, 21, true);
+const C = sm(3, 100, 21, 2, false);
+
+describe('ForecastPanel: the rate without enough usage history (#3407)', () => {
+  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("the reporter's SKU: 1100 g over the last 90 days, not 1100 g over 120", async () => {
+    render(<ForecastPanel spools={[A, B, C]} spoolmanMode />);
+    // 1100 / 90 = 12.2; it showed 9.2.
+    expect(await screen.findByText('12.2g/d')).toBeInTheDocument();
+  });
+
+  it('shelf time before the first use is not use', async () => {
+    render(<ForecastPanel spools={[C]} spoolmanMode />);
+    // 100 g since the first use 21 days ago, not since registering 120 days ago.
+    expect(await screen.findByText('4.8g/d')).toBeInTheDocument();
+  });
+
+  it("a spool's grams count as far as its use falls inside the window", async () => {
+    // Scenario J of the backend's parity table: 90 of the older spool's 400 g.
+    const old = spool(1, { label_weight: 1000, weight_used: 400, created_at: ago(400) });
+    const young = spool(2, { label_weight: 1000, weight_used: 100, created_at: ago(5) });
+    render(<ForecastPanel spools={[old, young]} />);
+    expect(await screen.findByText('2.1g/d')).toBeInTheDocument();
+  });
+
+  it('a spool ends at its last use', async () => {
+    // Used from 150 to 60 days ago: 30 of its 90 days of use, 300 of its 900 g,
+    // fall inside the window. Same as the backend's test.
+    render(<ForecastPanel spools={[sm(1, 900, 150, 60, false)]} spoolmanMode />);
+    expect(await screen.findByText('3.3g/d')).toBeInTheDocument();
+  });
+
+  it('a spool of one print is not dropped', async () => {
+    // First and last use the same moment: 200 g at once, 10 days ago.
+    render(<ForecastPanel spools={[sm(1, 200, 10, 10, false)]} spoolmanMode />);
+    expect(await screen.findByText('20.0g/d')).toBeInTheDocument();
+  });
+
+  it("a built-in spool's first use is its earliest usage record", async () => {
+    // One day of history: no trend, but it says when the spool came into use.
+    mocked.getAllUsageHistory.mockResolvedValue([
+      { id: 1, spool_id: 1, created_at: ago(30), weight_used: 100 } as SpoolUsageRecord,
+    ]);
+    render(<ForecastPanel spools={[spool(1, { label_weight: 1000, weight_used: 300, created_at: ago(120) })]} />);
+    expect(await screen.findByText('10.0g/d')).toBeInTheDocument();
+  });
+
+  it('a spool archived long ago and never used does not stretch the window', async () => {
+    const never = spool(9, { label_weight: 1000, weight_used: 0, created_at: ago(400), archived_at: ago(400) });
+    render(<ForecastPanel spools={[never, C]} spoolmanMode />);
+    // C alone: 100 g over the 21 days since its first use, not over 90.
+    expect(await screen.findByText('4.8g/d')).toBeInTheDocument();
+  });
+
+  it("an earlier spool's rows under a reused id are not this spool's", async () => {
+    // Spool 6 registered 15 days ago; rows from 40 and 30 days ago name id 6
+    // but belong to a spool Spoolman deleted before handing the id on.
+    const reused = spool(6, { label_weight: 1000, weight_used: 60, created_at: ago(15), first_used: ago(15) });
+    mocked.getAllUsageHistory.mockResolvedValue(
+      [40, 30, 10, 5].map(
+        (d, i) => ({ id: i + 1, spool_id: null, spoolman_spool_id: 6, created_at: ago(d), weight_used: 30 }) as SpoolUsageRecord,
+      ),
+    );
+    render(<ForecastPanel spools={[reused]} spoolmanMode />);
+    // Only its own two rows: 30 g over the 5 days between them.
+    expect(await screen.findByText('6.0g/d')).toBeInTheDocument();
+  });
+
+  it('a SKU not used lately shows a rate of 0 without dividing by it', async () => {
+    mocked.getSkuSettings.mockResolvedValue([
+      {
+        material: 'PLA', subtype: 'Matte', brand: 'Polymaker', color_name: 'Red',
+        lead_time_days: 7, safety_margin_value: 200, safety_margin_unit: 'g', alerts_snoozed: false,
+      },
+    ]);
+    const idle = spool(1, { label_weight: 1000, weight_used: 300, created_at: ago(300), last_used: ago(120) });
+    render(<ForecastPanel spools={[idle]} />);
+
+    fireEvent.click(await screen.findByText('0.0g/d'));
+    expect(await screen.findByText('200')).toBeInTheDocument();
+    expect(document.body.textContent).not.toContain('Infinity');
+    // Nor in the hint behind the info icon.
+    expect(document.body.innerHTML).not.toContain('Infinity');
+  });
+});

+ 15 - 1
frontend/src/__tests__/components/ForecastPanelSpoolmanHistory.test.tsx

@@ -108,7 +108,21 @@ describe('ForecastPanel: usage history in Spoolman mode (#3406)', () => {
 
     expect(await screen.findByText('2.5g/d')).toBeInTheDocument();
     expect(screen.queryByText('Trend')).toBeNull();
-    expect(mocked.getAllUsageHistory).not.toHaveBeenCalled();
+    // The Spoolman spools' own rows are asked for (#3407); built-in rows that
+    // come back anyway name no Spoolman spool.
+    expect(mocked.getAllUsageHistory).toHaveBeenCalledWith(5000, undefined, true);
+  });
+
+  it("the Spoolman spools' own usage rows give Spoolman mode the trend (#3407)", async () => {
+    const spoolmanHistory = [30, 20, 10].map(
+      (daysAgo, i) =>
+        ({ id: i + 1, spool_id: null, spoolman_spool_id: 6, created_at: ago(daysAgo), weight_used: 30 }) as SpoolUsageRecord,
+    );
+    mocked.getAllUsageHistory.mockResolvedValue(spoolmanHistory);
+    render(<ForecastPanel spools={[spoolman6]} spoolmanMode />);
+
+    expect(await screen.findByText('Trend')).toBeInTheDocument();
+    expect(screen.getByText('3.0g/d')).toBeInTheDocument();
   });
 
   it('history cached in built-in mode is not used after switching to Spoolman', async () => {

+ 7 - 4
frontend/src/__tests__/components/ForecastPanelUsedUp.test.tsx

@@ -105,15 +105,18 @@ describe('ForecastPanel: used-up spools (#3399)', () => {
   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();
+    // 16 g plus the 800 of the 900 g that fall inside the last 90 days, over
+    // those 90 days (#3407), not 16 g over 20.
+    expect(await screen.findByText('9.1g/d')).toBeInTheDocument();
     expect(screen.getByText('984g')).toBeInTheDocument();
   });
 
-  it('leaves out a spool archived over 90 days ago', async () => {
+  it('a spool archived over 90 days ago adds no grams', async () => {
     render(<ForecastPanel spools={[stock, usedUp(91)]} />);
 
-    expect(await screen.findByText('0.8g/d')).toBeInTheDocument();
+    // But the SKU was in use before the window, so all 90 days count (#3407):
+    // 16 g over 90, not over the 20 since the newer spool was added.
+    expect(await screen.findByText('0.2g/d')).toBeInTheDocument();
   });
 
   it('does not forecast a SKU whose spools are all archived', async () => {

+ 20 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -27,6 +27,7 @@ vi.mock('../../api/client', () => ({
     getBuiltinFilaments: vi.fn().mockResolvedValue([]),
     getPrinters: vi.fn().mockResolvedValue([]),
     getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    getSpoolmanSpoolUsageHistory: vi.fn().mockResolvedValue([]),
     createSpool: vi.fn().mockResolvedValue({ id: 99 }),
     createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
@@ -660,6 +661,25 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
     vi.clearAllMocks();
   });
 
+  it("shows a Spoolman spool's own usage history (#3407)", async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolmanSpool}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    await waitFor(() => expect(screen.getByText('Edit Spool')).toBeInTheDocument());
+    openColorAndCostTab();
+
+    await waitFor(() => expect(api.getSpoolmanSpoolUsageHistory).toHaveBeenCalledWith(spoolmanSpool.id));
+    expect(api.getSpoolUsageHistory).not.toHaveBeenCalled();
+  });
+
   it('shows the Printers tab for Spoolman spools in non-quickAdd mode', async () => {
     render(
       <SpoolFormModal

+ 83 - 0
frontend/src/__tests__/components/SpoolUsageHistorySpoolman.test.tsx

@@ -0,0 +1,83 @@
+/**
+ * A Spoolman spool's usage list (#3407): the prints Bambuddy charged to it,
+ * read and cleared through the Spoolman routes, never a built-in spool's that
+ * shares its id.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolUsageHistory } from '../../components/SpoolUsageHistory';
+import { api } from '../../api/client';
+import type { SpoolUsageRecord } from '../../api/client';
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getSpoolUsageHistory: vi.fn(),
+      clearSpoolUsageHistory: vi.fn(),
+      getSpoolmanSpoolUsageHistory: vi.fn(),
+      clearSpoolmanSpoolUsageHistory: vi.fn(),
+    },
+  };
+});
+
+const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+
+const record = (printName: string): SpoolUsageRecord => ({
+  id: 1,
+  spool_id: null,
+  spoolman_spool_id: 6,
+  printer_id: 1,
+  print_name: printName,
+  weight_used: 42,
+  percent_used: 4,
+  status: 'completed',
+  cost: 1.05,
+  created_at: '2026-10-20T10:00:00Z',
+});
+
+describe('SpoolUsageHistory for a Spoolman spool (#3407)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocked.getSpoolmanSpoolUsageHistory.mockResolvedValue([record('Bracket')]);
+    mocked.getSpoolUsageHistory.mockResolvedValue([record('Built-in spool 6')]);
+    mocked.clearSpoolmanSpoolUsageHistory.mockResolvedValue({ status: 'cleared' });
+  });
+
+  it("lists the Spoolman spool's own prints", async () => {
+    render(<SpoolUsageHistory spoolId={6} spoolman />);
+
+    expect(await screen.findByText('Bracket')).toBeInTheDocument();
+    expect(mocked.getSpoolmanSpoolUsageHistory).toHaveBeenCalledWith(6);
+    expect(mocked.getSpoolUsageHistory).not.toHaveBeenCalled();
+  });
+
+  it('clears through the Spoolman route', async () => {
+    render(<SpoolUsageHistory spoolId={6} spoolman />);
+    fireEvent.click(await screen.findByRole('button', { name: /clear/i }));
+
+    await waitFor(() => expect(mocked.clearSpoolmanSpoolUsageHistory).toHaveBeenCalledWith(6));
+    expect(mocked.clearSpoolUsageHistory).not.toHaveBeenCalled();
+  });
+
+  it('a built-in spool reads its own', async () => {
+    render(<SpoolUsageHistory spoolId={6} />);
+
+    expect(await screen.findByText('Built-in spool 6')).toBeInTheDocument();
+    expect(mocked.getSpoolmanSpoolUsageHistory).not.toHaveBeenCalled();
+  });
+
+  it("leaves out rows from before the spool existed, an earlier spool's under its id", async () => {
+    mocked.getSpoolmanSpoolUsageHistory.mockResolvedValue([
+      { ...record('Bracket'), id: 1, created_at: '2026-10-20T10:00:00Z' },
+      { ...record('Old spool print'), id: 2, created_at: '2026-01-01T10:00:00Z' },
+    ]);
+    render(<SpoolUsageHistory spoolId={6} spoolman createdAt="2026-10-01T00:00:00Z" />);
+
+    expect(await screen.findByText('Bracket')).toBeInTheDocument();
+    expect(screen.queryByText('Old spool print')).toBeNull();
+  });
+});

+ 8 - 4
frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx

@@ -298,7 +298,9 @@ describe('InventoryPage - LOCATION column (Spoolman mode)', () => {
 describe('InventoryPage - Forecast in Spoolman mode (#3406)', () => {
   it("the forecast does not read the built-in inventory's usage history", async () => {
     localStorage.clear();
-    let usageHistoryRead = false;
+    // The Spoolman spools' own rows are read (#3407), never the built-in ones.
+    let builtInUsageRead = false;
+    let spoolmanUsageRead = false;
     server.use(
       http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)),
       http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
@@ -308,8 +310,9 @@ describe('InventoryPage - Forecast in Spoolman mode (#3406)', () => {
       ),
       http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])),
       http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
-      http.get('/api/v1/inventory/usage', () => {
-        usageHistoryRead = true;
+      http.get('/api/v1/inventory/usage', ({ request }) => {
+        if (new URL(request.url).searchParams.get('spoolman') === 'true') spoolmanUsageRead = true;
+        else builtInUsageRead = true;
         return HttpResponse.json([]);
       }),
     );
@@ -320,6 +323,7 @@ describe('InventoryPage - Forecast in Spoolman mode (#3406)', () => {
 
     // The forecast is up, with the rate from the spool's own consumption.
     expect(await screen.findByText('Est.')).toBeInTheDocument();
-    expect(usageHistoryRead).toBe(false);
+    await waitFor(() => expect(spoolmanUsageRead).toBe(true));
+    expect(builtInUsageRead).toBe(false);
   });
 });

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

@@ -3921,6 +3921,8 @@ export interface InventorySpool {
   note: string | null;
   added_full: boolean | null;
   last_used: string | null;
+  // Spoolman mode: when the spool was first printed with (#3407).
+  first_used?: string | null;
   // Last drying (#2863): stamped when an AMS drying run of at least half its
   // length ends, or set by hand. Temperature and hours are null when unknown.
   last_dried_at?: string | null;
@@ -4064,6 +4066,8 @@ export interface SpoolUsageRecord {
   id: number;
   // null: the spool was deleted, its usage kept
   spool_id: number | null;
+  // Spoolman mode: the Spoolman spool charged (#3407); spool_id is null then.
+  spoolman_spool_id?: number | null;
   printer_id: number | null;
   print_name: string | null;
   weight_used: number;
@@ -7314,8 +7318,15 @@ export const api = {
   },
   getSpoolUsageHistory: (spoolId: number, limit = 50) =>
     request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
-  getAllUsageHistory: (limit = 100, printerId?: number) =>
-    request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
+  // One inventory's at a time: ``spoolman`` for the Spoolman spools' (#3407).
+  getAllUsageHistory: (limit = 100, printerId?: number, spoolman = false) =>
+    request<SpoolUsageRecord[]>(
+      `/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}${spoolman ? '&spoolman=true' : ''}`,
+    ),
+  getSpoolmanSpoolUsageHistory: (spoolId: number, limit = 50) =>
+    request<SpoolUsageRecord[]>(`/spoolman/inventory/spools/${spoolId}/usage?limit=${limit}`),
+  clearSpoolmanSpoolUsageHistory: (spoolId: number) =>
+    request<{ status: string }>(`/spoolman/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
   clearSpoolUsageHistory: (spoolId: number) =>
     request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
   syncWeightsFromAms: () =>

+ 94 - 32
frontend/src/components/ForecastPanel.tsx

@@ -19,6 +19,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { ConfirmModal } from './ConfirmModal';
 import { parseUTCDate } from '../utils/date';
+import { ownRecords, timeOf } from '../utils/usageHistory';
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 
@@ -33,6 +34,9 @@ interface SkuGroup {
   /** Spools archived in the last ARCHIVED_RATE_WINDOW_DAYS: no stock, but what
    *  they were used for counts towards the rate (#3399). */
   usedUp: InventorySpool[];
+  /** It has spools archived longer ago than that: it was in use before the
+   *  rate's window, so the window is observed in full (#3407). */
+  usedBefore: boolean;
 }
 
 // An archived spool counts towards its SKU's rate for this long after it was
@@ -167,20 +171,52 @@ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev
   return { rate: mean, stdDev: Math.sqrt(variance) };
 }
 
-function computeDeltaRate(spools: InventorySpool[]): number | null {
-  // 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) => {
-    const t = new Date(sp.created_at).getTime();
-    return t < min ? t : min;
-  }, now);
-  const daysSinceOldest = (now - oldestMs) / 86400000;
-  if (daysSinceOldest < 1) return null;
-  return totalUsed / daysSinceOldest;
+// The rate when there is not enough usage history (#3407). A spool's total is
+// all there is, so it is spread evenly over the time the spool was in use: from
+// its first use (Spoolman's first_used, else its earliest usage record, else
+// its creation) to its last (else its archiving, else now). What falls inside
+// the last ARCHIVED_RATE_WINDOW_DAYS is divided by the days of that window,
+// which starts no earlier than the SKU's first use (all of it, when the SKU
+// has spools archived before the window), so shelf time is not use
+// and the grams and the days cover the same span. Same as
+// stock_forecast.delta_rate in the backend.
+function computeDeltaRate(
+  spools: InventorySpool[],
+  firstUse: Map<number, number>,
+  usedBefore: boolean,
+  now: number,
+): number | null {
+  const spans: { consumed: number; start: number; end: number }[] = [];
+  for (const sp of spools) {
+    // 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 consumed = Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0));
+    if (consumed <= 0) continue;
+    const endAt = timeOf(sp.last_used) ?? timeOf(sp.archived_at);
+    const end = endAt === null ? now : Math.min(endAt, now);
+    const startAt = timeOf(sp.first_used) ?? firstUse.get(sp.id) ?? timeOf(sp.created_at);
+    const start = startAt === null ? end : Math.min(startAt, end);
+    spans.push({ consumed, start, end });
+  }
+  if (spans.length === 0) return null;
+
+  const windowStart = now - ARCHIVED_RATE_WINDOW_DAYS * 86400000;
+  const observedFrom = usedBefore ? windowStart : Math.max(windowStart, Math.min(...spans.map(s => s.start)));
+  const days = (now - observedFrom) / 86400000;
+  if (days < 1) return null;
+  let grams = 0;
+  for (const { consumed, start, end } of spans) {
+    const length = end - start;
+    if (length < 86400000) {
+      // Used up within a day: all of it at its end.
+      if (end >= observedFrom) grams += consumed;
+      continue;
+    }
+    const inside = end - Math.max(start, observedFrom);
+    if (inside > 0) grams += (consumed * inside) / length;
+  }
+  return grams / days;
 }
 
 function buildProjectionSeries(
@@ -227,12 +263,15 @@ export function ForecastPanel({ spools, spoolmanMode = false }: { spools: Invent
 
   const { data: settings } = useUiSettings({ enabled: canRead });
   const { data: skuSettingsList = [] } = useQuery({ queryKey: ['sku-settings'], queryFn: api.getSkuSettings, staleTime: 60_000, enabled: canRead });
-  // Bambuddy's usage history is the built-in inventory's, keyed by its own
-  // spool ids. In Spoolman mode a Spoolman id that matches one, left over from
-  // before the switch, would borrow that spool's history (#3406): the rate is
-  // the consumption delta there, as in the backend's stock alerts.
-  const { data: fetchedUsageHistory } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead && !spoolmanMode });
-  const usageHistory = useMemo(() => (spoolmanMode ? [] : (fetchedUsageHistory ?? [])), [spoolmanMode, fetchedUsageHistory]);
+  // Each inventory's own usage history, keyed by its own spool ids: in
+  // Spoolman mode the rows Bambuddy wrote for the Spoolman spools it charged
+  // (#3407), never the built-in ones, whose ids a Spoolman id can match (#3406).
+  const { data: usageHistory = [] } = useQuery({
+    queryKey: ['all-usage-history-forecast', spoolmanMode],
+    queryFn: () => api.getAllUsageHistory(5000, undefined, spoolmanMode),
+    staleTime: 60_000,
+    enabled: canRead,
+  });
   const { data: shoppingList = [] } = useQuery({ queryKey: ['shopping-list'], queryFn: api.getShoppingList, staleTime: 30_000, enabled: canRead });
 
   const globalLeadTime = settings?.forecast_global_lead_time_days ?? 0;
@@ -246,21 +285,22 @@ export function ForecastPanel({ spools, spoolmanMode = false }: { spools: Invent
   const usageBySpoolId = useMemo(() => {
     const m = new Map<number, SpoolUsageRecord[]>();
     for (const r of usageHistory) {
+      const spoolId = spoolmanMode ? r.spoolman_spool_id : r.spool_id;
       // Usage of a deleted spool belongs to no spool here.
-      if (r.spool_id === null) continue;
-      const arr = m.get(r.spool_id) ?? [];
+      if (spoolId === null || spoolId === undefined) continue;
+      const arr = m.get(spoolId) ?? [];
       arr.push(r);
-      m.set(r.spool_id, arr);
+      m.set(spoolId, arr);
     }
     return m;
-  }, [usageHistory]);
+  }, [usageHistory, spoolmanMode]);
 
   const groups = useMemo((): SkuGroup[] => {
     const map = new Map<string, SkuGroup>();
     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: [], usedUp: [] };
+      const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, colorName: spool.color_name, spools: [], usedUp: [], usedBefore: false };
       g.spools.push(spool);
       map.set(key, g);
     }
@@ -269,14 +309,19 @@ export function ForecastPanel({ spools, spoolmanMode = false }: { spools: Invent
     // 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);
+      if (!spool.archived_at) continue;
+      const group = map.get(skuKey(spool.material, spool.subtype, spool.brand, spool.color_name));
+      if (!group) continue;
+      if (archivedCounts(spool.archived_at, now)) group.usedUp.push(spool);
+      // Only a spool that was used says the SKU was in use before the window.
+      else if (spool.weight_used > 0) group.usedBefore = true;
     }
     return [...map.values()];
   }, [spools]);
 
   const forecasts = useMemo((): SkuForecast[] => {
     const today = new Date(); today.setHours(0, 0, 0, 0);
+    const now = Date.now();
 
     return groups.map((group): SkuForecast => {
       // Fall back to the NULL-colour row that pre-upgrade users have so their
@@ -300,10 +345,17 @@ export function ForecastPanel({ spools, spoolmanMode = false }: { spools: Invent
       // 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.
+      // Spoolman hands deleted spools' ids on; built-in ids are never reused.
+      const own = new Map(
+        rateSpools.map(s => {
+          const records = usageBySpoolId.get(s.id) ?? [];
+          return [s.id, spoolmanMode ? ownRecords(s, records) : records] as const;
+        }),
+      );
       const groupHistory: SpoolUsageRecord[] = [];
       for (const s of rateSpools) {
         if ((s.weight_used_baseline ?? 0) === 0) {
-          groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
+          groupHistory.push(...(own.get(s.id) ?? []));
         }
       }
 
@@ -317,7 +369,16 @@ export function ForecastPanel({ spools, spoolmanMode = false }: { spools: Invent
         dailyRateStdDev = histResult.stdDev;
         rateTier = 'history';
       } else {
-        const delta = computeDeltaRate(rateSpools);
+        // A spool's first use is its earliest record, kept or not: a reset
+        // drops records from the rate, not from when use began.
+        const firstUse = new Map<number, number>();
+        for (const s of rateSpools) {
+          for (const r of own.get(s.id) ?? []) {
+            const at = timeOf(r.created_at);
+            if (at !== null && at < (firstUse.get(s.id) ?? Infinity)) firstUse.set(s.id, at);
+          }
+        }
+        const delta = computeDeltaRate(rateSpools, firstUse, group.usedBefore, now);
         if (delta !== null) { dailyRateG = delta; rateTier = 'delta'; }
       }
 
@@ -354,7 +415,7 @@ export function ForecastPanel({ spools, spoolmanMode = false }: { spools: Invent
         reorderAlert, stockBreakAlert,
       };
     });
-  }, [groups, settingsMap, usageBySpoolId, globalLeadTime]);
+  }, [groups, settingsMap, usageBySpoolId, globalLeadTime, spoolmanMode]);
 
   const uniqueMaterials = useMemo(() =>
     [...new Set(groups.map((g) => g.material))].sort(),
@@ -1246,7 +1307,8 @@ function SafetyMarginField({
         approx: displayG !== null ? t('forecast.safetyMarginHintDaysApprox', { g: displayG }) : '',
       })
     : t('forecast.safetyMarginHintG', {
-        approx: dailyRateG !== null ? t('forecast.safetyMarginHintGApprox', { days: Math.round(value / dailyRateG) }) : '',
+        // A SKU not used lately has a rate of 0 (#3407): no day count to give.
+        approx: dailyRateG ? t('forecast.safetyMarginHintGApprox', { days: Math.round(value / dailyRateG) }) : '',
       });
 
   return (
@@ -1278,7 +1340,7 @@ function SafetyMarginField({
           {displayG !== null && unit === 'days' && (
             <span className="text-lg font-semibold text-white">≈ {displayG}g</span>
           )}
-          {unit === 'g' && dailyRateG !== null && (
+          {unit === 'g' && !!dailyRateG && (
             <span className="text-lg font-semibold text-white">≈ {Math.round(value / dailyRateG)}d</span>
           )}
           <button onClick={onEdit} className="p-1 text-bambu-gray hover:text-white rounded transition-colors"><Edit2 className="w-3 h-3" /></button>

+ 3 - 3
frontend/src/components/SpoolFormModal.tsx

@@ -1400,10 +1400,10 @@ export function SpoolFormModal({
                 />
               </div>
 
-              {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
-              {isEditing && spool && !spoolmanMode && (
+              {/* Usage History: in Spoolman mode, the prints Bambuddy charged to the spool (#3407) */}
+              {isEditing && spool && (
                 <div>
-                  <SpoolUsageHistory spoolId={spool.id} />
+                  <SpoolUsageHistory spoolId={spool.id} spoolman={spoolmanMode} createdAt={spool.created_at} />
                 </div>
               )}
             </div>

+ 13 - 6
frontend/src/components/SpoolUsageHistory.tsx

@@ -6,6 +6,7 @@ import type { SpoolUsageRecord } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { parseUTCDate } from '../utils/date';
+import { ownRecords } from '../utils/usageHistory';
 function formatDate(dateStr: string): string {
   const date = parseUTCDate(dateStr);
   if (!date) return '';
@@ -15,6 +16,10 @@ function formatDate(dateStr: string): string {
 
 interface SpoolUsageHistoryProps {
   spoolId: number;
+  // A Spoolman spool: the rows Bambuddy wrote for the prints it charged (#3407).
+  spoolman?: boolean;
+  // When the spool was created: older rows are an earlier spool's that had its id.
+  createdAt?: string | null;
 }
 
 const STATUS_COLORS: Record<string, string> = {
@@ -23,20 +28,22 @@ const STATUS_COLORS: Record<string, string> = {
   aborted: 'text-yellow-700 dark:text-yellow-400',
 };
 
-export function SpoolUsageHistory({ spoolId }: SpoolUsageHistoryProps) {
+export function SpoolUsageHistory({ spoolId, spoolman = false, createdAt = null }: SpoolUsageHistoryProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
-  const { data: history, isLoading } = useQuery({
-    queryKey: ['spool-usage', spoolId],
-    queryFn: () => api.getSpoolUsageHistory(spoolId),
+  const { data: fetched, isLoading } = useQuery({
+    queryKey: ['spool-usage', spoolman, spoolId],
+    queryFn: () => (spoolman ? api.getSpoolmanSpoolUsageHistory(spoolId) : api.getSpoolUsageHistory(spoolId)),
   });
 
+  const history = fetched && spoolman ? ownRecords({ created_at: createdAt }, fetched) : fetched;
+
   const clearMutation = useMutation({
-    mutationFn: () => api.clearSpoolUsageHistory(spoolId),
+    mutationFn: () => (spoolman ? api.clearSpoolmanSpoolUsageHistory(spoolId) : api.clearSpoolUsageHistory(spoolId)),
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['spool-usage', spoolId] });
+      queryClient.invalidateQueries({ queryKey: ['spool-usage', spoolman, spoolId] });
       showToast(t('inventory.historyCleared'), 'success');
     },
   });

+ 19 - 0
frontend/src/utils/usageHistory.ts

@@ -0,0 +1,19 @@
+import type { SpoolUsageRecord } from '../api/client';
+import { parseUTCDate } from './date';
+
+/** A timestamp in ms, or null when missing or unreadable (naive = UTC). */
+export function timeOf(value: string | null | undefined): number | null {
+  const at = value ? parseUTCDate(value)?.getTime() : undefined;
+  return at === undefined || Number.isNaN(at) ? null : at;
+}
+
+/** The records of this spool, not of an earlier one that had its id: Spoolman
+ *  can give a deleted spool's id to a new one, and the usage rows Bambuddy
+ *  recorded under it stay. A row from before the spool was created is not its
+ *  own (#3407). Built-in ids are never handed on. Same as
+ *  stock_forecast.own_records in the backend. */
+export function ownRecords(spool: { created_at: string | null }, records: SpoolUsageRecord[]): SpoolUsageRecord[] {
+  const created = timeOf(spool.created_at);
+  if (created === null) return records;
+  return records.filter(r => (timeOf(r.created_at) ?? created) >= created);
+}

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AdditionalSection-2WFC952c.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AmsUnitCard-Roh0GRe8.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ArchivesPage-BYL-wceG.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AreaChart-vJnRYZ8X.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AssignToAmsModal-Dyz7w6Is.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AuthContext-CmTN4q4U.js


+ 1 - 1
static/assets/Button-Bj3LhBE2.js → static/assets/Button-DZq9YkGs.js

@@ -1 +1 @@
-import{w as e}from"./client-Wixdakxn.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-8XqSBxc-.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CamWallPage-yFwPX4qE.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraDiagnoseModal-BUy1BH5K.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraPage-Cn0lyt6D.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraWall-pndz7IrG.js


+ 1 - 1
static/assets/Card-FgTRnwUz.js → static/assets/Card-DQ4O8sjc.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CartesianChart-BtjX66WV.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ColumnConfigModal-ro24Wsda.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CCJBPE8O.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConfirmModal-BAiQSdF-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConfirmOutcomeDialog-C2TGy01-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConnectAuthorizePage-uppIxj7u.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConnectionDiagnostic-CirSjZ36.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ContextMenu-CjSMQjlR.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CopyButton-BL2Qtj5g.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/EmbeddedCameraViewer-1c0A6JuB.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ErrorBarContext-BTcCz-yo.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ExternalLinkPage-Cc0cY4MB.js


+ 1 - 1
static/assets/FilamentSwatch-CAdwRRk_.js → static/assets/FilamentSwatch-b34zntYa.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/FileManagerPage-CRabD5zB.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/FileUploadModal-CmEPhkuE.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/FinancePage-C5VbzQ9e.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/GCodeViewerPage-DfvJSVm8.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/GcodeToolpathViewer-CYiE6IYk.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/GroupEditPage-7w-QTXS6.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/IconPicker-Cfn2hKEM.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ImagePreviewModal-Btmk29cN.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/InventoryPage-DWdm7M1K.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/InventoryPage-nFXtC2i2.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/Layout-VDJYUcZm.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/LibraryTrashPage-BWuP1EJw.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/LoginPage-ur3Ey4w-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/MaintenancePage-CUWJ8F9Q.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ModelSourcesPage-Dyj1919n.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/NotificationsPage-DAa4jLgY.js


+ 1 - 1
static/assets/NumberInput-VlZYDJ-L.js → static/assets/NumberInput-mKi1v1k8.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-CshZQHBk.js → static/assets/OverlayFrame-Deb3W49X.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-Wixdakxn.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-8XqSBxc-.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PdfPreviewModal-C1DME1Bi.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PhotoGalleryModal-Dt6zcy0a.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrintModal-DLimPejf.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrinterLocationsPage-XLXVfYis.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrintersPage-Do_mA8X5.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ProfilesPage-CHrPvRhn.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ProjectDetailPage-DmxC26IT.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ProjectsPage-CMzjnHm5.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/QueryClientProvider-Cx3hLxdK.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/QueuePage-DPNaibs8.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ReferenceLine-9Jin1_zQ.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/RichTextEditor-vzw_MOnk.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SettingsPage-BRI7KDcc.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SetupPage-CqLAI7ZM.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SkipObjectsModal-Dn3ii47e.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SliceModal-DJwnqzL-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BkcS4WRZ.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyDashboard-CiDIRwNV.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DBY1Z7DZ.js


File diff ditekan karena terlalu besar
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DkwYU1Vh.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-ydY-PyZe.js


+ 1 - 1
static/assets/SpoolIcon-BzEmwIft.js → static/assets/SpoolIcon-CiFF8I-M.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Wixdakxn.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-8XqSBxc-.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Dc7yhxGy.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/StatsPage-SABPnjhR.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/StreamOverlayPage-DcmoC58k.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SystemHealthPanel-C4UjWQN2.js


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini