mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
@@ -19,6 +19,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
|
||||
|
||||
### Fixed
|
||||
- **Print cost ignored the linked Spoolman spool's price and always used the default rate (#2591, reported by @khaosdoctor)** — Spoolman holds per-spool pricing, and #261 gave that as the reason for integrating with it, but 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, 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 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. On an install with an empty catalogue, which is what the reporter had, every print came out at the default no matter what the spool cost. Multi-material was wrong twice over: the primary type's rate was 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, as the charge is made, and the per-slot costs are summed — which is what fixes the multi-material case rather than a separate change. The rate is the spool's own `price` when set, else the filament's, divided by `filament.weight`; that is the same net-grams field the remain%-delta path already divides by, so a spool that can be charged by percentage can always be priced. A missing or zero price is read as unpriced rather than free, and those grams — along with a tray that has no Spoolman row and any filament the sliced file never attributed — are covered at the default rate in one subtraction against the archive's own total, so a partially priced print still reports a whole-print figure instead of a fraction of one (#1344 in the other inventory mode). Only the first run writes the archive's cost, matching the built-in writer (#1378). Pricing is 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, whereas the grams have already been deducted from these spools and the archive should say what that deduction was worth.
|
||||
- **A rescan or a cost recalculation quietly replaced a Spoolman-derived cost with a default-rate one** — Both rebuild an archive's cost from the built-in inventory's usage rows and fall back to the catalogue or the global default when there are none. In Spoolman mode there are never any, so the fallback was not a recalculation but a downgrade, and it would have undone the pricing above on the next rescan. The spool-to-slot resolution a price is derived from only exists while a print is being completed and cannot be rebuilt from the archive row afterwards, 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 that has no cost yet is still priced, and with Spoolman off both behave exactly as before.
|
||||
- **A print sent from Bambu Studio charged the wrong Spoolman spool, and rewrote the archive to match (#2953, reported by @bitelvl1)** — A sliced file numbers its filaments 1..4; which AMS tray each came from is decided when the job is sent, and #2768 gave the Spoolman writer two ways to recover that decision when the print did not come through Bambuddy: the printer's own `mapping` field, and a colour match of the 3MF's slots against the loaded trays. An A1 can satisfy neither. It publishes no `mapping` field, and it drops the MQTT connection when Bambuddy subscribes to its request topic, so the slicer's instruction never arrives either — which leaves the colour match, and that compares hex strings exactly. The reporter sliced with a generic black profile against a tray they had set to #111111. No match, so every print fell through to the positional default and charged slot 1 to whatever sat in the first tray: 2.17 g onto a grey PLA+ spool, while the print was fed from tray 3. The printer had already said so. `Tray change during print: tray=3 at layer=0` is in their log, recorded 90 seconds in, and read further down the same completion pass to decide which slots the print had touched — the same pass then charged tray 0 on a guess. A single-slot print is now pinned to the tray the printer reported feeding from, using the ladder the built-in inventory writer has consulted all along: the mid-print tray-change log, then the tray loaded at print start, then the current one, then the last real tray seen. Spoolman users were the only ones not getting it, which is why an install with the internal inventory in use never showed this. Gated on exactly one slot with usage, because a multi-colour print moves `tray_now` on every change and one reading cannot then be attributed to one slot; and it declines when the log holds more than one switch, so an AMS-backup runout still splits per segment (#1793) rather than landing whole on one spool. Where nothing at all names a tray the positional default still stands — it is right for an AMS loaded in slicer order — but it now says so at warning level, and no longer restamps the archive's filament colour and material from a spool it picked by position. That restamp is what made the fault look like data loss: the grams can be put back, whereas overwriting what the slicer recorded leaves nothing to compare against, and the reporter's archive had already been rewritten from #000000 to the wrong spool's grey.
|
||||
- **One unexplained disconnect could stop a printer reporting slicer print mappings for good** — Bambuddy subscribes to the printer's request topic to intercept the mapping a slicer sends with a print. Printers that refuse kill the TCP connection instead of returning a SUBACK failure, so the only signal is "we subscribed, then got disconnected", and that was believed the first time it happened. Every other reason a connection drops inside the same few seconds looks identical — a network blip, the printer rebooting, the container being stopped mid-probe — and the verdict was cached per serial with no re-probe anywhere, so on a printer that supports the topic perfectly well a single unlucky drop cost mapping capture for the rest of the process, and every Studio print after it was charged to a spool picked by tray position. The subscription is now retried once before being written off, and a disconnect Bambuddy asked for is not counted at all. A printer that genuinely refuses answers the same way every time and pays one extra reconnect; nothing changes for one already known to refuse, which still skips the subscription outright rather than reopening a reconnect loop.
|
||||
- **The AMS temperature alarm fired hourly on ambient room heat, and silencing it cost the colour band (#2905, reported and contributed by @ojimpo in #2943)** — `ams_temp_fair` decides when the AMS card turns amber, and it decided when a notification was sent as well. 35 °C is a reasonable place to change a colour and a low place to page someone: an AMS in a room without air conditioning sits above it on ambient heat alone, so the alarm repeated every hour for as long as the weather lasted — with every heater target at zero and humidity inside the good band — and the only way to stop it was to raise the display band and lose the red that says the unit is warm. "Red above 35 °C, notify above 45 °C" was not expressible. A new **Alarm above** field under Settings → AMS Display Thresholds separates the two. It is nullable and unset resolves to the fair value, so an install that never touches it behaves exactly as it did and there is no migration — the field is a row in the existing key/value settings table, not a column. Deliberately not a per-filament map like the humidity thresholds: that map exists because humidity also decides when a drying cycle starts and PLA, PETG and PA want different targets, whereas ambient heat does not care what is loaded, so a per-type map would only help someone who set every type to the same number. Three call sites take the new value, not one. The comparison is the obvious one; the second is the drying latch, which releases once the unit reads back at or below the threshold it was given, so handing it the display band stranded the latch on any unit that settles above it — an AMS resting at 37.7 °C never returns under a 35 °C band, so the hold placed during a cycle could only expire on its grace cap instead of releasing when the unit had actually cooled. The third is the number the notification quotes, or the message reads "> 35 °C" while firing at 45. A non-positive or non-finite value is refused rather than honoured, which matters more than it looks: nothing is ever greater than NaN, so a value that failed to parse as a real number would have silenced the alarm permanently — the failure mode that looks exactly like a working configuration. The field shows the fair threshold as its placeholder so the fallback is visible without reading docs, says so when a value it cannot use is entered, and the temperature block gains the disclosure line the humidity block has had all along: only the alarm threshold notifies, Good and Fair colour the display, and leaving it empty alarms where it always did. The background task that dispatches these alarms is a no-arg infinite loop and had never been covered by a test; it is now driven end to end, pinning that an install with no value set still fires and quotes 35, that a stored 45 is both what fires and what the message says, and that 37.7 °C in a warm room sends nothing.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user