Selaa lähdekoodia

Price a print from the spool that fed it, not the default rate (issue #2591)

Spoolman holds per-spool pricing, and #261 gave that as the reason for
integrating with it. Nothing ever read it. A print's cost is set once, at
archive time, from the built-in Filament catalogue matched on the primary type
and falling back to a global default rate -- and in Spoolman mode nothing
revisited that figure afterwards. The per-spool recompute that would have fixed
it, in usage_tracker.on_print_complete, runs only over rows the built-in
inventory writes, and Spoolman mode hands the usage tracker spoolman_owns_usage
at print start so it writes none. The reporter's catalogue was empty, which is
the ordinary state of one in Spoolman mode, so every print came out at the
default no matter what the linked spool cost.

Multi-material was wrong twice over there: the primary type's rate applied to
the whole print's weight, so a slot of expensive PA was billed at the price of
the PLA beside it.

Each slot is now priced from the spool it was actually charged to, at the
moment of the charge, and the per-slot costs are summed -- which is what fixes
the multi-material case, rather than a separate change. All three charge paths
feed it: per-slot, tray-split, and the remain%-delta fallback. The rate is the
spool's own price when set, else the filament's, over filament.weight. That is
net grams excluding the core, and the same field the remain-delta path already
divides by to turn a percentage into weight, so a spool that can be charged by
percentage can always be priced. The price comes out of the get_spool call the
colour and material rewrites already pay for, so the tagged path costs no extra
round trip.

Grams no spool could price are covered at the global default in one subtraction
against the archive's own total. A spool with no price, a tray with no Spoolman
row, and filament the sliced file never attributed are the same case from here,
and without the top-up a print with one priced slot out of four would report a
quarter of its cost -- #1344 in the other inventory mode. Only the first run
writes the archive, matching the built-in writer (#1378); reprint actuals live
in PrintLogEntry. If no slot could be priced at all, whatever archive.py
recorded is left alone, so an install with prices in neither place stays where
it was.

Applied even when the slot-to-tray mapping was a positional guess, unlike the
colour and material rewrites beside it. Those overwrite what the slicer
recorded, which is why a guess must not touch them. The cost has no such
original -- archive.py's figure is itself derived from a default rate -- and the
grams have already been deducted from these spools, so the archive should say
what that deduction was worth.

Both cost recalculations would have undone it on the next run. /rescan and
/recalculate-costs rebuild an archive's cost from SpoolUsageHistory and fall
back to the catalogue or the default when there are no rows, which in Spoolman
mode is always, so the fallback was not a recalculation but a downgrade. The
spool-to-slot resolution a price is derived from exists only while a print is
completing and cannot be rebuilt from the archive row, so both now leave a cost
alone rather than replacing it with a worse one, and the bulk endpoint reports
how many it kept. An archive with no cost yet is still priced, and with
Spoolman off both behave exactly as before.

The rate parser refuses more than it looks like it needs to, because everything
it refuses was reachable. A non-dict filament raised through a call that sits
after a successful use_spool, which would have abandoned the remaining slots of
a multi-material print with the charges already made. NaN compares False
against every bound, including the applier's own total <= 0, so a NaN price
would have been written to the archive with nothing downstream able to clear
it; two finite operands can produce it by overflow, so the quotient is checked
as well as the inputs. A bool is an int in Python, and float(True) is 1.0 -- a
weight of 1 g prices a spool per-gram at its whole cost. And a spool-level price
of 0 now falls through to the catalogue rather than reading as free: Spoolman
leaves the override null when unset, but importers write 0 often enough that
treating it literally would price a whole print at the default with a good
catalogue price one level down.
maziggy 1 viikko sitten
vanhempi
sitoutus
39835437a3

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
CHANGELOG.md


+ 35 - 1
backend/app/api/routes/archives.py

@@ -1814,6 +1814,25 @@ async def toggle_favorite(
     return archive
 
 
+async def _spoolman_owns_cost(db: AsyncSession) -> bool:
+    """True when per-spool pricing lives in Spoolman rather than in our tables.
+
+    Both cost recalculations below rebuild a print's cost from
+    ``SpoolUsageHistory``, and fall back to the built-in Filament catalogue or
+    the global default rate when there are no rows for it. In Spoolman mode
+    there are never any rows -- the built-in usage tracker is handed
+    ``spoolman_owns_usage`` at print start and writes none -- so that fallback
+    is not a recalculation, it is a downgrade: it would overwrite the
+    Spoolman-priced figure ``spoolman_tracking`` recorded at completion with a
+    default-rate one, and the per-slot spool resolution it came from is
+    transient and cannot be rebuilt here (#2591).
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    setting = await get_setting(db, "spoolman_enabled")
+    return bool(setting) and setting.lower() == "true"
+
+
 @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
 async def rescan_archive(
     archive_id: int,
@@ -1884,6 +1903,10 @@ async def rescan_archive(
             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))
@@ -1943,7 +1966,10 @@ async def recalculate_all_costs(
         if row[0] is not None and row[1] is not None and row[1] > 0
     }
 
+    spoolman_owns = await _spoolman_owns_cost(db)
+
     updated = 0
+    preserved = 0
     for archive in archives:
         usage = cost_map.get(archive.id)
         if usage is not None:
@@ -1965,6 +1991,11 @@ async def recalculate_all_costs(
             fallback_cost = usage_result.scalar()
             if fallback_cost is not None and fallback_cost > 0:
                 new_cost = round(fallback_cost, 2)
+            elif spoolman_owns and archive.cost is not None:
+                # Priced from the linked Spoolman spools at completion; there is
+                # nothing better to recompute it from here (#2591).
+                new_cost = None
+                preserved += 1
             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)
@@ -1976,7 +2007,10 @@ async def recalculate_all_costs(
             updated += 1
 
     await db.commit()
-    return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
+    message = f"Recalculated costs for {updated} archives"
+    if preserved:
+        message += f"; kept {preserved} priced from Spoolman"
+    return {"message": message, "updated": updated, "preserved": preserved}
 
 
 @router.post("/rescan-all")

+ 240 - 7
backend/app/services/spoolman_tracking.py

@@ -7,6 +7,8 @@ Supports accurate partial usage reporting for failed/cancelled prints.
 
 import json
 import logging
+import math
+from dataclasses import dataclass
 
 from sqlalchemy import delete, select
 
@@ -662,6 +664,104 @@ async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tr
         return result.scalar_one_or_none()
 
 
+def _as_positive_number(value) -> float | None:
+    """``value`` as a float when it is a usable positive quantity, else None.
+
+    Rejects bools (``True`` is an int in Python, and ``float(True)`` is 1.0 --
+    a weight of 1 g would price a spool per-gram at its whole cost), and
+    rejects NaN and infinity, which compare False against every bound and would
+    otherwise reach the archive as a NaN cost that no later comparison can
+    clear.
+    """
+    if isinstance(value, bool):
+        return None
+    try:
+        number = float(value)
+    except (TypeError, ValueError):
+        return None
+    if not math.isfinite(number) or number <= 0:
+        return None
+    return number
+
+
+def _spool_cost_per_gram(spool: dict | None) -> float | None:
+    """What one gram off this Spoolman spool costs, or None if it can't be said.
+
+    Spoolman prices a spool in two places. ``filament.price`` is the catalogue
+    figure for a full spool of that filament, and ``price`` on the spool itself
+    overrides it when a particular purchase cost something else -- a sale, a
+    different vendor, import duty. The spool's own value wins, which is the
+    order the Spoolman UI presents them in.
+
+    The divisor is ``filament.weight``: net filament grams, excluding the core.
+    That is the same field the remain-delta path already divides by to turn a
+    remain%% drop into grams, so a spool that can be charged by percentage can
+    always be priced too.
+
+    A missing or non-positive price is not a free spool, it is an unpriced one,
+    and returns None so the caller can fall back to the global default rate
+    rather than silently recording that this print cost nothing. Mirrors the
+    ``cost_per_kg > 0`` guard the built-in inventory writer applies to its own
+    per-spool rate.
+    """
+    if not isinstance(spool, dict):
+        return None
+    filament = spool.get("filament")
+    if not isinstance(filament, dict):
+        filament = {}
+
+    # A spool-level 0 is treated as "not overridden" rather than "this roll was
+    # free": Spoolman leaves the field null when unset, but an import or an API
+    # client that writes 0 instead is common enough that reading it as free
+    # would price a whole print at the default rate while a perfectly good
+    # catalogue price sat one level down.
+    raw_price = spool.get("price")
+    if _as_positive_number(raw_price) is None:
+        raw_price = filament.get("price")
+
+    price = _as_positive_number(raw_price)
+    weight = _as_positive_number(filament.get("weight"))
+    if price is None or weight is None:
+        return None
+    # Both operands can be finite and the quotient still overflow. A non-finite
+    # rate would reach the archive as a NaN or inf cost, and every later
+    # comparison against it is False, so nothing downstream would correct it.
+    rate = price / weight
+    return rate if math.isfinite(rate) else None
+
+
+@dataclass
+class _PrintCost:
+    """What a print cost, accumulated as each slot is actually charged.
+
+    Only grams that were both charged to a spool *and* priced from it are
+    counted. Everything else -- a slot whose spool has no price, a tray with no
+    Spoolman row at all, filament the 3MF never attributed -- is left for the
+    caller to cover at the global default rate, in one subtraction against the
+    archive's own total. That is the same shape as the built-in inventory
+    writer's untracked-grams top-up (#1344), and it means a partially priced
+    print reports a whole-print figure rather than only the priced share.
+    """
+
+    cost: float = 0.0
+    priced_grams: float = 0.0
+    priced: int = 0
+    unpriced: int = 0
+
+    def add(self, grams: float, spool: dict | None, label: str) -> None:
+        """Price ``grams`` off ``spool``. Call only after the charge succeeded."""
+        if grams <= 0:
+            return
+        rate = _spool_cost_per_gram(spool)
+        if rate is None:
+            self.unpriced += 1
+            logger.debug("[SPOOLMAN] %s: spool has no usable price, will fall back to the default rate", label)
+            return
+        self.cost += grams * rate
+        self.priced_grams += grams
+        self.priced += 1
+
+
 async def _report_spool_usage_for_slots(
     client,
     filament_usage_items: list[tuple[int, float]],
@@ -672,6 +772,7 @@ async def _report_spool_usage_for_slots(
     printer_id: int | None = None,
     slot_colors_out: dict[int, str] | None = None,
     slot_materials_out: dict[int, str] | None = None,
+    cost_out: _PrintCost | None = None,
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
 
@@ -718,6 +819,9 @@ async def _report_spool_usage_for_slots(
         # yields an id and is fetched below.
         spool_color_hex: str | None = None
         spool_material: str | None = None
+        # Full spool row, kept so the price fields (#2591) can be read from the
+        # same fetch the colour and material already pay for.
+        spool_obj: dict | None = None
 
         spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
         if spool_tag:
@@ -725,6 +829,7 @@ async def _report_spool_usage_for_slots(
             if spool:
                 spool_id_to_use = spool["id"]
                 resolution_path = "tag"
+                spool_obj = spool
                 spool_color_hex = (spool.get("filament") or {}).get("color_hex")
                 spool_material = (spool.get("filament") or {}).get("material")
 
@@ -747,17 +852,19 @@ async def _report_spool_usage_for_slots(
         # id, so fetch the spool once for whichever value is still missing.
         # Strictly best-effort: a fetch failure must never abort the weight
         # reporting for the remaining slots, so the catch is broad.
-        if slot_colors_out is not None or slot_materials_out is not None:
+        if slot_colors_out is not None or slot_materials_out is not None or cost_out is not None:
             need_color = slot_colors_out is not None and spool_color_hex is None
             need_material = slot_materials_out is not None and spool_material is None
-            if need_color or need_material:
+            need_price = cost_out is not None and spool_obj is None
+            if need_color or need_material or need_price:
                 try:
-                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    spool_obj = await client.get_spool(spool_id_to_use)
+                    _fil = spool_obj.get("filament") or {}
                     if need_color:
                         spool_color_hex = _fil.get("color_hex")
                     if need_material:
                         spool_material = _fil.get("material")
-                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                except Exception as exc:  # noqa: BLE001 — colour/material/price are non-critical
                     logger.debug("[SPOOLMAN] Slot %s: could not fetch spool filament: %s", slot_id, exc)
             if slot_colors_out is not None and spool_color_hex:
                 slot_colors_out[slot_id] = spool_color_hex
@@ -775,6 +882,10 @@ async def _report_spool_usage_for_slots(
                 resolution_path,
             )
             spools_updated += 1
+            # Priced only after the charge landed, so a spool Spoolman refused
+            # cannot contribute to what the print is said to have cost.
+            if cost_out is not None:
+                cost_out.add(grams_used, spool_obj, f"Slot {slot_id}")
         except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
             logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
 
@@ -795,6 +906,7 @@ async def _report_spool_usage_split_by_tray_changes(
     printer_id: int,
     slot_colors_out: dict[int, str] | None = None,
     slot_materials_out: dict[int, str] | None = None,
+    cost_out: _PrintCost | None = None,
 ) -> tuple[int, set[int]]:
     """Split each slot's grams across ``tray_changes`` and charge per-segment.
 
@@ -848,6 +960,7 @@ async def _report_spool_usage_split_by_tray_changes(
             resolution_path = ""
             spool_color_hex: str | None = None
             spool_material: str | None = None
+            spool_obj: dict | None = None
 
             spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
             if spool_tag:
@@ -855,6 +968,7 @@ async def _report_spool_usage_split_by_tray_changes(
                 if spool:
                     spool_id_to_use = spool["id"]
                     resolution_path = "tag"
+                    spool_obj = spool
                     spool_color_hex = (spool.get("filament") or {}).get("color_hex")
                     spool_material = (spool.get("filament") or {}).get("material")
 
@@ -882,14 +996,19 @@ async def _report_spool_usage_split_by_tray_changes(
             need_material = (
                 slot_materials_out is not None and slot_id not in slot_materials_out and spool_material is None
             )
-            if need_color or need_material:
+            # Unlike the colour, every segment needs its own price: each was
+            # charged to its own spool, and a backup roll can have cost
+            # something different from the one it replaced.
+            need_price = cost_out is not None and spool_obj is None
+            if need_color or need_material or need_price:
                 try:
-                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    spool_obj = await client.get_spool(spool_id_to_use)
+                    _fil = spool_obj.get("filament") or {}
                     if need_color:
                         spool_color_hex = _fil.get("color_hex")
                     if need_material:
                         spool_material = _fil.get("material")
-                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                except Exception as exc:  # noqa: BLE001 — colour/material/price are non-critical
                     logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool filament: %s", slot_id, exc)
             if slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex:
                 slot_colors_out[slot_id] = spool_color_hex
@@ -909,6 +1028,8 @@ async def _report_spool_usage_split_by_tray_changes(
                     resolution_path,
                 )
                 spools_updated += 1
+                if cost_out is not None:
+                    cost_out.add(round(segment_grams, 2), spool_obj, f"Split slot {slot_id} seg {seg_idx}")
             except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
                 logger.warning(
                     "[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s",
@@ -1256,6 +1377,9 @@ async def report_usage(printer_id: int, archive_id: int):
 
         slot_colors: dict[int, str] = {}
         slot_materials: dict[int, str] = {}
+        # Priced as each charge lands, so the figure the archive ends up with
+        # describes the same grams Spoolman actually had deducted (#2591).
+        print_cost = _PrintCost()
         handled_global_tray_ids: set[int] = set()
         spools_updated = 0
 
@@ -1301,6 +1425,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
                     slot_materials_out=slot_materials,
+                    cost_out=print_cost,
                 )
                 spools_updated += split_updated
                 handled_global_tray_ids |= split_handled
@@ -1317,6 +1442,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
                     slot_materials_out=slot_materials,
+                    cost_out=print_cost,
                 )
                 # Track which physical slots the 3MF path already covered so
                 # Path 2 doesn't double-charge them.
@@ -1349,6 +1475,7 @@ async def report_usage(printer_id: int, archive_id: int):
                 print_used_keys=_print_used_tray_keys(slot_to_tray, tray_now_at_start, current),
                 slot_colors_out=slot_colors,
                 slot_materials_out=slot_materials,
+                cost_out=print_cost,
             )
             spools_updated += fallback_updates
 
@@ -1380,6 +1507,14 @@ async def report_usage(printer_id: int, archive_id: int):
             # than it was sliced for otherwise records the sliced type (#2563).
             await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
 
+        # Cost is applied whether or not the mapping was a guess, unlike the
+        # colour and material above. Those overwrite what the slicer recorded,
+        # which is why a guess must not touch them; the cost has no such
+        # original -- archive.py's figure is itself derived from a default rate
+        # -- and the grams have already been deducted from these spools, so the
+        # archive should say what that deduction was worth.
+        await _apply_spool_cost_to_archive(db, archive_id, print_cost)
+
 
 def _print_used_tray_keys(
     slot_to_tray: list | None,
@@ -1435,6 +1570,7 @@ async def _report_remain_delta_for_slots(
     print_used_keys: set[tuple[int, int]] | None = None,
     slot_colors_out: dict[int, str] | None = None,
     slot_materials_out: dict[int, str] | None = None,
+    cost_out: _PrintCost | None = None,
 ) -> int:
     """AMS remain%-delta path: write ``(start - current) * filament.weight``
     grams to Spoolman for slots the 3MF path didn't cover.
@@ -1552,6 +1688,10 @@ async def _report_remain_delta_for_slots(
             continue
 
         spools_updated += 1
+        # ``spool`` here is the full row fetched above for its filament weight,
+        # so the price is already in hand (#2591).
+        if cost_out is not None:
+            cost_out.add(grams_used, spool, f"AMS{ams_id}-T{tray_id}")
         # No 3MF slot_id for this path — use the AMS slot key so the maps can
         # still be inspected by callers if needed. The archive rewrites
         # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only
@@ -1584,6 +1724,99 @@ async def _report_remain_delta_for_slots(
     return spools_updated
 
 
+async def _apply_spool_cost_to_archive(db, archive_id: int, print_cost: _PrintCost) -> None:
+    """Set an archive's cost from what the Spoolman spools that fed it are worth (#2591).
+
+    Until now this was the one thing the Spoolman integration was asked for by
+    name and did not do. ``archive.py`` prices a print once, at archive time,
+    from the built-in Filament catalogue matched on the primary type, falling
+    back to the global default rate -- and in Spoolman mode nothing ever
+    revisited that figure, because the per-spool recompute in
+    ``usage_tracker.on_print_complete`` only runs over rows the built-in
+    inventory wrote and Spoolman mode writes none. An install with an empty
+    catalogue therefore priced every print at the default no matter what the
+    linked spool actually cost.
+
+    Multi-material was wrong twice over there: the primary type's rate applied
+    to the *whole* print's grams, so a slot of expensive PA came out at the
+    price of the PLA next to it. Summing per charged slot is what fixes that,
+    and it falls out of pricing each charge as it is made rather than pricing a
+    total afterwards.
+
+    Grams that could not be priced are covered at the global default rate in a
+    single subtraction against the archive's own total -- a slot whose spool has
+    no price, a tray with no Spoolman row, and filament the 3MF never attributed
+    are all the same case. Without it a print with one priced slot out of four
+    would report a quarter of its cost, which is #1344 in a different inventory
+    mode.
+
+    Only on the first run, matching the built-in writer (#1378): reprint actuals
+    live in ``PrintLogEntry``, and the archive card keeps the first run's figure
+    so a failed 10 g reprint doesn't visually clobber a successful 100 g print.
+
+    Does nothing when no slot could be priced, leaving whatever ``archive.py``
+    recorded. That keeps an install with prices in neither place exactly where
+    it was.
+    """
+    if print_cost.priced == 0:
+        if print_cost.unpriced:
+            logger.info(
+                "[SPOOLMAN] Archive %s: %d charged spool(s) carry no price -- "
+                "leaving the cost as recorded at archive time",
+                archive_id,
+                print_cost.unpriced,
+            )
+        return
+
+    from sqlalchemy import func
+
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_log import PrintLogEntry
+
+    archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if archive is None:
+        return
+
+    total = print_cost.cost
+    archive_grams = archive.filament_used_grams or 0
+    unpriced_grams = max(0.0, archive_grams - print_cost.priced_grams)
+    if unpriced_grams > 0:
+        # Malformed settings must not cost the whole usage report; the rate is
+        # the least important thing this pass produces.
+        try:
+            _setting = await get_setting(db, "default_filament_cost")
+            default_cost_per_kg = float(_setting) if _setting else 25.0
+        except (TypeError, ValueError):
+            default_cost_per_kg = 25.0
+        if default_cost_per_kg > 0:
+            total += (unpriced_grams / 1000.0) * default_cost_per_kg
+
+    if total <= 0:
+        return
+
+    existing_runs = (
+        await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
+    ).scalar()
+    if existing_runs:
+        return
+
+    new_cost = round(total, 2)
+    if new_cost != archive.cost:
+        logger.info(
+            "[SPOOLMAN] Archive %s cost %s -> %s (%d slot(s) priced from Spoolman over %.2fg, "
+            "%.2fg at the default rate)",
+            archive_id,
+            archive.cost,
+            new_cost,
+            print_cost.priced,
+            print_cost.priced_grams,
+            unpriced_grams,
+        )
+        archive.cost = new_cost
+        await db.commit()
+
+
 async def _apply_spool_colors_to_archive(
     db,
     archive_id: int,

+ 94 - 0
backend/tests/integration/test_spoolman_cost_preserved_2591.py

@@ -0,0 +1,94 @@
+"""A Spoolman-priced cost survives the two recalculations (#2591).
+
+``spoolman_tracking`` prices an archive from the linked spools at completion.
+Both cost recalculations rebuild a print's cost from ``SpoolUsageHistory``, and
+Spoolman mode never writes rows there -- the built-in usage tracker is handed
+``spoolman_owns_usage`` at print start. Their catalogue-or-default fallback
+would therefore overwrite the Spoolman figure with a default-rate one on the
+next rescan or bulk recalculate, silently undoing the fix, and the per-slot
+spool resolution it came from is transient and cannot be rebuilt from the
+archive row.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.settings import Settings
+
+
+@pytest.fixture
+async def spoolman_mode(db_session):
+    """Spoolman owns pricing; the built-in catalogue is empty, as the reporter's was."""
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="default_filament_cost", value="25"))
+    await db_session.commit()
+    yield
+    await db_session.rollback()
+
+
+class TestRecalculateCostsPreservesSpoolmanPricing:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_recalculate_keeps_the_spoolman_figure(
+        self, async_client: AsyncClient, spoolman_mode, archive_factory, printer_factory, db_session
+    ):
+        """100 g at the 25/kg default would be 2.50. The archive says 4.00
+        because the linked spool cost 40.00 a kilo, and a recalculate with no
+        usage history to read must not drag it back down."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="SpoolmanPriced", status="completed", cost=4.0)
+        archive.filament_used_grams = 100.0
+        archive.filament_type = "PLA"
+        await db_session.commit()
+
+        response = await async_client.post("/api/v1/archives/recalculate-costs")
+        assert response.status_code == 200
+        assert response.json()["preserved"] >= 1
+
+        after = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert after.status_code == 200
+        assert after.json()["cost"] == 4.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archive_with_no_cost_is_still_priced(
+        self, async_client: AsyncClient, spoolman_mode, archive_factory, printer_factory, db_session
+    ):
+        """The guard preserves a figure; it does not stop one being produced.
+        An archive that never got a cost still falls to the default rate."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="NeverPriced", status="completed", cost=None)
+        archive.filament_used_grams = 100.0
+        archive.filament_type = "PLA"
+        await db_session.commit()
+
+        response = await async_client.post("/api/v1/archives/recalculate-costs")
+        assert response.status_code == 200
+
+        after = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert after.json()["cost"] == 2.5
+
+
+class TestRecalculateCostsWithoutSpoolman:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_internal_mode_recalculates_as_before(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """With Spoolman off, the catalogue/default path is the only source of
+        truth and must still overwrite a stale cost."""
+        db_session.add(Settings(key="default_filament_cost", value="25"))
+        await db_session.commit()
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="InternalMode", status="completed", cost=999.0)
+        archive.filament_used_grams = 100.0
+        archive.filament_type = "PLA"
+        await db_session.commit()
+
+        response = await async_client.post("/api/v1/archives/recalculate-costs")
+        assert response.status_code == 200
+
+        after = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert after.json()["cost"] == 2.5
+        await db_session.rollback()

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

@@ -0,0 +1,312 @@
+"""Print cost comes from the linked Spoolman spool's price (#2591).
+
+Spoolman exists to hold per-spool pricing, and #261 gave that as the reason for
+integrating with it. Bambuddy never read it. ``archive.py`` prices a print once,
+at archive time, from the built-in Filament catalogue matched on the primary
+type and falling back to the global default rate -- and in Spoolman mode nothing
+revisited that figure, because the per-spool recompute in
+``usage_tracker.on_print_complete`` runs only over rows the built-in inventory
+writes, and ``spoolman_owns_usage`` stops it writing any.
+
+The reporter's install had an empty catalogue (``filaments_total: 0``), so every
+print was priced at the global default no matter what the spool cost.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.spoolman_tracking import _PrintCost, _spool_cost_per_gram
+
+
+class _AsyncCtx:
+    def __init__(self, db):
+        self._db = db
+
+    async def __aenter__(self):
+        return self._db
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+def _spool(spool_id, *, price=None, filament_price=None, weight=1000, color="888888", material="PLA"):
+    """A Spoolman spool row, shaped as its API returns one."""
+    return {
+        "id": spool_id,
+        "price": price,
+        "filament": {
+            "price": filament_price,
+            "weight": weight,
+            "color_hex": color,
+            "material": material,
+        },
+    }
+
+
+class TestSpoolCostPerGram:
+    def test_uses_the_filament_catalogue_price(self):
+        """25.00 for a 1 kg spool is 2.5 cents a gram."""
+        assert _spool_cost_per_gram(_spool(1, filament_price=25.0, weight=1000)) == pytest.approx(0.025)
+
+    def test_the_spools_own_price_overrides_the_filaments(self):
+        """Spoolman carries a price on the spool for the purchase that cost
+        something other than the catalogue figure. That is the one the Spoolman
+        UI shows, so it is the one a print should be charged at."""
+        rate = _spool_cost_per_gram(_spool(1, price=40.0, filament_price=25.0, weight=1000))
+        assert rate == pytest.approx(0.04)
+
+    def test_weight_is_net_filament_grams_not_a_fixed_kilo(self):
+        """A 750 g spool is not a kilo. Dividing by a constant would under-price
+        every non-standard roll."""
+        assert _spool_cost_per_gram(_spool(1, filament_price=30.0, weight=750)) == pytest.approx(0.04)
+
+    def test_a_zero_spool_override_falls_through_to_the_catalogue(self):
+        """Spoolman leaves the spool override null when unset, but importers and
+        API clients write 0 often enough that reading it as "this roll was free"
+        would price a whole print at the default rate with a perfectly good
+        catalogue price one level down."""
+        rate = _spool_cost_per_gram(_spool(1, price=0, filament_price=25.0, weight=1000))
+        assert rate == pytest.approx(0.025)
+
+    @pytest.mark.parametrize(
+        "spool",
+        [
+            _spool(1, weight=1000),  # no price anywhere
+            _spool(1, filament_price=25.0, weight=None),  # no reference weight
+            _spool(1, filament_price=0, weight=1000),  # zero is unpriced, not free
+            _spool(1, filament_price=-5, weight=1000),
+            _spool(1, filament_price="abc", weight=1000),
+            {"id": 1, "price": 25, "filament": "not a dict"},
+            {"id": 1, "price": 25, "filament": {"weight": True}},  # bool is an int in Python
+            {"id": 1, "price": float("nan"), "filament": {"weight": 1000}},
+            {"id": 1, "price": 1e308, "filament": {"weight": 1e-308}},  # quotient overflows
+            None,
+        ],
+    )
+    def test_says_nothing_rather_than_guessing(self, spool):
+        """None means "fall back to the default rate", not "this was free" --
+        and never a NaN or an infinity, which every later comparison would
+        silently pass through into the archive."""
+        assert _spool_cost_per_gram(spool) is None
+
+
+class TestPrintCostAccumulator:
+    def test_sums_each_slot_at_its_own_rate(self):
+        """The defect archive.py has: it takes the primary type's rate and
+        applies it to the whole print's grams, so a slot of expensive filament
+        is billed at the price of the cheap one beside it."""
+        cost = _PrintCost()
+        cost.add(100.0, _spool(1, filament_price=20.0, weight=1000), "slot 1")  # 0.02/g
+        cost.add(50.0, _spool(2, filament_price=60.0, weight=1000), "slot 2")  # 0.06/g
+
+        assert cost.cost == pytest.approx(2.0 + 3.0)
+        assert cost.priced_grams == pytest.approx(150.0)
+        assert cost.priced == 2
+
+    def test_an_unpriced_spool_is_counted_but_not_charged(self):
+        """Its grams stay out of priced_grams so the caller covers them at the
+        default rate rather than recording them as free."""
+        cost = _PrintCost()
+        cost.add(100.0, _spool(1, filament_price=20.0, weight=1000), "slot 1")
+        cost.add(50.0, _spool(2, weight=1000), "slot 2")
+
+        assert cost.cost == pytest.approx(2.0)
+        assert cost.priced_grams == pytest.approx(100.0)
+        assert (cost.priced, cost.unpriced) == (1, 1)
+
+    def test_zero_grams_is_not_a_slot(self):
+        cost = _PrintCost()
+        cost.add(0.0, _spool(1, filament_price=20.0, weight=1000), "slot 1")
+        assert (cost.priced, cost.unpriced, cost.cost) == (0, 0, 0.0)
+
+
+class TestReportUsagePricesTheArchive:
+    """End to end: the price has to reach PrintArchive.cost."""
+
+    @staticmethod
+    def _run(tracking, state, spools_by_tag, archive, *, existing_runs=0, default_cost="25"):
+        rows = iter([tracking])
+
+        def _next_row(*_args, **_kwargs):
+            result = MagicMock()
+            result.scalar_one_or_none.return_value = next(rows, archive)
+            # The first-run guard counts PrintLogEntry rows.
+            result.scalar.return_value = existing_runs
+            return result
+
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=_next_row)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+
+        client = AsyncMock()
+        client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spools_by_tag.get(tag))
+        client.get_spool = AsyncMock(
+            side_effect=lambda sid: next((s for s in spools_by_tag.values() if s["id"] == sid), None)
+        )
+        client.use_spool = AsyncMock()
+
+        pm = MagicMock()
+        pm.get_status.return_value = state
+
+        async def _get_setting(_db, key):
+            return {"spoolman_enabled": "true", "default_filament_cost": default_cost}.get(key)
+
+        async def _go():
+            from backend.app.services.spoolman_tracking import report_usage
+
+            with (
+                patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+                patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=_get_setting)),
+                patch(
+                    "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                    AsyncMock(return_value=client),
+                ),
+                patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
+                patch(
+                    "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                    AsyncMock(return_value=None),
+                ),
+                patch("backend.app.services.printer_manager.printer_manager", pm),
+            ):
+                await report_usage(printer_id=1, archive_id=7)
+
+        return _go, client
+
+    @staticmethod
+    def _state():
+        return SimpleNamespace(
+            raw_data={},
+            tray_change_log=[],
+            total_layers=0,
+            layer_num=0,
+            tray_now=255,
+            last_loaded_tray=-1,
+        )
+
+    @pytest.mark.asyncio
+    async def test_the_linked_spools_price_replaces_the_default(self):
+        """The reported bug. 100 g off a spool that cost 40.00 for 1 kg is 4.00,
+        not the 2.50 the global 25/kg default produced."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
+            ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+            tray_now_at_start=0,
+        )
+        archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=2.5)
+
+        run, client = self._run(tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive)
+        await run()
+
+        client.use_spool.assert_awaited_once_with(41, 100.0)
+        assert archive.cost == pytest.approx(4.0)
+
+    @pytest.mark.asyncio
+    async def test_multi_material_bills_each_slot_at_its_own_price(self):
+        """archive.py charged the whole print at the primary type's rate. Two
+        slots, 100 g at 0.02/g and 50 g at 0.06/g, is 5.00 -- not 150 g at
+        either one of them (3.00 or 9.00)."""
+        tracking = SimpleNamespace(
+            filament_usage=[
+                {"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"},
+                {"slot_id": 2, "used_g": 50.0, "type": "PA", "color": "#111111"},
+            ],
+            ams_trays={
+                "0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"},
+                "1": {"tray_uuid": "TRAY1", "tag_uid": "", "tray_type": "PA"},
+            },
+            slot_to_tray=[0, 1],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+            tray_now_at_start=0,
+        )
+        archive = SimpleNamespace(
+            filament_color="#888888", filament_type="PLA,PA", filament_used_grams=150.0, cost=3.75
+        )
+
+        run, _client = self._run(
+            tracking,
+            self._state(),
+            {
+                "TRAY0": _spool(41, filament_price=20.0, weight=1000),
+                "TRAY1": _spool(42, filament_price=60.0, weight=1000, material="PA", color="111111"),
+            },
+            archive,
+        )
+        await run()
+
+        assert archive.cost == pytest.approx(5.0)
+
+    @pytest.mark.asyncio
+    async def test_grams_no_spool_could_price_fall_back_to_the_default_rate(self):
+        """One priced slot out of a heavier print must not report only its own
+        share -- that is #1344 in the other inventory mode. 100 g priced at
+        0.04/g plus 50 g the archive knows about but nothing priced, at the
+        25/kg default, is 4.00 + 1.25."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
+            ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+            tray_now_at_start=0,
+        )
+        archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=150.0, cost=3.75)
+
+        run, _client = self._run(tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive)
+        await run()
+
+        assert archive.cost == pytest.approx(5.25)
+
+    @pytest.mark.asyncio
+    async def test_a_spool_with_no_price_leaves_the_archive_alone(self):
+        """Nothing better is known than what archive.py already recorded, so
+        an install with prices in neither place stays exactly where it was."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": "#888888"}],
+            ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+            tray_now_at_start=0,
+        )
+        archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=2.5)
+
+        run, _client = self._run(tracking, self._state(), {"TRAY0": _spool(41, weight=1000)}, archive)
+        await run()
+
+        assert archive.cost == pytest.approx(2.5)
+
+    @pytest.mark.asyncio
+    async def test_a_reprint_does_not_overwrite_the_first_runs_cost(self):
+        """Same guard the built-in writer carries (#1378): reprint actuals live
+        in PrintLogEntry, and the archive card keeps the first run's figure."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#888888"}],
+            ams_trays={"0": {"tray_uuid": "TRAY0", "tag_uid": "", "tray_type": "PLA"}},
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+            tray_now_at_start=0,
+        )
+        archive = SimpleNamespace(filament_color="#888888", filament_type="PLA", filament_used_grams=100.0, cost=4.0)
+
+        run, client = self._run(
+            tracking, self._state(), {"TRAY0": _spool(41, price=40.0, weight=1000)}, archive, existing_runs=1
+        )
+        await run()
+
+        # The spool is still charged for the reprint's grams...
+        client.use_spool.assert_awaited_once_with(41, 10.0)
+        # ...but the archive keeps the first run's number.
+        assert archive.cost == pytest.approx(4.0)

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä