Bläddra i källkod

fix(spoolman): the print dialog's cost estimate and each slot's grams left come from the spool bound to the slot, priced the way the print is charged, instead of the built-in assignments; they refresh when a slot's assignment changes

maziggy 2 dagar sedan
förälder
incheckning
5649f836da

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman: the print dialog's cost estimate uses the Spoolman spool's price** — In Spoolman mode, the print dialog estimated each slot from the built-in inventory's assignments, which hold nothing in that mode or rows left from before the switch to Spoolman. The estimate (and the budget check that uses it) came out at the default price per kg or at a spool no longer in the slot, and the "g left" shown for each slot was wrong the same way. Both now come from the spool bound to the slot, priced the way the print is charged: the spool's own price, else its filament's, else the default.
 - **Spoolman: a print whose 3MF arrived late is charged from the 3MF** — A print that started before Bambuddy could fetch its 3MF (an "Untitled" print, a printer briefly unreachable) gets an empty archive that is filled in once the 3MF turns up. In Spoolman mode its filament was still charged from the AMS remaining-percentage change — in steps of 10 g on a 1 kg spool, nothing for a spool without a percentage, and nothing at all when no slot reported one at the start. The recovered 3MF's usage is now used, as the built-in inventory already did.
 - **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it.
 - **Spoolman AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases:

+ 12 - 0
backend/app/services/filament_deficit.py

@@ -440,6 +440,10 @@ class SlotMaterial:
     # Display-only; see SlotSpoolIdentity. None when the binding resolves to a
     # spool we cannot describe, which callers render from telemetry as before.
     spool: SlotSpoolIdentity | None = None
+    # Per-kg rate a print off this spool is charged at; None when unpriced,
+    # which the charge prices at the default rate. Display-only: the print
+    # dialog's estimate, which priced Spoolman slots from the built-in table.
+    cost_per_kg: float | None = None
 
     def to_dict(self) -> dict:
         return {
@@ -450,6 +454,7 @@ class SlotMaterial:
             "remaining_g": self.remaining_grams,
             "extruder": self.extruder,
             "spool": self.spool.to_dict() if self.spool else None,
+            "cost_per_kg": self.cost_per_kg,
         }
 
 
@@ -477,6 +482,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
         material_key: str,
         remaining: float,
         spool: SlotSpoolIdentity | None = None,
+        cost_per_kg: float | None = None,
     ) -> None:
         materials.append(
             SlotMaterial(
@@ -487,10 +493,13 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                 remaining_grams=remaining,
                 extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
                 spool=spool,
+                cost_per_kg=cost_per_kg,
             )
         )
 
     if await _is_spoolman_mode(db):
+        from backend.app.services.spoolman_tracking import _spool_cost_per_gram
+
         sm_all = await db.execute(select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
         from backend.app.services.spoolman import (
             SpoolmanClientError,
@@ -525,12 +534,14 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                     remaining = max(0.0, float(total) - float(used))
             if remaining is None:
                 continue
+            per_gram = _spool_cost_per_gram(spool_dict)
             _append(
                 sa.ams_id,
                 sa.tray_id,
                 _material_identity_spoolman(spool_dict),
                 remaining,
                 _identity_from_spoolman(spool_dict),
+                per_gram * 1000.0 if per_gram is not None else None,
             )
         return materials
 
@@ -553,6 +564,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
             _material_identity_internal(spool),
             max(0.0, label_weight - weight_used),
             _identity_from_internal(spool),
+            spool.cost_per_kg,
         )
     return materials
 

+ 78 - 0
backend/tests/unit/services/test_slot_materials_cost.py

@@ -0,0 +1,78 @@
+"""The print dialog's estimate prices each slot at the rate its print is charged.
+
+The dialog priced slots from the built-in assignment table in both inventory
+modes. In Spoolman mode that table holds nothing, or rows from before the
+switch, so the estimate used the default rate or a spool the printer is not
+drawing on, while the print was charged the Spoolman spool's price. The
+mode-aware slot payload now carries each slot's rate.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.filament_deficit import build_slot_materials
+from backend.tests.unit.services import test_filament_deficit as _deficit_tests
+
+pytestmark = pytest.mark.asyncio
+
+
+async def _slots(db_session, printer_id: int, client=None):
+    patches = _deficit_tests.TestFilamentDeficitBackupAware._patch_status(
+        printer_id=printer_id, backup_on=False, model="X1C"
+    )
+    for p in patches:
+        p.start()
+    try:
+        with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+            return {s.global_tray_id: s for s in await build_slot_materials(db_session, printer_id)}
+    finally:
+        for p in patches:
+            p.stop()
+
+
+async def test_spoolman_slots_carry_the_charged_rate(db_session, printer_factory):
+    printer = await printer_factory(model="X1C")
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    for tray_id in range(3):
+        db_session.add(
+            SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray_id, spoolman_spool_id=10 + tray_id)
+        )
+    await db_session.commit()
+    spools = {
+        # The spool's own price, over its own net weight.
+        10: {"id": 10, "remaining_weight": 200.0, "price": 10.0, "initial_weight": 250.0, "filament": {"id": 1}},
+        # No spool price: the filament's, over the filament's weight.
+        11: {"id": 11, "remaining_weight": 900.0, "filament": {"id": 2, "price": 30.0, "weight": 1000.0}},
+        # Unpriced: no rate, so the dialog uses the default as the charge does.
+        12: {"id": 12, "remaining_weight": 500.0, "filament": {"id": 3}},
+    }
+    client = AsyncMock()
+    client.get_spool = AsyncMock(side_effect=lambda sid: spools[sid])
+
+    slots = await _slots(db_session, printer.id, client)
+
+    assert slots[0].cost_per_kg == pytest.approx(40.0)
+    assert slots[1].cost_per_kg == pytest.approx(30.0)
+    assert slots[2].cost_per_kg is None
+    assert slots[0].to_dict()["cost_per_kg"] == pytest.approx(40.0)
+
+
+async def test_built_in_slots_carry_the_spool_s_rate(db_session, printer_factory):
+    printer = await printer_factory(model="X1C")
+    priced = Spool(material="PLA", label_weight=1000, weight_used=0.0, cost_per_kg=22.5)
+    unpriced = Spool(material="PLA", label_weight=1000, weight_used=0.0)
+    db_session.add_all([priced, unpriced])
+    await db_session.commit()
+    for tray_id, spool in enumerate((priced, unpriced)):
+        db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=tray_id))
+    await db_session.commit()
+
+    slots = await _slots(db_session, printer.id)
+
+    assert slots[0].cost_per_kg == 22.5
+    assert slots[1].cost_per_kg is None

+ 2 - 0
backend/tests/unit/test_inventory_remain_endpoint.py

@@ -148,6 +148,8 @@ class TestGetInventoryRemain:
                 # Present even when there is nothing to say, so the client can
                 # branch on the field rather than on its absence.
                 "spool": None,
+                # Unpriced: the dialog's estimate uses the default rate.
+                "cost_per_kg": None,
             }
         ]
 

+ 93 - 0
frontend/src/__tests__/components/FilamentMappingSlotCost.test.tsx

@@ -0,0 +1,93 @@
+/**
+ * The print dialog prices each slot at the rate its print is charged.
+ *
+ * The estimate and the per-slot "g left" came from the built-in assignment
+ * table in both inventory modes. In Spoolman mode that table holds nothing, or
+ * rows from before the switch, so a Spoolman print was estimated at the
+ * default rate or at a spool the printer is not drawing on. Both now come from
+ * the mode-aware slot payload (`/printers/{id}/inventory-remain`).
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
+
+const status = {
+  id: 1,
+  name: 'X1C',
+  connected: true,
+  state: 'IDLE',
+  ams: [{ id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }] }],
+  vt_tray: [],
+  ams_extruder_map: {},
+};
+
+const slot = (cost: number | null) => ({
+  ams_id: 0,
+  tray_id: 0,
+  global_tray_id: 0,
+  material_key: 'k',
+  remaining_g: 312.4,
+  extruder: 0,
+  spool: null,
+  cost_per_kg: cost,
+});
+
+// A row left in the built-in table from before the switch to Spoolman.
+const staleBuiltIn = [
+  { id: 1, spool_id: 9, printer_id: 1, ams_id: 0, tray_id: 0, spool: { cost_per_kg: 99, label_weight: 1000, weight_used: 0 } },
+];
+
+function renderWith(cost: number | null) {
+  const onCost = vi.fn();
+  server.use(
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+    http.get('/api/v1/inventory/assignments', () => HttpResponse.json(staleBuiltIn)),
+    http.get('/api/v1/printers/:id/inventory-remain', () =>
+      HttpResponse.json({ inventory_remain_g: { '0': 312.4 }, slot_materials: [slot(cost)] }),
+    ),
+  );
+  render(
+    <FilamentMapping
+      printerId={1}
+      filamentReqs={{ filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 100, used_meters: 30 }] }}
+      manualMappings={{}}
+      onManualMappingChange={() => {}}
+      currencySymbol="$"
+      defaultCostPerKg={25}
+      onEstimatedCostChange={onCost}
+      defaultExpanded
+    />,
+  );
+  return onCost;
+}
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentMapping slot price', () => {
+  it("estimates at the bound spool's rate, not a stale built-in row's", async () => {
+    const onCost = renderWith(40);
+    await waitFor(() => expect(onCost).toHaveBeenLastCalledWith(4));
+    expect(screen.getByText('$4.00')).toBeInTheDocument();
+  });
+
+  it('estimates an unpriced spool at the default rate', async () => {
+    const onCost = renderWith(null);
+    await waitFor(() => expect(onCost).toHaveBeenLastCalledWith(2.5));
+  });
+
+  it("offers the slot with the bound spool's grams left", async () => {
+    renderWith(40);
+    const trigger = await waitFor(() => screen.getAllByRole('combobox')[0]);
+    fireEvent.click(trigger);
+    const listbox = await waitFor(() => screen.getByRole('listbox'));
+    expect(listbox.textContent).toContain('312g left');
+    expect(listbox.textContent).not.toContain('1000g left');
+  });
+});

+ 3 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -650,6 +650,9 @@ describe('useWebSocket hook', () => {
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-slot-assignments'] });
+      // The print dialog prices each slot and shows its grams from this one,
+      // in both modes; an open dialog kept the previous spool's figures.
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['printer-inventory-remain'] });
 
       vi.useRealTimers();
       vi.unstubAllGlobals();

+ 3 - 0
frontend/src/api/client.ts

@@ -4118,6 +4118,9 @@ export interface SlotMaterial {
    *  third-party spool, so this is the only place a slot's real identity
    *  exists. Null when the binding resolves to a spool we cannot describe. */
   spool?: SlotSpoolIdentity | null;
+  /** Per-kg rate a print off this spool is charged at; null when unpriced,
+   *  which is charged at the default rate. Optional for an older backend. */
+  cost_per_kg?: number | null;
 }
 
 /** Display identity of an inventory-bound slot. See `SlotMaterial.spool`. */

+ 11 - 22
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -5,7 +5,7 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
 import { api } from '../../api/client';
 import type { SlotSpoolIdentity } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
-import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
+import { effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
 import { disambiguateColorNames, getColorName } from '../../utils/colors';
 import { SlotPicker } from './SlotPicker';
 import { displayHex } from './displayHex';
@@ -98,12 +98,6 @@ export function FilamentMapping({
     enabled: !!printerId,
   });
 
-  const { data: assignments } = useQuery({
-    queryKey: ['spool-assignments', printerId],
-    queryFn: () => api.getAssignments(printerId),
-    enabled: !!printerId,
-  });
-
   // Settings + inventory map drive the same prefer-lowest + AMS-backup gate
   // the dispatcher uses (#1766). Without this, the per-slot dropdown's
   // auto-suggestion could disagree with what actually gets dispatched.
@@ -167,30 +161,25 @@ export function FilamentMapping({
   // resolved — never blanks out the required row.
   const filamentLabels = useFilamentLabels(filamentReqs?.filaments);
 
+  // Price and grams left per slot, from the mode-aware bindings above. The
+  // built-in assignment table these came from holds nothing in Spoolman mode,
+  // or rows from before the switch, so the estimate priced a Spoolman print at
+  // the default rate or at a spool the printer is not drawing on.
   const trayCostMap = useMemo(() => {
     const map = new Map<number, number | null>();
-    for (const assignment of assignments || []) {
-      const isExternal = assignment.ams_id === 255;
-      const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
-      map.set(globalTrayId, assignment.spool?.cost_per_kg ?? null);
+    for (const slot of inventoryRemain?.slot_materials ?? []) {
+      map.set(slot.global_tray_id, slot.cost_per_kg ?? null);
     }
     return map;
-  }, [assignments]);
+  }, [inventoryRemain]);
 
   const trayRemainingWeightMap = useMemo(() => {
     const map = new Map<number, number | null>();
-    for (const assignment of assignments || []) {
-      const isExternal = assignment.ams_id === 255;
-      const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);
-      const spool = assignment.spool;
-      if (!spool) {
-        map.set(globalTrayId, null);
-        continue;
-      }
-      map.set(globalTrayId, Math.max(0, Math.round((spool.label_weight ?? 0) - (spool.weight_used ?? 0))));
+    for (const slot of inventoryRemain?.slot_materials ?? []) {
+      map.set(slot.global_tray_id, Math.max(0, Math.round(slot.remaining_g)));
     }
     return map;
-  }, [assignments]);
+  }, [inventoryRemain]);
 
   const totalCost = useMemo(() => {
     let total = 0;

+ 2 - 0
frontend/src/hooks/useWebSocket.ts

@@ -352,6 +352,8 @@ export function useWebSocket() {
     queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
     queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
     queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
+    // Both modes' bindings, which the print dialog prices and weighs slots from.
+    queryClient.invalidateQueries({ queryKey: ['printer-inventory-remain'] });
   }, [queryClient]);
 
   const handleMessage = useCallback((message: WebSocketMessage) => {