fix(cost): top-up untracked filament at default rate so multi-color

archives stop reporting near-zero cost (#1344)

  Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
  global default filament cost set to $10/kg. archive.py initial cost
  calc was correct (~$1.10), then usage_tracker.on_print_complete
  overwrote archive.cost with sum(r.cost for r in results) -- where
  results only includes AMS trays mapped to a spool in Bambuddy's
  inventory. On a multi-color print where 3 of 4 used trays had no
  inventory spool, only the one tracked slot's tiny share (~1g) survived
  and the archive recorded $0.01.

  The overwrite logic dates to #505 (Feb 2026) and is correct for
  fully-tracked single-color prints, but the multi-color slicer feature
  in 0.2.4 (988c0055) made the partial-inventory state common -- users
  slice + print multi-color from Bambuddy without first setting up an
  inventory entry for every tray.

  Cover the gap: any filament weight not represented in results gets
  charged at the global default rate. For a fully-tracked print,
  untracked grams = 0 and the top-up adds nothing, so the single-color
  behavior is preserved. For a partial print, the missing slots are
  priced at the user's documented default rate so the archive cost
  reflects the whole print.

  Three call sites updated to share the same logic:
    - usage_tracker.py: live cost-update on print complete
    - archives.py rescan_archive: per-archive manual recalc
    - archives.py recalculate_all_costs: bulk recalc button
This commit is contained in:
maziggy
2026-05-15 15:43:37 +02:00
parent 29379e3be7
commit b5a83924eb
6 changed files with 235 additions and 17 deletions
+2
View File
@@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
### Fixed
- **Multi-color print archives reported near-zero cost (e.g. $0.01 for 110g) when only some AMS trays were mapped to inventory spools** ([#1344](https://github.com/maziggy/bambuddy/issues/1344), reported by @nicktags) — On an H2C running multi-color prints from Bambuddy with the global default filament cost set to $10/kg, the reporter's 110.3g archive showed $0.01 instead of ~$1.10. **Root cause:** `archive.cost` was set in two stages — first in [`backend/app/services/archive.py:1100-1114`](backend/app/services/archive.py) at archive creation (total grams × primary filament `cost_per_kg`, which produced the correct ~$1.10), then **overwritten** in [`backend/app/services/usage_tracker.py:618-621`](backend/app/services/usage_tracker.py) with `sum(r.cost for r in results)` where `results` only contains AMS trays mapped to a spool in Bambuddy's inventory. On a multi-color print where 3 of 4 used trays had no inventory spool, the sum only included the one tracked slot's tiny share, e.g. 1g × $10/kg = $0.01. The overwrite logic (#505, Feb 2026) was correct for fully-tracked single-color prints but silently corrupted multi-color archives when inventory was incomplete. The multi-color slicer feature that shipped in 0.2.4 ([`988c0055`](https://github.com/maziggy/bambuddy/commit/988c0055)) made this state common — many more users started running multi-filament prints from Bambuddy without first setting up inventory entries for every tray. **Fix:** the overwrite block in `usage_tracker.py` now charges any filament weight not covered by an inventory spool at the global default rate. New computation: `total_cost = sum(tracked_costs) + (archive.filament_used_grams - sum(tracked_weights)) × default_filament_cost / 1000`. Fully-tracked prints are unchanged (untracked grams = 0, top-up = 0). Partial-tracked prints get the missing slots' grams charged at the default rate, so the archive reflects the whole print. Same correction applied to the manual rescan path in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py) (`update_metadata` and `recalculate-costs` both now read `SUM(SpoolUsageHistory.weight_used)` alongside `SUM(cost)` and top up by the untracked delta). The pre-#1344 `archive.cost not overwritten with zero` regression test stays green — when `total_cost` after top-up is still 0 (no inventory match, no default rate set), the pre-existing catalog-based cost is preserved. **Regression tests** in [`backend/tests/unit/test_cost_tracking.py`](backend/tests/unit/test_cost_tracking.py): `test_archive_cost_includes_untracked_filament_at_default_rate` — 110g archive, only 10g tracked by inventory at $10/kg, default rate $10/kg → archive.cost = $1.10 (was $0.01 pre-fix; this is the exact reporter scenario). `test_archive_cost_fully_tracked_unchanged_by_topup` — when tracked weight ≥ archive grams, no top-up is applied and cost is unchanged from the pre-fix sum. All 14 cost-tracking tests + 182 in the wider usage-tracker / archives / cost-statistics suites pass; ruff clean.
- **Plate-detection calibration captured the wrong camera when an external camera was configured** ([#1359](https://github.com/maziggy/bambuddy/issues/1359), reported by @Andlar94) — On the reporter's A1 with an external RTSP / go2rtc camera enabled, every print start raised "Build plate not empty" no matter how perfectly they calibrated. **Root cause:** the runtime auto-check at print start in [`backend/app/main.py:1819`](backend/app/main.py) called `check_plate_empty(..., use_external=printer.external_camera_enabled, ...)` — honouring the external camera setting. The manual UI check + calibration routes in [`backend/app/api/routes/camera.py`](backend/app/api/routes/camera.py) declared `use_external: bool = False`, and the frontend client at [`frontend/src/api/client.ts`](frontend/src/api/client.ts) always sent `use_external=false` explicitly (the UI call sites in [`PrintersPage.tsx`](frontend/src/pages/PrintersPage.tsx) never passed `useExternal`). So calibration captured a frame from the **built-in** chamber camera and saved it as the reference; the runtime auto-check captured a frame from the **external** camera and diffed it against that built-in reference — a permanent difference well above any sane threshold, hence "not empty" on every print. **Fix:** the two routes now use `use_external: bool | None = None`, and after the printer row is loaded they derive the default as `bool(printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type)` — identical to the runtime path's logic and the service-layer gate at [`plate_detection.py:605`](backend/app/services/plate_detection.py). Centralising the default on the backend means any current or future caller automatically gets the right camera without having to remember the flag. The frontend client now only forwards `use_external` when the caller explicitly sets it (default omitted → backend decides), so the existing UI buttons immediately benefit. Power-user override path stays open: passing `?use_external=false` on a printer with an external camera still wins, so anyone who deliberately wants a built-in-camera reference can still get one. **Regression tests** in [`backend/tests/integration/test_camera_api.py`](backend/tests/integration/test_camera_api.py): `test_check_plate_defaults_use_external_when_external_camera_enabled` and `test_calibrate_plate_defaults_use_external_when_external_camera_enabled` pin the new default for a printer with external camera + URL + type set; `test_check_plate_defaults_use_external_false_when_external_camera_disabled` pins the built-in default for the no-external-camera case (the common path stays untouched); `test_calibrate_plate_explicit_use_external_false_overrides_default` pins the explicit-override escape hatch. All 11 plate-tagged camera integration tests pass; ruff clean; frontend build clean.
- **API Keys page now exposes a narrowly-scoped "Update electricity price" toggle so the Home Assistant dynamic-tariff integration actually works** ([#1356](https://github.com/maziggy/bambuddy/issues/1356), reported by @maziggy) — The reporter followed the [Energy Tracking wiki](https://wiki.bambuddy.cool/features/api-keys/) page literally — "create a key with **Write Settings** permission, then PATCH `/api/v1/settings` with `{energy_cost_per_kwh: ...}`" — and hit `{"detail":"API keys cannot be used for administrative operations"}`. Triage showed three independent drifts: (1) the wiki listed nine fictional permissions ("Read Printers / Write Settings / Admin / …") but the actual UI in [`SettingsPage.tsx:3683-3744`](frontend/src/pages/SettingsPage.tsx) only ever exposed **four** toggles (Read Status, Manage Queue, Control Printer, Allow Cloud Access). There was no Write Settings toggle to tick. (2) Even if the UI had exposed it, the backend hard-denies `Permission.SETTINGS_UPDATE` for every API key via `_APIKEY_DENIED_PERMISSIONS` in [`backend/app/core/auth.py`](backend/app/core/auth.py) — intentional protection because `PATCH /settings` can rewrite SMTP/LDAP/MQTT credentials and the HA access token, which would silently widen attack surface beyond what any documented use case needs. (3) So the wiki had been promising a workflow that was never deliverable. **Fix:** introduce a narrowly-scoped door for exactly the documented use case rather than relaxing the deny list. New column `can_update_energy_cost BOOLEAN DEFAULT FALSE` on `api_keys` ([`backend/app/models/api_key.py`](backend/app/models/api_key.py)) with idempotent migration in [`backend/app/core/database.py`](backend/app/core/database.py) — defaults FALSE so existing keys never silently gain settings-write capability on upgrade. New endpoint `POST /api/v1/settings/electricity-price` in [`backend/app/api/routes/settings.py`](backend/app/api/routes/settings.py) accepts `{"energy_cost_per_kwh": <float ≥ 0>}` — the field name matches what the wiki already documented so the HA `rest_command` example needs only a URL+method change, not a payload change. New custom dependency `require_energy_cost_update()` in [`backend/app/core/auth.py`](backend/app/core/auth.py) bypasses the `_APIKEY_DENIED_PERMISSIONS` check **for this one route** for API keys with `can_update_energy_cost=True`; JWT users still go through the standard `SETTINGS_UPDATE` permission check; auth-disabled deployments allow it (matches other settings routes). Crucially, the general `PATCH /settings` route remains denied for API keys — flipping the narrow flag does NOT widen general settings-write access (regression test pins this). Schema/route wiring in [`backend/app/schemas/api_key.py`](backend/app/schemas/api_key.py) + [`backend/app/api/routes/api_keys.py`](backend/app/api/routes/api_keys.py) accepts and returns the new field on create/update/list. **Frontend:** fifth toggle "Update electricity price" added to the create-API-key card in [`SettingsPage.tsx`](frontend/src/pages/SettingsPage.tsx) with an amber "Energy" badge on existing keys that have it set; `APIKey` / `APIKeyCreate` / `APIKeyUpdate` types in [`api/client.ts`](frontend/src/api/client.ts) gained the new field; 16 new i18n keys (`updateEnergyCost`, `updateEnergyCostDescription`, `energyCostBadge`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Wiki rewrites:** [`features/api-keys.md`](https://wiki.bambuddy.cool/features/api-keys/) — replaced the fictional 9-row permissions table with the actual 5 toggles plus an info box explaining why no general Write Settings / Admin exists. [`features/energy.md`](https://wiki.bambuddy.cool/features/energy/) — Home Assistant section now points at `POST /api/v1/settings/electricity-price`, instructs users to tick the new permission, and adds a deprecation warning for users who built the integration from the old (broken) `PATCH /settings` example. **Tests:** [`backend/tests/integration/test_settings_electricity_price.py`](backend/tests/integration/test_settings_electricity_price.py) — 8 tests covering create-with-flag, default-off, API-key-with-flag updates persist, API-key-without-flag → 403, JWT admin user with SETTINGS_UPDATE allowed, anon → 401, negative price → 422 (Pydantic `ge=0`), and the critical regression test `test_patch_settings_still_denied_with_energy_flag` that pins the narrow-flag-doesn't-widen-PATCH contract. [`frontend/src/__tests__/pages/SettingsPage.test.tsx`](frontend/src/__tests__/pages/SettingsPage.test.tsx) — 2 new tests: Energy badge renders for keys with the flag, the toggle's value flows through to the POST body when the box is ticked. All 8 new backend tests + 32/32 SettingsPage tests pass; ruff clean; i18n parity passes; frontend build clean.
+40 -13
View File
@@ -1316,15 +1316,29 @@ async def rescan_archive(
if metadata.get("designer"):
archive.designer = metadata["designer"]
# Calculate cost: prefer spool-based cost if available, else catalog-based
# Calculate cost: prefer spool-based cost if available, else catalog-based.
# When spool-based costs exist but don't cover every filament gram used
# (#1344), fall back to the global default rate for the untracked weight
# so the displayed cost still reflects the whole print.
if archive.filament_used_grams and archive.filament_type:
default_cost_setting = await get_setting(db, "default_filament_cost")
default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
usage_result = await db.execute(
select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.archive_id == archive.id)
select(
func.sum(SpoolUsageHistory.cost),
func.sum(SpoolUsageHistory.weight_used),
).where(SpoolUsageHistory.archive_id == archive.id)
)
usage_cost = usage_result.scalar()
usage_cost_row = usage_result.one()
usage_cost = usage_cost_row[0]
tracked_grams = float(usage_cost_row[1] or 0)
if usage_cost is not None and usage_cost > 0:
archive.cost = float(Decimal(str(usage_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
total_cost = float(usage_cost)
untracked_grams = max(0.0, archive.filament_used_grams - tracked_grams)
if untracked_grams > 0 and default_cost_per_kg > 0:
total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
archive.cost = float(Decimal(str(total_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
else:
primary_type = archive.filament_type.split(",")[0].strip()
filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
@@ -1336,9 +1350,6 @@ async def rescan_archive(
)
)
else:
# Use default filament cost from settings
default_cost_setting = await get_setting(db, "default_filament_cost")
default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
archive.cost = float(
Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
@@ -1370,18 +1381,34 @@ async def recalculate_all_costs(
default_cost_setting = await get_setting(db, "default_filament_cost")
default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
# Pre-fetch all usage costs by archive_id
# Pre-fetch all usage costs and tracked weight by archive_id.
# Tracked weight is used to top-up the cost at the default rate for any
# filament grams not covered by an inventory spool (#1344).
usage_costs_result = await db.execute(
select(SpoolUsageHistory.archive_id, func.sum(SpoolUsageHistory.cost)).group_by(SpoolUsageHistory.archive_id)
select(
SpoolUsageHistory.archive_id,
func.sum(SpoolUsageHistory.cost),
func.sum(SpoolUsageHistory.weight_used),
).group_by(SpoolUsageHistory.archive_id)
)
usage_costs = usage_costs_result.fetchall()
cost_map = {row[0]: row[1] for row in usage_costs if row[0] is not None and row[1] is not None and row[1] > 0}
cost_map = {
row[0]: (row[1], float(row[2] or 0))
for row in usage_costs
if row[0] is not None and row[1] is not None and row[1] > 0
}
updated = 0
for archive in archives:
usage_cost = cost_map.get(archive.id)
if usage_cost is not None:
new_cost = round(usage_cost, 2)
usage = cost_map.get(archive.id)
if usage is not None:
usage_cost, tracked_grams = usage
total_cost = float(usage_cost)
archive_grams = float(archive.filament_used_grams or 0)
untracked_grams = max(0.0, archive_grams - tracked_grams)
if untracked_grams > 0 and default_cost_per_kg > 0:
total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
new_cost = round(total_cost, 2)
else:
# Fallback: sum costs for old records by print_name
usage_result = await db.execute(
+13
View File
@@ -606,6 +606,14 @@ async def on_print_complete(
await db.commit()
# --- Update PrintArchive.cost from THIS print session only ---
#
# Cover any filament weight that wasn't tracked by an inventory spool with
# the global default rate (#1344). Without this, a multi-color print where
# only some AMS trays are mapped to inventory spools would record only the
# mapped slots' share — e.g. $0.01 for a 110g print when 3 of 4 trays had
# no spool record. The initial cost set by archive.py (total grams *
# primary cost_per_kg) is fine on its own, but this block overwrites it,
# so the overwrite must reconstruct the whole-print cost.
if archive_id and results:
from sqlalchemy import select
@@ -616,6 +624,11 @@ async def on_print_complete(
archive = archive_result.scalar_one_or_none()
if archive:
total_cost = sum(r.get("cost", 0) or 0 for r in results)
tracked_grams = sum(r.get("weight_used", 0) or 0 for r in results)
archive_grams = archive.filament_used_grams or 0
untracked_grams = max(0.0, archive_grams - tracked_grams)
if untracked_grams > 0 and default_filament_cost > 0:
total_cost += (untracked_grams / 1000.0) * default_filament_cost
if total_cost > 0:
archive.cost = round(total_cost, 2)
await db.commit()
@@ -350,12 +350,16 @@ class TestCostCalculationScenarios:
await db_session.refresh(spool_new)
await db_session.refresh(spool_old)
# Create archive with new SpoolUsageHistory (archive_id set)
# Create archive with new SpoolUsageHistory (archive_id set).
# filament_used_grams matches the tracked weight so the #1344 default-
# rate top-up for untracked filament doesn't apply -- this test pins
# the query routing, not the top-up branch.
archive_new = await archive_factory(
printer.id,
print_name="UniquePrint",
status="completed",
cost=None,
filament_used_grams=20.0,
)
history_new = SpoolUsageHistory(
@@ -707,6 +707,9 @@ class TestSpoolAssignmentSnapshot:
spool = _make_spool(id=8, label_weight=1000, weight_used=50)
archive = MagicMock()
archive.file_path = "archives/big_print.3mf"
# Explicit numeric so the #1344 top-up branch doesn't trip a
# MagicMock-vs-float comparison.
archive.filament_used_grams = 14.2
# Session was created at print start WITH snapshot
_active_sessions[1] = PrintSession(
@@ -736,9 +739,8 @@ class TestSpoolAssignmentSnapshot:
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
# Cost aggregation: sum query (uses .scalar()), archive lookup
MagicMock(scalar=MagicMock(return_value=0)),
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
# Cost-update block re-selects the archive to mutate cost.
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
]
)
+170
View File
@@ -75,6 +75,10 @@ def _make_archive(archive_id=1, file_path=None):
archive = MagicMock()
archive.id = archive_id
archive.file_path = file_path
# Explicit numeric default so the #1344 top-up logic (archive_grams -
# tracked_grams) doesn't compare a MagicMock to a float. Tests that
# exercise the top-up path overwrite this with a real number.
archive.filament_used_grams = 0
return archive
@@ -689,6 +693,172 @@ class TestCostAggregation:
# Archive cost should have been updated
assert archive.cost == expected_cost
@pytest.mark.asyncio
async def test_archive_cost_includes_untracked_filament_at_default_rate(self):
"""#1344: when only some AMS trays have inventory spools, the untracked
filament weight is charged at the global default rate so the total
archive cost still reflects the whole print."""
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=10.0)
assignment = _make_assignment(spool_id=1)
archive = _make_archive(archive_id=10)
archive.cost = None
archive.print_name = "TestPrint"
archive.printer_id = 1
archive.filament_used_grams = 110.0 # whole-print weight from slicer
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="TestPrint",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
tray_now_at_start=0,
)
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
progress=100,
layer_num=50,
tray_now=0,
)
responses = [
("scalar_one_or_none", archive),
("scalar_one_or_none", None), # queue item
("scalar_one_or_none", assignment),
("scalar_one_or_none", spool),
("scalar_one_or_none", archive), # cost-update select
]
db = AsyncMock()
call_count = [0]
async def mock_execute(*args, **kwargs):
idx = call_count[0]
call_count[0] += 1
result = MagicMock()
if idx < len(responses):
_, value = responses[idx]
result.scalar.return_value = value
result.scalar_one_or_none.return_value = value
else:
result.scalar_one_or_none.return_value = None
result.scalar.return_value = None
return result
db.execute = mock_execute
# 3MF reports a single slot using 10g, but archive.filament_used_grams
# says the whole print was 110g -- the other 100g came from spools that
# aren't in inventory.
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.api.routes.settings.get_setting", return_value="10.0"),
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_settings.base_dir = MagicMock()
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=printer_manager,
db=db,
archive_id=10,
)
# Tracked slot: 10g * $10/kg = $0.10
assert len(results) == 1
assert results[0]["cost"] == 0.10
# Untracked: 110g - 10g = 100g at $10/kg default = $1.00
# Archive total: $0.10 + $1.00 = $1.10 (was $0.01 pre-fix because only
# the tracked slot's tiny share was kept)
assert archive.cost == 1.10
@pytest.mark.asyncio
async def test_archive_cost_fully_tracked_unchanged_by_topup(self):
"""When every gram is covered by inventory spools, the default-rate
top-up adds nothing -- the archive cost is just the sum of tracked
costs, same as before #1344."""
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
assignment = _make_assignment(spool_id=1)
archive = _make_archive(archive_id=10)
archive.cost = None
archive.print_name = "TestPrint"
archive.printer_id = 1
archive.filament_used_grams = 20.0 # exactly what the slot reports
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="TestPrint",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
tray_now_at_start=0,
)
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
progress=100,
layer_num=50,
tray_now=0,
)
responses = [
("scalar_one_or_none", archive),
("scalar_one_or_none", None),
("scalar_one_or_none", assignment),
("scalar_one_or_none", spool),
("scalar_one_or_none", archive),
]
db = AsyncMock()
call_count = [0]
async def mock_execute(*args, **kwargs):
idx = call_count[0]
call_count[0] += 1
result = MagicMock()
if idx < len(responses):
_, value = responses[idx]
result.scalar.return_value = value
result.scalar_one_or_none.return_value = value
else:
result.scalar_one_or_none.return_value = None
result.scalar.return_value = None
return result
db.execute = mock_execute
filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_settings.base_dir = MagicMock()
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await on_print_complete(
printer_id=1,
data={"status": "completed"},
printer_manager=printer_manager,
db=db,
archive_id=10,
)
# 20g at $25/kg = $0.50 -- no top-up because tracked >= archive grams
assert len(results) == 1
assert results[0]["cost"] == 0.50
assert archive.cost == 0.50
@pytest.mark.asyncio
async def test_cost_with_archive_id(self):
"""Test cost aggregation using archive_id (3MF path)."""