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.
This commit is contained in:
maziggy
2026-08-26 10:21:23 +02:00
4 changed files with 681 additions and 8 deletions
+35 -1
View File
@@ -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
View File
@@ -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,
@@ -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()
@@ -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)