Bläddra i källkod

fix(archive): an archive's cost is the cost of one print of it

maziggy 6 dagar sedan
förälder
incheckning
95ea683b74

+ 0 - 16
.basedpyright/baseline.json

@@ -99,22 +99,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 31,
-                    "endColumn": 41,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 31,
-                    "endColumn": 41,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportOptionalMemberAccess",
                 "range": {

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ 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
+- **Archive costs** —
+  - **Recalculate Costs** and **Rescan** price one print of the archive. An archive printed three times was given the cost of all three. Usage recorded without a price is priced at the default filament cost instead of counting as free. Print History and the statistics still count every print with its own cost.
+  - Editing an archive's filament weight now reprices it, on the card and on the print it was copied to.
+  - A spool without a price is charged at the default filament cost when the print completes. With the default never saved, completion priced it at nothing while the Settings page showed 25 per kg, and Recalculate later used 25.
 - **A print of a file whose last print was never closed gets its own archive** — an archive left on "printing" because its end was missed used to take over the next print of the same file for two hours, so that print got no history entry, timelapse or cost estimate of its own. A different job id from the printer now marks it as a new print, except when it is Bambuddy's own reprint of that archive.
 - **Filament of a print that spans a Bambuddy restart is no longer counted twice** — when the restart found no saved print session, or one of another print, the AMS remaining-% sync raised the spool's used weight during the print, and completion then added the print's full usage on top.
 - **A print whose dispatch failed no longer leaves its cost centre behind** — the next print of the same archive without a cost centre of its own was charged to it, instead of the user or the archive's cost centre.

+ 101 - 56
backend/app/api/routes/archives.py

@@ -6,7 +6,6 @@ import re as _re
 import zipfile
 from collections import defaultdict
 from datetime import date, datetime, time, timedelta, timezone
-from decimal import ROUND_HALF_UP, Decimal
 from html import escape as html_escape
 from pathlib import Path
 
@@ -50,6 +49,7 @@ from backend.app.services.print_confirmation import (
     retire_confirm_token,
     stamp_verdict,
 )
+from backend.app.services.print_cost_estimate import default_cost_per_kg as _default_cost_per_kg, price_one_print
 from backend.app.services.print_storage import (
     REASON_FTP_TRANSFER_FAILED,
     REASON_FTPS_COOLOFF,
@@ -1952,6 +1952,16 @@ async def update_archive(
     for field, value in update_payload.items():
         setattr(archive, field, value)
 
+    # New grams are a new price, unless the caller set the cost as well.
+    repriced = False
+    if (
+        update_payload.get("filament_used_grams")
+        and "cost" not in update_payload
+        and update_payload["filament_used_grams"] != previous_filament_grams
+    ):
+        archive.cost = await _one_print_cost(db, archive, previous_grams=previous_filament_grams)
+        repriced = True
+
     # #1898: a landed verdict retires the one-tap capability token from the
     # push notification — the links stop changing anything once someone
     # decided, and report the recorded verdict instead. Clearing the verdict
@@ -2002,6 +2012,9 @@ async def update_archive(
                 latest_entry.filament_used_grams is None or latest_entry.filament_used_grams == previous_filament_grams
             ):
                 del to_mirror["filament_used_grams"]
+            # The run's cost was inherited with its grams, so it follows them.
+            if "filament_used_grams" in to_mirror and repriced:
+                to_mirror["cost"] = archive.cost
 
             for field, value in to_mirror.items():
                 setattr(latest_entry, field, value)
@@ -2065,6 +2078,62 @@ async def _spoolman_owns_cost(db: AsyncSession) -> bool:
     return bool(setting) and setting.lower() == "true"
 
 
+def _priced_usage_totals():
+    """Priced spool usage per archive. A row recorded without a price is left
+    out of both sums, so its grams are priced at the default rate instead of
+    counting as free filament."""
+    priced = SpoolUsageHistory.cost.is_not(None)
+    return (
+        func.sum(SpoolUsageHistory.cost),
+        func.sum(case((priced, SpoolUsageHistory.weight_used), else_=0.0)),
+    )
+
+
+async def _one_print_cost(db: AsyncSession, archive: PrintArchive, previous_grams: float | None = None) -> float | None:
+    """The archive's cost for one print of its grams (see ``price_one_print``).
+
+    A Spoolman-priced cost has no usage rows to rebuild it from (#2591); it is
+    kept, scaled to new grams when they were edited.
+    """
+    from backend.app.models.print_log import PrintLogEntry
+
+    grams = archive.filament_used_grams
+    default_cost = await _default_cost_per_kg(db)
+    usage_cost, tracked_grams = (
+        await db.execute(select(*_priced_usage_totals()).where(SpoolUsageHistory.archive_id == archive.id))
+    ).one()
+    if usage_cost is not None and usage_cost > 0:
+        printed_grams, runs = (
+            await db.execute(
+                select(func.sum(PrintLogEntry.filament_used_grams), func.count(PrintLogEntry.id)).where(
+                    PrintLogEntry.archive_id == archive.id
+                )
+            )
+        ).one()
+        return price_one_print(
+            grams,
+            float(usage_cost),
+            float(tracked_grams or 0),
+            float(printed_grams or 0),
+            int(runs or 0),
+            default_cost,
+            default_cost,
+        )
+    if not grams:
+        return archive.cost
+    if archive.cost is not None and await _spoolman_owns_cost(db):
+        if previous_grams:
+            return round(archive.cost * grams / previous_grams, 2)
+        return archive.cost
+    fallback = default_cost
+    if archive.filament_type:
+        primary_type = archive.filament_type.split(",")[0].strip()
+        catalog = await db.scalar(select(Filament.cost_per_kg).where(Filament.type == primary_type).limit(1))
+        if catalog is not None:
+            fallback = catalog
+    return price_one_print(grams, None, 0.0, 0.0, 0, fallback, default_cost)
+
+
 @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
 async def rescan_archive(
     archive_id: int,
@@ -2073,7 +2142,6 @@ async def rescan_archive(
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Rescan the 3MF file and update metadata."""
-    from backend.app.api.routes.settings import get_setting
     from backend.app.services.archive import ThreeMFParser
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2119,43 +2187,7 @@ async def rescan_archive(
     # so the displayed cost still reflects the whole print.
 
     if archive.filament_used_grams and archive.filament_type:
-        default_cost_setting = await get_setting(db, "default_filament_cost")
-        default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
-        usage_result = await db.execute(
-            select(
-                func.sum(SpoolUsageHistory.cost),
-                func.sum(SpoolUsageHistory.weight_used),
-            ).where(SpoolUsageHistory.archive_id == archive.id)
-        )
-        usage_cost_row = usage_result.one()
-        usage_cost = usage_cost_row[0]
-        tracked_grams = float(usage_cost_row[1] or 0)
-        if usage_cost is not None and usage_cost > 0:
-            total_cost = float(usage_cost)
-            untracked_grams = max(0.0, archive.filament_used_grams - tracked_grams)
-            if untracked_grams > 0 and default_cost_per_kg > 0:
-                total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
-            archive.cost = float(Decimal(str(total_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
-        elif await _spoolman_owns_cost(db) and archive.cost is not None:
-            # Keep what completion priced from the linked spools. A rescan
-            # re-reads the 3MF's metadata; it learns nothing about spools.
-            pass
-        else:
-            primary_type = archive.filament_type.split(",")[0].strip()
-            filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
-            filament = filament_result.scalar_one_or_none()
-            if filament:
-                archive.cost = float(
-                    Decimal(str((archive.filament_used_grams / 1000) * filament.cost_per_kg)).quantize(
-                        Decimal("0.01"), rounding=ROUND_HALF_UP
-                    )
-                )
-            else:
-                archive.cost = float(
-                    Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
-                        Decimal("0.01"), rounding=ROUND_HALF_UP
-                    )
-                )
+        archive.cost = await _one_print_cost(db, archive)
 
     await db.commit()
     await db.refresh(archive)
@@ -2170,8 +2202,6 @@ async def recalculate_all_costs(
 ):
     """Recalculate costs for all archives based on filament usage and prices."""
 
-    from backend.app.api.routes.settings import get_setting
-
     # Only the archives of the caller's printers, as the archive list shows them.
     query = select(PrintArchive)
     if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
@@ -2183,19 +2213,13 @@ async def recalculate_all_costs(
     filament_result = await db.execute(select(Filament))
     filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
 
-    # Get default filament cost from settings
-    default_cost_setting = await get_setting(db, "default_filament_cost")
-    default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
+    default_cost_per_kg = await _default_cost_per_kg(db)
 
     # 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
     # filament grams not covered by an inventory spool (#1344).
     usage_costs_result = await db.execute(
-        select(
-            SpoolUsageHistory.archive_id,
-            func.sum(SpoolUsageHistory.cost),
-            func.sum(SpoolUsageHistory.weight_used),
-        ).group_by(SpoolUsageHistory.archive_id)
+        select(SpoolUsageHistory.archive_id, *_priced_usage_totals()).group_by(SpoolUsageHistory.archive_id)
     )
     usage_costs = usage_costs_result.fetchall()
     cost_map = {
@@ -2204,6 +2228,21 @@ async def recalculate_all_costs(
         if row[0] is not None and row[1] is not None and row[1] > 0
     }
 
+    from backend.app.models.print_log import PrintLogEntry
+
+    run_totals = {
+        row[0]: (float(row[1] or 0), int(row[2] or 0))
+        for row in (
+            await db.execute(
+                select(
+                    PrintLogEntry.archive_id,
+                    func.sum(PrintLogEntry.filament_used_grams),
+                    func.count(PrintLogEntry.id),
+                ).group_by(PrintLogEntry.archive_id)
+            )
+        ).all()
+    }
+
     spoolman_owns = await _spoolman_owns_cost(db)
 
     updated = 0
@@ -2212,12 +2251,16 @@ async def recalculate_all_costs(
         usage = cost_map.get(archive.id)
         if usage is not None:
             usage_cost, tracked_grams = usage
-            total_cost = float(usage_cost)
-            archive_grams = float(archive.filament_used_grams or 0)
-            untracked_grams = max(0.0, archive_grams - tracked_grams)
-            if untracked_grams > 0 and default_cost_per_kg > 0:
-                total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
-            new_cost = round(total_cost, 2)
+            printed_grams, runs = run_totals.get(archive.id, (0.0, 0))
+            new_cost = price_one_print(
+                archive.filament_used_grams,
+                usage_cost,
+                tracked_grams,
+                printed_grams,
+                runs,
+                default_cost_per_kg,
+                default_cost_per_kg,
+            )
         else:
             # No usage linked to this archive. Usage without an archive link
             # (recorded before usage was linked, with archiving off, or of a
@@ -2231,7 +2274,9 @@ async def recalculate_all_costs(
             elif archive.filament_used_grams and archive.filament_type:
                 primary_type = archive.filament_type.split(",")[0].strip()
                 cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
-                new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
+                new_cost = price_one_print(
+                    archive.filament_used_grams, None, 0.0, 0.0, 0, cost_per_kg, default_cost_per_kg
+                )
             else:
                 new_cost = None
         if new_cost is not None and archive.cost != new_cost:

+ 40 - 4
backend/app/services/print_cost_estimate.py

@@ -2,6 +2,7 @@
 
 import json
 import logging
+from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
 from sqlalchemy import select
@@ -77,16 +78,51 @@ def _global_tray_id(assignment: SpoolAssignment) -> int:
     return assignment.ams_id * 4 + assignment.tray_id
 
 
-async def _default_cost_per_kg(db: AsyncSession) -> float:
+async def default_cost_per_kg(db: AsyncSession) -> float:
+    """The default filament price per kg, as the Settings page shows it: 25
+    when it was never saved."""
     from backend.app.api.routes.settings import get_setting
 
     raw = await get_setting(db, "default_filament_cost")
     try:
-        return float(raw) if raw is not None else 25.0
+        return float(raw) if raw not in (None, "") else 25.0
     except (TypeError, ValueError):
         return 25.0
 
 
+def price_one_print(
+    grams: float | None,
+    usage_cost: float | None,
+    tracked_grams: float,
+    printed_grams: float,
+    runs: int,
+    fallback_cost_per_kg: float,
+    default_cost_per_kg: float,
+) -> float | None:
+    """Cost of one print of ``grams``, the figure an archive carries (#1378).
+
+    ``usage_cost`` / ``tracked_grams`` are the priced spool usage recorded
+    against the archive, over every run of it; ``printed_grams`` / ``runs`` are
+    those runs' grams and count from Print History. The spools tracked
+    ``tracked_grams`` of ``printed_grams``: one print gets that share at their
+    average price per gram and the rest at the default rate (#1344). Without
+    priced usage, everything is priced at ``fallback_cost_per_kg``.
+    """
+    if usage_cost and usage_cost > 0 and tracked_grams > 0:
+        if not grams:
+            # No figure for one print: an average run's usage is the best one.
+            cost = usage_cost / max(1, runs)
+        else:
+            expected = printed_grams if printed_grams > 0 else grams
+            covered = grams * min(1.0, tracked_grams / expected)
+            cost = covered * (usage_cost / tracked_grams) + (grams - covered) / 1000.0 * default_cost_per_kg
+    elif grams:
+        cost = grams / 1000.0 * fallback_cost_per_kg
+    else:
+        return None
+    return float(Decimal(str(cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
+
+
 async def estimate_queue_source_cost(
     db: AsyncSession,
     *,
@@ -109,7 +145,7 @@ async def estimate_queue_source_cost(
         # requiring the browser to provide an estimate.
         if grams is None or grams <= 0:
             return None
-        default_cost = await _default_cost_per_kg(db)
+        default_cost = await default_cost_per_kg(db)
         estimated_cost = (grams / 1000.0) * default_cost
         return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
 
@@ -133,7 +169,7 @@ async def estimate_queue_source_cost(
     if not usage:
         return None
 
-    default_cost = await _default_cost_per_kg(db)
+    default_cost = await default_cost_per_kg(db)
     cost_by_tray: dict[int, float | None] = {}
     mapping = _parse_mapping(ams_mapping)
     # Built-in spool prices only. In Spoolman mode the built-in table may still

+ 3 - 3
backend/app/services/usage_tracker.py

@@ -686,7 +686,6 @@ async def on_print_complete(
     """
     from sqlalchemy import select
 
-    from backend.app.api.routes.settings import get_setting
     from backend.app.models.spool_usage_history import SpoolUsageHistory
 
     session = _active_sessions.pop(printer_id, None)
@@ -705,8 +704,9 @@ async def on_print_complete(
     handled_trays: set[tuple[int, int]] = set()
 
     # Fetch default filament cost from settings for fallback
-    default_cost_str = await get_setting(db, "default_filament_cost")
-    default_filament_cost = float(default_cost_str) if default_cost_str else 0.0
+    from backend.app.services.print_cost_estimate import default_cost_per_kg
+
+    default_filament_cost = await default_cost_per_kg(db)
 
     # Fall back to ams_mapping captured at print start (needed when auto-archive is off
     # and the caller can't retrieve the mapping from _print_ams_mappings without archive_id)

+ 188 - 0
backend/tests/integration/test_one_print_cost_b5.py

@@ -0,0 +1,188 @@
+"""An archive's cost is the cost of one print of it.
+
+Completion stores the first run's cost on the archive and every run's own cost
+on its Print History entry (#1378). "Recalculate costs" and "Rescan" used to
+sum the spool usage of every run, so an archive printed three times was priced
+three times over; editing its grams repriced nothing.
+"""
+
+import zipfile
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.config import settings
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+
+async def _archive(archive_factory, printer_factory, db_session, *, grams, cost=None, with_run=True):
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id,
+        print_name="Bracket",
+        status="completed",
+        cost=cost,
+        filament_used_grams=grams,
+        filament_type="PLA",
+        with_run=with_run,
+    )
+    return archive.id
+
+
+async def _usage(db_session, archive_id, *rows):
+    for grams, cost in rows:
+        db_session.add(SpoolUsageHistory(archive_id=archive_id, weight_used=grams, cost=cost, print_name="Bracket"))
+    await db_session.commit()
+
+
+async def _cost(async_client, archive_id):
+    response = await async_client.get(f"/api/v1/archives/{archive_id}")
+    assert response.status_code == 200
+    return response.json()["cost"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_recalculate_prices_one_print_of_an_archive_printed_twice(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0)
+    await _usage(db_session, archive_id, (100.0, 3.0), (100.0, 3.0))
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert await _cost(async_client, archive_id) == 3.0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_recalculate_still_prices_untracked_grams_at_the_default(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """One run, half the grams on an inventory spool (#1344): 50 g at the
+    spool's 2.00 plus 50 g at the 25/kg default."""
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0)
+    await _usage(db_session, archive_id, (50.0, 2.0))
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert await _cost(async_client, archive_id) == 3.25
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rescan_prices_one_print(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path, monkeypatch
+):
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0)
+    (tmp_path / "archives/test").mkdir(parents=True)
+    with zipfile.ZipFile(tmp_path / "archives/test/test_print.gcode.3mf", "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+    await _usage(db_session, archive_id, (100.0, 3.0), (100.0, 3.0), (100.0, 3.0))
+
+    assert (await async_client.post(f"/api/v1/archives/{archive_id}/rescan")).status_code == 200
+
+    assert await _cost(async_client, archive_id) == 3.0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_editing_grams_prices_the_archive_and_its_run(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """A print archived without its 3MF has neither grams nor cost (#1820)."""
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=None)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive_id}", json={"filament_used_grams": 120})
+    assert response.status_code == 200
+    assert response.json()["cost"] == 3.0
+
+    run = await db_session.scalar(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
+    await db_session.refresh(run)
+    assert (run.filament_used_grams, run.cost) == (120, 3.0)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_cost_given_with_the_grams_is_kept(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0, cost=2.5)
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{archive_id}", json={"filament_used_grams": 200, "cost": 9.99}
+    )
+
+    assert response.json()["cost"] == 9.99
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_run_that_measured_its_own_grams_keeps_its_cost(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0, cost=2.5)
+    run = await db_session.scalar(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
+    run.filament_used_grams, run.cost = 37.0, 1.11
+    await db_session.commit()
+
+    response = await async_client.patch(f"/api/v1/archives/{archive_id}", json={"filament_used_grams": 200})
+    assert response.json()["cost"] == 5.0
+
+    await db_session.refresh(run)
+    assert (run.filament_used_grams, run.cost) == (37.0, 1.11)
+
+
+async def _runs(db_session, archive_id, *grams):
+    for g in grams:
+        db_session.add(PrintLogEntry(archive_id=archive_id, status="completed", filament_used_grams=g))
+    await db_session.commit()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_recalculate_keeps_the_untracked_share_of_every_run(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Printed three times, half of each 100 g run on a 60/kg spool: one print
+    is 50 g at 0.06 plus 50 g at the 25/kg default, as completion priced it."""
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0)
+    await _runs(db_session, archive_id, 100.0, 100.0)
+    await _usage(db_session, archive_id, (50.0, 3.0), (50.0, 3.0), (50.0, 3.0))
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert await _cost(async_client, archive_id) == 4.25
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_recalculate_prices_unpriced_usage_at_the_default(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """A usage row recorded without a price is not free filament."""
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=100.0)
+    await _usage(db_session, archive_id, (50.0, 2.0), (50.0, None))
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert await _cost(async_client, archive_id) == 3.25
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_recalculate_without_grams_prices_an_average_run(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Archived without its 3MF, so no grams: the spool usage of its two runs
+    still prices one of them."""
+    archive_id = await _archive(archive_factory, printer_factory, db_session, grams=None)
+    await _runs(db_session, archive_id, None)
+    await _usage(db_session, archive_id, (40.0, 2.0), (40.0, 2.0))
+
+    assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
+
+    assert await _cost(async_client, archive_id) == 2.0

+ 1 - 1
backend/tests/unit/services/test_mode_toggle_keeps_assignments_2812.py

@@ -129,7 +129,7 @@ class TestCostEstimateIgnoresTheInactiveModesTable:
                 return_value=spoolman_mode,
             ),
             patch(
-                "backend.app.services.print_cost_estimate._default_cost_per_kg",
+                "backend.app.services.print_cost_estimate.default_cost_per_kg",
                 new_callable=AsyncMock,
                 return_value=25.0,
             ),

+ 3 - 3
backend/tests/unit/services/test_print_cost_estimate.py

@@ -22,7 +22,7 @@ async def test_archive_without_stored_cost_uses_server_default(monkeypatch):
         filament_used_grams=100.0,
         cost=None,
     )
-    monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
+    monkeypatch.setattr(print_cost_estimate, "default_cost_per_kg", AsyncMock(return_value=20.0))
 
     cost = await print_cost_estimate.estimate_queue_source_cost(SimpleNamespace(), archive=archive)
 
@@ -41,7 +41,7 @@ async def test_library_estimate_uses_server_default_cost(monkeypatch, library_fi
             ]
         ),
     )
-    monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
+    monkeypatch.setattr(print_cost_estimate, "default_cost_per_kg", AsyncMock(return_value=20.0))
 
     cost = await print_cost_estimate.estimate_queue_source_cost(
         SimpleNamespace(),
@@ -64,7 +64,7 @@ async def test_library_estimate_uses_server_spool_assignment_costs(monkeypatch,
             ]
         ),
     )
-    monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
+    monkeypatch.setattr(print_cost_estimate, "default_cost_per_kg", AsyncMock(return_value=20.0))
 
     assignments = [
         SimpleNamespace(ams_id=0, tray_id=0, spool=SimpleNamespace(cost_per_kg=10.0)),

+ 3 - 0
backend/tests/unit/services/test_usage_tracker.py

@@ -801,6 +801,9 @@ class TestSpoolAssignmentSnapshot:
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 # Cost-update block re-selects the archive to mutate cost.
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                # Priced at the default rate, so it asks whether this is the
+                # archive's first run before storing the cost.
+                MagicMock(scalar=MagicMock(return_value=0)),
             ]
         )
 

+ 58 - 0
backend/tests/unit/test_cost_tracking.py

@@ -271,6 +271,64 @@ class TestCostCalculation:
         # Cost = 30g / 1000 * 15.0 = 0.45
         assert results[0]["cost"] == 0.45
 
+    @pytest.mark.asyncio
+    async def test_cost_uses_the_settings_default_when_none_is_saved(self):
+        """Never saved, the default is the 25/kg the Settings page shows, as for
+        every other reader of it; not 0, which left unpriced spools uncosted."""
+        # Spool without cost_per_kg
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # 30g used from 3MF
+        filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value=None),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 30.0
+        # Cost = 30g / 1000 * 25.0 = 0.75
+        assert results[0]["cost"] == 0.75
+
     @pytest.mark.asyncio
     async def test_cost_zero_when_default_cost_is_zero(self):
         """Cost is None when both spool cost and default cost are 0."""