Prechádzať zdrojové kódy

Recalculate Costs uses the spools' current prices

- Usage records of spools still in the inventory get the spool's
  current price (or the default cost) before archive costs are rebuilt;
  a deleted spool's records keep what they cost
- Spoolman charges are repriced from the spool's price in Spoolman;
  unreachable Spoolman keeps them and the Stats page says how many
- Each Print History run moves by the change of its own records; an
  archive's only priced run follows the archive's new cost, so the
  statistics, project totals and order sums follow too
- Records are matched to the run whose time window holds them, and
  move no run when none does
maziggy 1 deň pred
rodič
commit
4f70d3da69
71 zmenil súbory, kde vykonal 899 pridanie a 15 odobranie
  1. 1 0
      CHANGELOG.md
  2. 24 5
      backend/app/api/routes/archives.py
  3. 8 2
      backend/app/services/print_log.py
  4. 228 0
      backend/app/services/usage_repricing.py
  5. 2 1
      backend/tests/integration/test_cost_statistics.py
  6. 579 0
      backend/tests/integration/test_recalculate_reprices_usage_3414.py
  7. 13 3
      backend/tests/integration/test_spoolman_usage_history_3407.py
  8. 16 0
      frontend/src/__tests__/pages/StatsPage.test.tsx
  9. 7 1
      frontend/src/api/client.ts
  10. 1 0
      frontend/src/i18n/locales/de.ts
  11. 1 0
      frontend/src/i18n/locales/en.ts
  12. 1 0
      frontend/src/i18n/locales/es.ts
  13. 1 0
      frontend/src/i18n/locales/fr.ts
  14. 1 0
      frontend/src/i18n/locales/it.ts
  15. 1 0
      frontend/src/i18n/locales/ja.ts
  16. 1 0
      frontend/src/i18n/locales/ko.ts
  17. 1 0
      frontend/src/i18n/locales/nl.ts
  18. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  19. 1 0
      frontend/src/i18n/locales/ru.ts
  20. 1 0
      frontend/src/i18n/locales/sv.ts
  21. 1 0
      frontend/src/i18n/locales/tr.ts
  22. 1 0
      frontend/src/i18n/locales/uk.ts
  23. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  24. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  25. 3 0
      frontend/src/pages/StatsPage.tsx
  26. 0 0
      static/assets/ArchivesPage-2npO88-s.js
  27. 0 0
      static/assets/CamWallPage-tZmJqBIY.js
  28. 0 0
      static/assets/CameraPage-zEszBP25.js
  29. 0 0
      static/assets/FileManagerPage-D383wkvW.js
  30. 0 0
      static/assets/InventoryPage-CTFR1_31.js
  31. 0 0
      static/assets/Layout-DZWcUUyM.js
  32. 0 0
      static/assets/ModelSourcesPage-B56xwQqg.js
  33. 0 0
      static/assets/PhotoGalleryModal-CxGjpoqh.js
  34. 0 0
      static/assets/PrintersPage-O8zbUGgp.js
  35. 0 0
      static/assets/SettingsPage-uD9I8lnV.js
  36. 0 0
      static/assets/SliceModal-D0DaOCZ-.js
  37. 0 0
      static/assets/SpoolBuddyInventoryPage-C11sTVRn.js
  38. 1 1
      static/assets/SpoolBuddySettingsPage-BCLEfCpM.js
  39. 0 0
      static/assets/StatsPage-BUMNjWin.js
  40. 0 0
      static/assets/de-BE0dNP4L.js
  41. 0 0
      static/assets/de-CWnVm6r9.js
  42. 0 0
      static/assets/es-DMnczCcr.js
  43. 0 0
      static/assets/es-WU3VfXAL.js
  44. 0 0
      static/assets/fr-L9oBcBjY.js
  45. 0 0
      static/assets/fr-U6FsCOQY.js
  46. 0 0
      static/assets/index-BDpZEvTD.js
  47. 0 0
      static/assets/it-BjhTlGrJ.js
  48. 0 0
      static/assets/it-CFDHN5Lq.js
  49. 0 0
      static/assets/ja-Bik2Ai3Y.js
  50. 0 0
      static/assets/ja-DwQrUNvd.js
  51. 0 0
      static/assets/ko-BC7OK_AY.js
  52. 0 0
      static/assets/ko-D0NfRe_s.js
  53. 0 0
      static/assets/locationSensorDefaults-8-iY3owe.js
  54. 0 0
      static/assets/nl-CMjFCcSb.js
  55. 0 0
      static/assets/nl-CQZtmdPA.js
  56. 0 0
      static/assets/pt-BR-BRKnKJPd.js
  57. 0 0
      static/assets/pt-BR-CABo8gd4.js
  58. 0 0
      static/assets/ru-C5QY5R46.js
  59. 0 0
      static/assets/ru-fY_z58hB.js
  60. 0 0
      static/assets/spoolCost-C8An9hXJ.js
  61. 0 0
      static/assets/sv-BHvCgx6C.js
  62. 0 0
      static/assets/sv-DcFzs_jk.js
  63. 0 0
      static/assets/tr-CwoEERG3.js
  64. 0 0
      static/assets/tr-rUpnHIan.js
  65. 0 0
      static/assets/uk-BQ6e5rYb.js
  66. 0 0
      static/assets/uk-CohRV3Xm.js
  67. 0 0
      static/assets/zh-CN-BT5WDwoe.js
  68. 0 0
      static/assets/zh-CN-DYSAzyYd.js
  69. 0 0
      static/assets/zh-TW-Bte8cvWl.js
  70. 0 0
      static/assets/zh-TW-pROPnzc9.js
  71. 2 2
      static/index.html

+ 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
+- **Recalculate Costs uses the spools' current prices (#3414, reported by @BurgerKerman)** — Changing a spool's price and pressing **Recalculate Costs** changed nothing ("0 archives were updated"), whether or not the spool had its own price. Each usage record keeps what its grams cost when the print completed, and the recalculation only added those up again, so it read the old price back. The usage records of spools still in the inventory now get the spool's current price first, or the current default filament cost for a spool without one, and the archive cost is rebuilt from them. Each print in the Print Log moves by the change of its own records, or takes the archive's new cost when it is the archive's only priced print, so the statistics, project totals and order sums follow the new price as well. A reprint off a spool whose price did not change keeps its cost. Records of a deleted spool keep what they cost. In Spoolman mode, charges recorded since #3407 are repriced from the spool's current price in Spoolman. If Spoolman can't be reached they keep their price, and the Statistics page says how many. Wiki updated.
 - **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.

+ 24 - 5
backend/app/api/routes/archives.py

@@ -2191,11 +2191,14 @@ async def recalculate_all_costs(
 ):
     """Recalculate costs for all archives based on filament usage and prices."""
     from backend.app.services.print_log import mirror_archive_cost
+    from backend.app.services.usage_repricing import reprice_usage
 
     # Only the archives of the caller's printers, as the archive list shows them.
     query = select(PrintArchive)
+    scoped_ids = None
     if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
         query = query.where(clause)
+        scoped_ids = select(PrintArchive.id).where(clause)
     result = await db.execute(query)
     archives = list(result.scalars().all())
 
@@ -2204,6 +2207,15 @@ async def recalculate_all_costs(
     filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
 
     default_cost_per_kg = await _default_cost_per_kg(db)
+    spoolman_owns = await _spoolman_owns_cost(db)
+
+    # The usage first, at the spools' current prices: the sums below read it,
+    # and a price corrected since the print changed nothing otherwise (#3414).
+    # A caller limited to some printers leaves the usage of no archive alone.
+    repriced = await reprice_usage(
+        db, scoped_ids, default_cost_per_kg=default_cost_per_kg, spoolman_enabled=spoolman_owns
+    )
+    await db.flush()
 
     # Pre-fetch all usage costs and tracked weight by archive_id.
     # Tracked weight is used to top-up the cost at the default rate for any
@@ -2233,8 +2245,6 @@ async def recalculate_all_costs(
         ).all()
     }
 
-    spoolman_owns = await _spoolman_owns_cost(db)
-
     updated = 0
     preserved = 0
     for archive in archives:
@@ -2272,15 +2282,24 @@ async def recalculate_all_costs(
         if new_cost is not None and archive.cost != new_cost:
             previous_cost = archive.cost
             archive.cost = new_cost
-            # Its run had the old figure: statistics read the run's cost.
-            await mirror_archive_cost(db, archive, previous_cost)
+            # Its run had the old figure: statistics read the run's cost. A
+            # run its own usage priced keeps that, repriced above.
+            await mirror_archive_cost(db, archive, previous_cost, skip_runs=repriced.priced_runs)
             updated += 1
 
     await db.commit()
     message = f"Recalculated costs for {updated} archives"
     if preserved:
         message += f"; kept {preserved} priced from Spoolman"
-    return {"message": message, "updated": updated, "preserved": preserved}
+    if repriced.spoolman_unreachable:
+        message += f"; Spoolman not reachable, {repriced.spoolman_unreachable} usage records kept their price"
+    return {
+        "message": message,
+        "updated": updated,
+        "preserved": preserved,
+        "usage_repriced": repriced.repriced,
+        "spoolman_unreachable": repriced.spoolman_unreachable,
+    }
 
 
 @router.get("/{archive_id}/duplicates")

+ 8 - 2
backend/app/services/print_log.py

@@ -4,6 +4,7 @@ Log entries are written to a separate table and never touch archives or queue it
 """
 
 import logging
+from collections.abc import Set as AbstractSet
 from datetime import datetime, timezone
 
 from sqlalchemy import func, or_, select
@@ -117,18 +118,23 @@ async def write_log_entry(
     return entry
 
 
-async def mirror_archive_cost(db: AsyncSession, archive, previous_cost: float | None) -> None:
+async def mirror_archive_cost(
+    db: AsyncSession, archive, previous_cost: float | None, *, skip_runs: AbstractSet[int] = frozenset()
+) -> None:
     """Give the archive's newest run the archive's new cost, if it had the old one.
 
     Statistics, project totals and batch sums read a run's cost. A completed
     run without its own spool price took the archive's (or none), so a new
     archive cost belongs to it too. A run its spools priced keeps that price,
     and a failed or cancelled run keeps its own: it printed only part of it.
+    A run in ``skip_runs`` was given its own new cost already.
     """
     entry = await db.scalar(
         select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
     )
-    if entry is not None and entry.status == "completed" and entry.cost in (None, previous_cost):
+    if entry is None or entry.id in skip_runs:
+        return
+    if entry.status == "completed" and entry.cost in (None, previous_cost):
         entry.cost = archive.cost
 
 

+ 228 - 0
backend/app/services/usage_repricing.py

@@ -0,0 +1,228 @@
+"""Reprice recorded spool usage at the spools' current prices (#3414).
+
+A usage row stores what its grams cost when the print completed. Recalculate
+Costs adds those up, so a spool price corrected afterwards changed nothing:
+the old price was read back from the rows. This puts the spool's current
+price on its rows first, and moves the cost of the run each row was charged
+in by the same difference, as statistics read a run's own cost.
+
+A row whose spool is gone keeps what it cost: there is no price left to apply.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections import defaultdict
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+
+from sqlalchemy import Select, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+logger = logging.getLogger(__name__)
+
+# A row is recorded as its run completes. Its run is the one whose start and
+# end (plus this much for the completion to be processed) hold the row's
+# time; a row that falls in none -- times that were stored in local time on
+# older installs -- moves no run rather than the wrong one.
+_COMPLETION_SLACK = timedelta(minutes=30)
+
+
+@dataclass
+class RepriceResult:
+    repriced: int = 0
+    # Spoolman rows left as they were because Spoolman could not be read.
+    spoolman_unreachable: int = 0
+    # Runs whose cost their own usage set: the archive's new cost is not
+    # theirs to take (``mirror_archive_cost``).
+    priced_runs: set[int] = field(default_factory=set)
+
+
+async def _spoolman_client(db: AsyncSession):
+    """The Spoolman client, as usage reporting gets it, off this session's
+    settings; None when it can't be reached."""
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
+
+    client = await get_spoolman_client()
+    if client is None:
+        url = await get_setting(db, "spoolman_url")
+        if not url:
+            return None
+        try:
+            client = await init_spoolman_client(url)
+        except ValueError as exc:
+            logger.warning("[COST] Spoolman URL rejected: %s", exc)
+            return None
+    if client is None or not await client.health_check():
+        return None
+    return client
+
+
+async def _spoolman_rates(db: AsyncSession, spool_ids: set[int]) -> dict[int, float | None] | None:
+    """Per-gram price of each Spoolman spool still in Spoolman (None for an
+    unpriced one); None when Spoolman can't be read."""
+    from backend.app.services.spoolman_tracking import _spool_cost_per_gram
+
+    client = await _spoolman_client(db)
+    if client is None:
+        return None
+    try:
+        spools = await client.get_all_spools(allow_archived=True)
+    except Exception as exc:  # noqa: BLE001 -- the rows are kept, the recalculation goes on
+        logger.warning("[COST] Spoolman spools could not be read to reprice their usage: %s", exc)
+        return None
+    return {
+        spool["id"]: _spool_cost_per_gram(spool)
+        for spool in spools
+        if isinstance(spool, dict) and spool.get("id") in spool_ids
+    }
+
+
+def _run_of(row: SpoolUsageHistory, runs: list[PrintLogEntry]) -> PrintLogEntry | None:
+    """The run the row was charged in, of the ``runs`` of its archive (or of
+    no archive, the archive deleted): on its printer, and running when the
+    row was recorded at its end. A run without an end time holds the rows
+    until the next run on the printer starts."""
+    if row.created_at is None:
+        return None
+    if row.printer_id is None and row.archive_id is None:
+        return None
+    candidates = sorted(
+        (
+            run
+            for run in runs
+            if run.started_at is not None and (row.printer_id is None or run.printer_id == row.printer_id)
+        ),
+        key=lambda run: run.started_at or datetime.min,
+    )
+    match = None
+    for index, run in enumerate(candidates):
+        started = run.started_at
+        if started is None or started > row.created_at:
+            break
+        if run.completed_at is not None:
+            end = run.completed_at + _COMPLETION_SLACK
+        elif index + 1 < len(candidates):
+            end = candidates[index + 1].started_at
+        else:
+            end = None
+        match = run if end is None or row.created_at <= end else None
+    return match
+
+
+def _in_run(cost: float | None, grams: float, spoolman: bool, default_cost_per_kg: float) -> float:
+    """What a row's cost put into its run's cost. A Spoolman charge without a
+    price was covered at the default rate (``spoolman_run_cost``, or the
+    archive's figure); a built-in one without a price had none to add."""
+    if cost is not None:
+        return cost
+    return round(grams / 1000.0 * default_cost_per_kg, 2) if spoolman else 0.0
+
+
+async def reprice_usage(
+    db: AsyncSession,
+    archive_ids: Select | None,
+    *,
+    default_cost_per_kg: float,
+    spoolman_enabled: bool,
+) -> RepriceResult:
+    """Reprice the usage rows of the archives ``archive_ids`` selects (every
+    row, those of no archive too, when it is None) and the runs they were
+    charged in. Does not commit.
+
+    A run is moved by the change of its own rows. The one exception is an
+    archive whose rows all belong to its latest run, still at the archive's
+    cost: that run is the archive's figure and follows the archive's new cost
+    as a whole (``mirror_archive_cost``), default-rate grams included.
+    """
+    result = RepriceResult()
+    query = select(SpoolUsageHistory)
+    runs_query = select(PrintLogEntry)
+    if archive_ids is not None:
+        query = query.where(SpoolUsageHistory.archive_id.in_(archive_ids))
+        runs_query = runs_query.where(PrintLogEntry.archive_id.in_(archive_ids))
+    rows = list((await db.scalars(query)).all())
+    if not rows:
+        return result
+
+    spool_ids = {row.spool_id for row in rows if row.spool_id is not None}
+    spool_rates = dict(
+        (await db.execute(select(Spool.id, Spool.cost_per_kg).where(Spool.id.in_(spool_ids)))).tuples().all()
+    )
+    spoolman_ids = {row.spoolman_spool_id for row in rows if row.spoolman_spool_id is not None}
+    spoolman_rates = await _spoolman_rates(db, spoolman_ids) if spoolman_ids and spoolman_enabled else {}
+
+    runs_by_archive: dict[int | None, list[PrintLogEntry]] = defaultdict(list)
+    for run in (await db.scalars(runs_query)).all():
+        runs_by_archive[run.archive_id].append(run)
+    owner = {row.id: _run_of(row, runs_by_archive.get(row.archive_id, [])) for row in rows}
+
+    archive_ids_seen = {row.archive_id for row in rows if row.archive_id is not None}
+    archive_costs = dict(
+        (await db.execute(select(PrintArchive.id, PrintArchive.cost).where(PrintArchive.id.in_(archive_ids_seen))))
+        .tuples()
+        .all()
+    )
+    follows_archive: set[int] = set()
+    rows_by_archive: dict[int, list[SpoolUsageHistory]] = defaultdict(list)
+    for row in rows:
+        if row.archive_id is not None:
+            rows_by_archive[row.archive_id].append(row)
+    for archive_id, archive_rows in rows_by_archive.items():
+        runs = runs_by_archive.get(archive_id, [])
+        if not runs or archive_id not in archive_costs:
+            continue
+        latest = max(runs, key=lambda run: run.id)
+        if (
+            latest.status == "completed"
+            and latest.cost in (None, archive_costs[archive_id])
+            and all(owner[row.id] is latest for row in archive_rows)
+        ):
+            follows_archive.add(latest.id)
+
+    for row in rows:
+        grams = row.weight_used or 0.0
+        run = owner[row.id]
+        if run is not None and run.id not in follows_archive:
+            result.priced_runs.add(run.id)
+        if row.spool_id is not None:
+            if row.spool_id not in spool_rates:
+                continue  # the spool was deleted
+            cost_per_kg = spool_rates[row.spool_id]
+            if cost_per_kg is None:
+                cost_per_kg = default_cost_per_kg
+            # As the usage tracker prices it at completion.
+            new_cost = round(grams / 1000.0 * cost_per_kg, 2) if cost_per_kg > 0 else None
+        elif row.spoolman_spool_id is not None:
+            if spoolman_rates is None:
+                result.spoolman_unreachable += 1
+                continue
+            if row.spoolman_spool_id not in spoolman_rates:
+                continue  # Spoolman is off, or the spool is gone from it
+            rate = spoolman_rates[row.spoolman_spool_id]
+            # Unpriced, as completion records it: the archive covers those
+            # grams at the default rate.
+            new_cost = round(grams * rate, 2) if rate is not None else None
+        else:
+            continue  # the spool was deleted
+        if new_cost == row.cost:
+            continue
+        spoolman = row.spool_id is None
+        delta = _in_run(new_cost, grams, spoolman, default_cost_per_kg) - _in_run(
+            row.cost, grams, spoolman, default_cost_per_kg
+        )
+        row.cost = new_cost
+        result.repriced += 1
+        if run is not None and run.id not in follows_archive and delta:
+            run.cost = round(max(0.0, (run.cost or 0.0) + delta), 2)
+    if spoolman_rates is None and result.spoolman_unreachable:
+        logger.warning(
+            "[COST] Spoolman not reachable: %d usage record(s) kept their price", result.spoolman_unreachable
+        )
+    return result

+ 2 - 1
backend/tests/integration/test_cost_statistics.py

@@ -341,7 +341,8 @@ class TestCostCalculationScenarios:
             brand="BrandA",
             label_weight=1000,
             core_weight=250,
-            cost_per_kg=20.0,
+            # 20 g of it cost the 0.50 its usage records.
+            cost_per_kg=25.0,
         )
         spool_old = Spool(
             material="ABS",

+ 579 - 0
backend/tests/integration/test_recalculate_reprices_usage_3414.py

@@ -0,0 +1,579 @@
+"""Recalculate Costs prices recorded usage at the spools' current prices (#3414).
+
+A usage row stores what its grams cost at completion, and Recalculate added
+those up, so a spool price corrected afterwards changed nothing: "0 archives
+were updated". The rows now take the spool's current price first, and the run
+each row was charged in moves by the same difference, as statistics read it.
+"""
+
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+START = datetime(2026, 10, 28, 10, 0, 0)
+
+
+async def _spool(db, cost_per_kg):
+    spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=0, cost_per_kg=cost_per_kg)
+    db.add(spool)
+    await db.commit()
+    await db.refresh(spool)
+    return spool
+
+
+async def _print(archive_factory, printer, *, grams, cost, started=START):
+    return await archive_factory(
+        printer.id,
+        status="completed",
+        cost=cost,
+        filament_used_grams=grams,
+        started_at=started,
+        completed_at=started + timedelta(hours=1),
+    )
+
+
+def _usage(archive, printer, *, grams, cost, spool_id=None, spoolman_spool_id=None, at=START, status="completed"):
+    return SpoolUsageHistory(
+        spool_id=spool_id,
+        spoolman_spool_id=spoolman_spool_id,
+        printer_id=printer.id,
+        archive_id=archive.id if archive is not None else None,
+        weight_used=grams,
+        percent_used=5,
+        status=status,
+        cost=cost,
+        created_at=at + timedelta(hours=1, seconds=5),
+    )
+
+
+async def _recalculate(client: AsyncClient, **kwargs):
+    response = await client.post("/api/v1/archives/recalculate-costs", **kwargs)
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _fresh(db, model, row_id):
+    return (
+        await db.execute(select(model).where(model.id == row_id).execution_options(populate_existing=True))
+    ).scalar_one()
+
+
+async def _runs(db, archive_id):
+    query = select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).order_by(PrintLogEntry.id)
+    return list((await db.execute(query.execution_options(populate_existing=True))).scalars().all())
+
+
+async def test_a_spools_new_price_reaches_its_usage_the_archive_and_the_run(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """The reported case: a print off a priced spool, the spool's price raised
+    tenfold afterwards."""
+    db_session.add(Settings(key="default_filament_cost", value="1400"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 1400.0)
+    archive = await _print(archive_factory, printer, grams=45.6, cost=63.84)
+    usage = _usage(archive, printer, grams=45.6, cost=63.84, spool_id=spool.id)
+    db_session.add(usage)
+    spool.cost_per_kg = 14000.0
+    await db_session.commit()
+
+    body = await _recalculate(async_client)
+
+    assert body["updated"] == 1
+    assert body["usage_repriced"] == 1
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 638.4
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 638.4
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [638.4]
+
+
+async def test_an_unchanged_price_changes_nothing_and_a_second_run_neither(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="default_filament_cost", value="1400"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 1400.0)
+    archive = await _print(archive_factory, printer, grams=45.6, cost=63.84)
+    usage = _usage(archive, printer, grams=45.6, cost=63.84, spool_id=spool.id)
+    db_session.add(usage)
+    await db_session.commit()
+
+    first = await _recalculate(async_client)
+    second = await _recalculate(async_client)
+
+    assert (first["usage_repriced"], first["updated"]) == (0, 0)
+    assert (second["usage_repriced"], second["updated"]) == (0, 0)
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [63.84]
+
+
+async def test_a_small_prints_price_change_of_a_cent_is_taken(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    archive = await _print(archive_factory, printer, grams=5.0, cost=0.1)
+    usage = _usage(archive, printer, grams=5.0, cost=0.1, spool_id=spool.id)
+    db_session.add(usage)
+    spool.cost_per_kg = 22.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 0.11
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [0.11]
+
+
+async def test_a_reprint_off_an_unchanged_spool_keeps_its_cost(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """The archive's new cost averages both prints; the reprint's own spool
+    priced it and did not change."""
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool_a = await _spool(db_session, 20.0)
+    spool_b = await _spool(db_session, 20.0)
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    later = START + timedelta(days=1)
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="completed",
+            started_at=later,
+            completed_at=later + timedelta(hours=1),
+            filament_used_grams=100.0,
+            cost=2.0,
+        )
+    )
+    db_session.add(_usage(archive, printer, grams=100.0, cost=2.0, spool_id=spool_a.id))
+    db_session.add(_usage(archive, printer, grams=100.0, cost=2.0, spool_id=spool_b.id, at=later))
+    spool_a.cost_per_kg = 40.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [4.0, 2.0]
+
+
+async def test_a_spoolman_print_takes_a_new_default_and_spool_price_together(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """80 g off the spool, 20 g unpriced at the default: completion gave the
+    run the archive's whole figure, and it follows the archive's new one."""
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="default_filament_cost", value="50"))
+    printer = await printer_factory()
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.1)
+    usage = _usage(archive, printer, grams=80.0, cost=1.6, spoolman_spool_id=41)
+    db_session.add(usage)
+    await db_session.commit()
+
+    with _spoolman([{"id": 41, "price": 30.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
+        await _recalculate(async_client)
+
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.4
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [3.4]
+
+
+async def test_spoolman_spool_given_a_price_reaches_every_run(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """Unpriced, completion covered each run's grams at the default rate."""
+    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 _print(archive_factory, printer, grams=100.0, cost=2.5)
+    later = START + timedelta(days=1)
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="failed",
+            started_at=later,
+            completed_at=later + timedelta(hours=1),
+            cost=1.0,
+        )
+    )
+    db_session.add(_usage(archive, printer, grams=100.0, cost=None, spoolman_spool_id=41))
+    db_session.add(_usage(archive, printer, grams=40.0, cost=None, spoolman_spool_id=41, at=later, status="failed"))
+    await db_session.commit()
+
+    with _spoolman([{"id": 41, "price": 50.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
+        await _recalculate(async_client)
+
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [5.0, 2.0]
+
+
+async def test_usage_recorded_outside_any_run_moves_none(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """A row stamped two hours late (local time on an older install) must not
+    move the reprint that started after the print it belongs to."""
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    later = START + timedelta(hours=1, minutes=10)
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="completed",
+            started_at=later,
+            completed_at=later + timedelta(minutes=30),
+            cost=1.9,
+        )
+    )
+    usage = _usage(archive, printer, grams=100.0, cost=2.0, spool_id=spool.id)
+    usage.created_at = START + timedelta(hours=3)
+    db_session.add(usage)
+    spool.cost_per_kg = 40.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0, 1.9]
+
+
+async def test_a_run_without_an_end_holds_usage_until_the_next_run(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    (first,) = await _runs(db_session, archive.id)
+    first.completed_at = None
+    first.cost = 1.0
+    later = START + timedelta(hours=2)
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="completed",
+            started_at=later,
+            completed_at=later + timedelta(hours=1),
+            cost=1.9,
+        )
+    )
+    db_session.add(_usage(archive, printer, grams=50.0, cost=1.0, spool_id=spool.id))
+    spool.cost_per_kg = 40.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0, 1.9]
+
+
+async def test_the_run_of_a_deleted_archive_follows_its_usage(async_client: AsyncClient, db_session, printer_factory):
+    """A printer deleted with its archives leaves both without an archive."""
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    run = PrintLogEntry(
+        printer_id=printer.id, status="completed", started_at=START, completed_at=START + timedelta(hours=1), cost=2.0
+    )
+    db_session.add(run)
+    db_session.add(_usage(None, printer, grams=100.0, cost=2.0, spool_id=spool.id))
+    spool.cost_per_kg = 40.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, PrintLogEntry, run.id)).cost == 4.0
+
+
+async def test_a_spool_without_a_price_follows_the_default_cost(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="default_filament_cost", value="50"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, None)
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.5)
+    usage = _usage(archive, printer, grams=100.0, cost=2.5, spool_id=spool.id)
+    db_session.add(usage)
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 5.0
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 5.0
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [5.0]
+
+
+async def test_a_deleted_spools_usage_keeps_what_it_cost(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="default_filament_cost", value="50"))
+    printer = await printer_factory()
+    archive = await _print(archive_factory, printer, grams=100.0, cost=3.0)
+    usage = _usage(archive, printer, grams=100.0, cost=3.0)
+    db_session.add(usage)
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 3.0
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
+
+
+async def test_a_deleted_spool_still_named_by_its_usage_keeps_what_it_cost(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """Usage left pointing at a spool id that no longer exists, which only
+    SQLite allows: PostgreSQL's foreign key clears it with the spool."""
+    if db_session.bind.dialect.name != "sqlite":
+        pytest.skip("PostgreSQL refuses a usage row of a missing spool")
+    db_session.add(Settings(key="default_filament_cost", value="50"))
+    printer = await printer_factory()
+    archive = await _print(archive_factory, printer, grams=100.0, cost=3.0)
+    usage = _usage(archive, printer, grams=100.0, cost=3.0, spool_id=987654)
+    db_session.add(usage)
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 3.0
+
+
+async def test_a_run_its_usage_moved_is_not_given_the_archives_cost(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """The run had only its spool's 50 g (1.00), the archive 50 g more at the
+    default (2.00). Repriced, the run is 2.00 -- the archive's old figure, which
+    must not hand it the archive's new 3.00."""
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    (run,) = await _runs(db_session, archive.id)
+    run.cost = 1.0
+    db_session.add(_usage(archive, printer, grams=50.0, cost=1.0, spool_id=spool.id))
+    spool.cost_per_kg = 40.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0]
+
+
+async def test_each_run_moves_by_its_own_usage(async_client: AsyncClient, db_session, printer_factory, archive_factory):
+    """A reprint that failed halfway: each run gets its own row's difference,
+    the default-rate grams of the full run stay as they were."""
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    # 100 g: 80 g off the spool at 20/kg (1.60) + 20 g at the default (0.40).
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    later = START + timedelta(days=2)
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="failed",
+            started_at=later,
+            completed_at=later + timedelta(hours=1),
+            cost=0.8,
+        )
+    )
+    first = _usage(archive, printer, grams=80.0, cost=1.6, spool_id=spool.id)
+    second = _usage(archive, printer, grams=40.0, cost=0.8, spool_id=spool.id, at=later, status="failed")
+    db_session.add_all([first, second])
+    spool.cost_per_kg = 40.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [3.6, 1.6]
+    assert (await _fresh(db_session, SpoolUsageHistory, first.id)).cost == 3.2
+    assert (await _fresh(db_session, SpoolUsageHistory, second.id)).cost == 1.6
+
+
+async def test_a_run_on_another_printer_is_not_charged_this_ones_usage(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    here = await printer_factory(name="Here")
+    there = await printer_factory(name="There")
+    spool = await _spool(db_session, 20.0)
+    archive = await _print(archive_factory, here, grams=100.0, cost=2.0)
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=there.id,
+            status="completed",
+            # Started after this printer's run, before its usage was recorded.
+            started_at=START + timedelta(minutes=1),
+            cost=1.9,
+        )
+    )
+    db_session.add(_usage(archive, here, grams=100.0, cost=2.0, spool_id=spool.id))
+    spool.cost_per_kg = 30.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert sorted((run.printer_id, run.cost) for run in await _runs(db_session, archive.id)) == sorted(
+        [(here.id, 3.0), (there.id, 1.9)]
+    )
+
+
+def _spoolman(spools):
+    client = MagicMock()
+    client.get_all_spools = AsyncMock(return_value=spools)
+    return patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=client))
+
+
+async def test_a_spoolman_spools_new_price_reaches_its_usage(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """Feature parity: a Spoolman charge is repriced from the spool's price in
+    Spoolman, as a built-in one from the inventory's."""
+    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 _print(archive_factory, printer, grams=100.0, cost=2.0)
+    usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
+    db_session.add(usage)
+    await db_session.commit()
+
+    with _spoolman([{"id": 41, "price": 30.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
+        body = await _recalculate(async_client)
+
+    assert body["usage_repriced"] == 1
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 3.0
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [3.0]
+
+
+async def test_unreachable_spoolman_keeps_its_usage_and_says_so(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    printer = await printer_factory()
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
+    db_session.add(usage)
+    await db_session.commit()
+
+    with patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=None)):
+        body = await _recalculate(async_client)
+
+    assert body["spoolman_unreachable"] == 1
+    assert "Spoolman not reachable" in body["message"]
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 2.0
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 2.0
+
+
+async def test_a_spool_gone_from_spoolman_keeps_its_usage(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    printer = await printer_factory()
+    archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
+    usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
+    db_session.add(usage)
+    await db_session.commit()
+
+    with _spoolman([{"id": 7, "price": 30.0, "initial_weight": 1000}]):
+        body = await _recalculate(async_client)
+
+    assert body["spoolman_unreachable"] == 0
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 2.0
+
+
+async def test_a_caller_limited_to_some_printers_reprices_only_their_usage(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    from backend.tests.integration.test_bulk_archive_routes_scope import _member
+
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    spool = await _spool(db_session, 20.0)
+    on_a = await _print(archive_factory, a, grams=100.0, cost=2.0)
+    on_b = await _print(archive_factory, b, grams=100.0, cost=2.0)
+    usage_a = _usage(on_a, a, grams=100.0, cost=2.0, spool_id=spool.id)
+    usage_b = _usage(on_b, b, grams=100.0, cost=2.0, spool_id=spool.id)
+    unlinked = _usage(None, b, grams=100.0, cost=2.0, spool_id=spool.id)
+    db_session.add_all([usage_a, usage_b, unlinked])
+    spool.cost_per_kg = 30.0
+    await db_session.commit()
+    member = await _member(async_client, [a.id])
+
+    await _recalculate(async_client, headers=member)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage_a.id)).cost == 3.0
+    assert (await _fresh(db_session, SpoolUsageHistory, usage_b.id)).cost == 2.0
+    assert (await _fresh(db_session, SpoolUsageHistory, unlinked.id)).cost == 2.0
+
+
+async def test_usage_of_no_archive_is_repriced_for_an_unlimited_caller(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    """It shows on the spool's usage list."""
+    db_session.add(Settings(key="default_filament_cost", value="20"))
+    printer = await printer_factory()
+    spool = await _spool(db_session, 20.0)
+    unlinked = _usage(None, printer, grams=100.0, cost=2.0, spool_id=spool.id)
+    db_session.add(unlinked)
+    spool.cost_per_kg = 30.0
+    await db_session.commit()
+
+    await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, unlinked.id)).cost == 3.0
+
+
+async def test_a_spoolman_spool_given_a_price_prices_its_print(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """Unpriced at completion, the print took the archive's estimate; with the
+    spool priced now, the run follows the archive's new cost."""
+    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 _print(archive_factory, printer, grams=100.0, cost=3.0)
+    usage = _usage(archive, printer, grams=100.0, cost=None, spoolman_spool_id=41)
+    db_session.add(usage)
+    await db_session.commit()
+
+    with _spoolman([{"id": 41, "price": 40.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
+        await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 4.0
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 4.0
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [4.0]
+
+
+async def test_a_spoolman_spool_losing_its_price_keeps_its_print_and_run_together(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    """The archive keeps a Spoolman-priced cost it can't rebuild (#2591); its
+    run must not be moved away from it."""
+    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 _print(archive_factory, printer, grams=100.0, cost=2.0)
+    usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
+    db_session.add(usage)
+    await db_session.commit()
+
+    with _spoolman([{"id": 41, "initial_weight": 1000, "filament": {"weight": 1000}}]):
+        await _recalculate(async_client)
+
+    assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost is None
+    assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 2.0
+    assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0]

+ 13 - 3
backend/tests/integration/test_spoolman_usage_history_3407.py

@@ -43,6 +43,13 @@ async def _built_in_spool(db) -> Spool:
     return spool
 
 
+def _spoolman_with(*spools):
+    """Spoolman as Recalculate Costs reads its prices (#3414)."""
+    client = MagicMock()
+    client.get_all_spools = AsyncMock(return_value=list(spools))
+    return patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=client))
+
+
 async def _rows(db) -> list[SpoolUsageHistory]:
     return list((await db.execute(select(SpoolUsageHistory).execution_options(populate_existing=True))).scalars().all())
 
@@ -102,7 +109,8 @@ async def test_recalculating_keeps_the_cost_of_a_print_from_unpriced_spools(
     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
+    with _spoolman_with({"id": 41, "initial_weight": 1000, "filament": {"weight": 1000}}):
+        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
 
@@ -183,7 +191,8 @@ async def test_the_stock_alerts_use_the_spoolman_rows_in_spoolman_mode(db_sessio
 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."""
+    """The rows carry what the charges were priced at; at the spool's price
+    unchanged, 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()
@@ -192,7 +201,8 @@ async def test_recalculating_costs_rebuilds_a_spoolman_archive_from_its_rows(
     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
+    with _spoolman_with({"id": 41, "price": 40.0, "initial_weight": 1000, "filament": {"weight": 1000}}):
+        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
 

+ 16 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -576,6 +576,22 @@ describe('StatsPage', () => {
         expect(screen.getByText('Recalculate Costs')).toBeInTheDocument();
       });
     });
+
+    it('says when Spoolman could not be reached for its prices (#3414)', async () => {
+      server.use(
+        http.post('/api/v1/archives/recalculate-costs', () =>
+          HttpResponse.json({ message: '', updated: 2, preserved: 0, usage_repriced: 3, spoolman_unreachable: 4 })
+        )
+      );
+      render(<StatsPage />);
+      const button = await screen.findByText('Recalculate Costs');
+
+      button.click();
+
+      expect(
+        await screen.findByText('Spoolman could not be reached: 4 usage records of its spools kept their old price')
+      ).toBeInTheDocument();
+    });
   });
 
   describe('user filter', () => {

+ 7 - 1
frontend/src/api/client.ts

@@ -5766,7 +5766,13 @@ export const api = {
       method: 'DELETE',
     }),
   recalculateCosts: () =>
-    request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
+    request<{
+      message: string;
+      updated: number;
+      preserved: number;
+      usage_repriced: number;
+      spoolman_unreachable: number;
+    }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number; createdById?: number }) => {
     const params = new URLSearchParams();
     if (options?.days) params.set('days', String(options.days));

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

@@ -1840,6 +1840,7 @@ export default {
     exportFailed: 'Export fehlgeschlagen',
     layoutReset: 'Layout zurückgesetzt',
     recalculatedCosts: 'Kosten für {{count}} Archive neu berechnet',
+    recalculateSpoolmanUnreachable: 'Spoolman war nicht erreichbar: {{count}} Verbrauchseinträge seiner Spulen behalten ihren alten Preis',
     recalculateFailed: 'Kosten konnten nicht neu berechnet werden',
     // Loading
     loadingStats: 'Statistiken werden geladen...',

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

@@ -1859,6 +1859,7 @@ export default {
     exportFailed: 'Export failed',
     layoutReset: 'Layout reset',
     recalculatedCosts: 'Recalculated costs for {{count}} archives',
+    recalculateSpoolmanUnreachable: 'Spoolman could not be reached: {{count}} usage records of its spools kept their old price',
     recalculateFailed: 'Failed to recalculate costs',
     // Loading
     loadingStats: 'Loading statistics...',

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

@@ -1840,6 +1840,7 @@ export default {
     exportFailed: 'Error en la exportación',
     layoutReset: 'Disposición restablecida',
     recalculatedCosts: 'Costes recalculados para {{count}} archivos',
+    recalculateSpoolmanUnreachable: 'No se pudo conectar con Spoolman: {{count}} registros de consumo de sus bobinas conservan su precio anterior',
     recalculateFailed: 'Error al recalcular los costes',
     // Loading
     loadingStats: 'Cargando estadísticas...',

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

@@ -1839,6 +1839,7 @@ export default {
     exportFailed: 'Échec export',
     layoutReset: 'Mise en page réinitialisée',
     recalculatedCosts: 'Coûts recalculés pour {{count}} archives',
+    recalculateSpoolmanUnreachable: 'Spoolman injoignable : {{count}} enregistrements de consommation de ses bobines gardent leur ancien prix',
     recalculateFailed: 'Échec du calcul',
     // Loading
     loadingStats: 'Chargement des statistiques...',

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

@@ -1839,6 +1839,7 @@ export default {
     exportFailed: 'Export non riuscito',
     layoutReset: 'Layout reimpostato',
     recalculatedCosts: 'Costi ricalcolati per {{count}} archivi',
+    recalculateSpoolmanUnreachable: 'Spoolman non raggiungibile: {{count}} registrazioni di consumo delle sue bobine mantengono il prezzo precedente',
     recalculateFailed: 'Ricalcolo costi non riuscito',
     // Loading
     loadingStats: 'Caricamento statistiche...',

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

@@ -1839,6 +1839,7 @@ export default {
     exportFailed: 'エクスポートに失敗しました',
     layoutReset: 'レイアウトをリセットしました',
     recalculatedCosts: '{{count}}件のアーカイブのコストを再計算しました',
+    recalculateSpoolmanUnreachable: 'Spoolman に接続できませんでした:そのスプールの使用記録 {{count}} 件は以前の価格のままです',
     recalculateFailed: 'コストの再計算に失敗しました',
     // Loading
     loadingStats: '統計を読み込み中...',

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

@@ -1770,6 +1770,7 @@ export default {
     exportFailed: '내보내기 실패',
     layoutReset: '레이아웃이 초기화되었습니다',
     recalculatedCosts: '{{count}}개 아카이브의 비용이 재계산되었습니다',
+    recalculateSpoolmanUnreachable: 'Spoolman에 연결할 수 없습니다: 해당 스풀의 사용 기록 {{count}}개는 이전 가격을 유지합니다',
     recalculateFailed: '비용 재계산 실패',
     loadingStats: '통계 로딩 중...',
     noPermissionResetLayout: '레이아웃을 초기화할 권한이 없습니다',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -1857,6 +1857,7 @@ export default {
     exportFailed: 'Export mislukt',
     layoutReset: 'Indeling gereset',
     recalculatedCosts: 'Kosten voor {{count}} archieven opnieuw berekend',
+    recalculateSpoolmanUnreachable: 'Spoolman was niet bereikbaar: {{count}} verbruiksregistraties van zijn spoelen houden hun oude prijs',
     recalculateFailed: 'Kosten opnieuw berekenen mislukt',
     // Loading
     loadingStats: 'Statistieken laden...',

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

@@ -1839,6 +1839,7 @@ export default {
     exportFailed: 'Falha na exportação',
     layoutReset: 'Layout redefinido',
     recalculatedCosts: 'Custos recalculados para {{count}} arquivos',
+    recalculateSpoolmanUnreachable: 'Não foi possível acessar o Spoolman: {{count}} registros de consumo dos seus carretéis mantiveram o preço anterior',
     recalculateFailed: 'Falha ao recalcular custos',
     // Loading
     loadingStats: 'Carregando estatísticas...',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -1767,6 +1767,7 @@ export default {
     exportFailed: "Не удалось экспортировать данные",
     layoutReset: "Расположение сброшено",
     recalculatedCosts: "Пересчитана стоимость архивов: {{count}}",
+    recalculateSpoolmanUnreachable: 'Spoolman недоступен: {{count}} записей расхода его катушек сохранили старую цену',
     recalculateFailed: "Не удалось пересчитать стоимость",
     loadingStats: "Загрузка статистики...",
     noPermissionResetLayout: "У вас нет разрешения сбрасывать расположение",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -1856,6 +1856,7 @@ export default {
     exportFailed: 'Kunde inte exportera',
     layoutReset: 'Layout återställd',
     recalculatedCosts: 'Omberäknade kostnader för {{count}} arkiv',
+    recalculateSpoolmanUnreachable: 'Spoolman kunde inte nås: {{count}} förbrukningsposter för dess spolar behöll sitt gamla pris',
     recalculateFailed: 'Kunde inte omberäkna kostnader',
     // Loading
     loadingStats: 'Laddar statistik...',

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

@@ -1840,6 +1840,7 @@ export default {
     exportFailed: 'Dışa aktarma başarısız',
     layoutReset: 'Düzen sıfırlandı',
     recalculatedCosts: '{{count}} arşiv için maliyetler yeniden hesaplandı',
+    recalculateSpoolmanUnreachable: "Spoolman'a ulaşılamadı: makaralarının {{count}} kullanım kaydı eski fiyatını korudu",
     recalculateFailed: 'Maliyetler yeniden hesaplanamadı',
     // Yükleniyor
     loadingStats: 'İstatistikler yükleniyor...',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -1855,6 +1855,7 @@ export default {
     exportFailed: "Помилка експорту",
     layoutReset: "Розташування віджетів скинуто",
     recalculatedCosts: "Перераховано вартість архівів: {{count}}",
+    recalculateSpoolmanUnreachable: 'Spoolman недоступний: {{count}} записів витрати його котушок зберегли стару ціну',
     recalculateFailed: "Не вдалося перерахувати витрати",
     // Loading
     loadingStats: "Завантаження статистики...",

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

@@ -1839,6 +1839,7 @@ export default {
     exportFailed: '导出失败',
     layoutReset: '布局已重置',
     recalculatedCosts: '已为 {{count}} 个归档重新计算成本',
+    recalculateSpoolmanUnreachable: '无法连接 Spoolman:其线轴的 {{count}} 条用量记录保留原价',
     recalculateFailed: '重新计算成本失败',
     // Loading
     loadingStats: '加载统计数据中...',

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

@@ -1839,6 +1839,7 @@ export default {
     exportFailed: '匯出失敗',
     layoutReset: '佈局已重設',
     recalculatedCosts: '已為 {{count}} 個歸檔重新計算成本',
+    recalculateSpoolmanUnreachable: '無法連線至 Spoolman:其線軸的 {{count}} 筆用量記錄保留原價',
     recalculateFailed: '重新計算成本失敗',
     // Loading
     loadingStats: '載入統計資料中...',

+ 3 - 0
frontend/src/pages/StatsPage.tsx

@@ -1108,6 +1108,9 @@ export function StatsPage() {
       const result = await api.recalculateCosts();
       await Promise.all([refetchStats(), refetchArchives()]);
       showToast(t('stats.recalculatedCosts', { count: result.updated }));
+      if (result.spoolman_unreachable) {
+        showToast(t('stats.recalculateSpoolmanUnreachable', { count: result.spoolman_unreachable }), 'warning');
+      }
     } catch {
       showToast(t('stats.recalculateFailed'), 'error');
     } finally {

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ArchivesPage-2npO88-s.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/CamWallPage-tZmJqBIY.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/CameraPage-zEszBP25.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/FileManagerPage-D383wkvW.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/InventoryPage-CTFR1_31.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/Layout-DZWcUUyM.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ModelSourcesPage-B56xwQqg.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/PhotoGalleryModal-CxGjpoqh.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/PrintersPage-O8zbUGgp.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/SettingsPage-uD9I8lnV.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/SliceModal-D0DaOCZ-.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-C11sTVRn.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BCLEfCpM.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/StatsPage-BUMNjWin.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/de-BE0dNP4L.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/de-CWnVm6r9.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/es-DMnczCcr.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/es-WU3VfXAL.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/fr-L9oBcBjY.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/fr-U6FsCOQY.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/index-BDpZEvTD.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/it-BjhTlGrJ.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/it-CFDHN5Lq.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ja-Bik2Ai3Y.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ja-DwQrUNvd.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ko-BC7OK_AY.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ko-D0NfRe_s.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/locationSensorDefaults-8-iY3owe.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/nl-CMjFCcSb.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/nl-CQZtmdPA.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/pt-BR-BRKnKJPd.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/pt-BR-CABo8gd4.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ru-C5QY5R46.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/ru-fY_z58hB.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/spoolCost-C8An9hXJ.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/sv-BHvCgx6C.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/sv-DcFzs_jk.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/tr-CwoEERG3.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/tr-rUpnHIan.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/uk-BQ6e5rYb.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/uk-CohRV3Xm.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/zh-CN-BT5WDwoe.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/zh-CN-DYSAzyYd.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/zh-TW-Bte8cvWl.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
static/assets/zh-TW-pROPnzc9.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-7C0I5acz.js"></script>
+    <script type="module" crossorigin src="/assets/index-BDpZEvTD.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-8XqSBxc-.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-DqnGEPFR.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-kwE5kmQs.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-BPSMaNFL.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CirSjZ36.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-VDJYUcZm.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-DZWcUUyM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-BhH7Qp2X.css">
   </head>
   <body>

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov