diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 2fd5f9fd5..900318021 100644 --- a/backend/app/api/routes/archives.py +++ b/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") diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index b752095e8..c3dade823 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/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, diff --git a/backend/tests/integration/test_spoolman_cost_preserved_2591.py b/backend/tests/integration/test_spoolman_cost_preserved_2591.py new file mode 100644 index 000000000..6d6800de1 --- /dev/null +++ b/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() diff --git a/backend/tests/unit/services/test_spoolman_print_cost_2591.py b/backend/tests/unit/services/test_spoolman_print_cost_2591.py new file mode 100644 index 000000000..d005f2a6d --- /dev/null +++ b/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)