فهرست منبع

fix(spoolman): Cost/kg in the inventory and on the SpoolBuddy spool card shows the rate prints are charged, falling back to the filament price when the spool has none (or 0)

maziggy 2 روز پیش
والد
کامیت
2e30470dfb

+ 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 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:
   - **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read.
   - **The Sync buttons in Spoolman settings** changed slots without telling other open pages, and without updating the slot's filament preset, so the printer card could keep the previous spool's preset name. They now do both, as the automatic sync does.

+ 14 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -59,6 +59,10 @@ class MappedSpoolFields(TypedDict):
     created_at: str | None  # None when Spoolman spool has no registered timestamp
     updated_at: str | None
     cost_per_kg: float | None
+    # What prints are charged per kg: the spool's own price, else its
+    # filament's catalogue price (SM-C8). Display only; the form edits
+    # cost_per_kg, so the catalogue price is never written onto the spool.
+    effective_cost_per_kg: float | None
     # Spoolman's native filament.article_number, surfaced as the internal
     # material number (#2870). Read-only in Spoolman mode — the number is
     # filament-level there and maintained in Spoolman itself.
@@ -240,6 +244,15 @@ def spoolman_cost_per_kg(spool: dict) -> float | None:
     return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
 
 
+def _charged_cost_per_kg(spool: dict) -> float | None:
+    """The per-kg rate a print off this spool is charged at, from the same
+    function the charge uses, so the Inventory shows what prints cost."""
+    from backend.app.services.spoolman_tracking import _spool_cost_per_gram
+
+    per_gram = _spool_cost_per_gram(spool)
+    return round(per_gram * 1000.0, 4) if per_gram is not None else None
+
+
 def _extract_extra_str(extra: dict, key: str) -> str:
     """Extract a JSON-encoded string from a Spoolman extra dict.
 
@@ -539,6 +552,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "updated_at": created_at,
         # Spoolman's spool price is what the whole spool cost (#3194).
         "cost_per_kg": spoolman_cost_per_kg(spool),
+        "effective_cost_per_kg": _charged_cost_per_kg(spool),
         # Spoolman's filament.article_number maps 1:1 onto the internal
         # material number (#2870): both identify the purchasable product.
         # Trimmed for the same reason the schema validator trims the internal

+ 15 - 0
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -192,6 +192,21 @@ class TestMapSpoolmanSpool:
         result = _map_spoolman_spool(spool)
         assert result["cost_per_kg"] is None
 
+    @pytest.mark.parametrize(
+        ("spool_price", "filament_price", "own", "charged"),
+        [(None, 24.0, None, 24.0), (30.0, 24.0, 30.0, 30.0), (None, None, None, None)],
+        ids=["filament-only", "spool-wins", "unpriced"],
+    )
+    def test_the_rate_prints_are_charged_at(self, spool_price, filament_price, own, charged):
+        """SM-C8: with only a filament price, Cost/kg read "-" although every
+        print was charged at it. cost_per_kg stays the spool's own, which the
+        edit form writes back."""
+        filament = {**MINIMAL_SPOOL["filament"], "weight": 1000, "price": filament_price}
+        result = _map_spoolman_spool({**MINIMAL_SPOOL, "price": spool_price, "filament": filament})
+
+        assert result["cost_per_kg"] == (pytest.approx(own) if own is not None else None)
+        assert result["effective_cost_per_kg"] == (pytest.approx(charged) if charged is not None else None)
+
     def test_infinity_weight_falls_back(self):
         spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}}
         result = _map_spoolman_spool(spool)

+ 18 - 0
frontend/src/__tests__/components/InventorySpoolInfoCard.test.tsx

@@ -119,4 +119,22 @@ describe('InventorySpoolInfoCard', () => {
     fireEvent.click(screen.getByText(/unassign/i));
     expect(onUnassign).toHaveBeenCalledTimes(1);
   });
+
+  // SM-C8: the SpoolBuddy card hid Cost/kg for a Spoolman spool priced only by
+  // its filament, which prints off it are charged.
+  it.each([
+    [{ cost_per_kg: null, effective_cost_per_kg: 24 }, '24.00/kg'],
+    [{ cost_per_kg: 0, effective_cost_per_kg: 24 }, '24.00/kg'],
+    [{ cost_per_kg: 30, effective_cost_per_kg: 30 }, '30.00/kg'],
+  ])('shows the rate prints are charged (%o)', (prices, shown) => {
+    render(
+      <InventorySpoolInfoCard
+        spool={{ ...mockSpool, ...prices }}
+        liveScaleWeight={null}
+        onAssignToAms={vi.fn()}
+        isAssigned={false}
+      />
+    );
+    expect(screen.getByText(shown)).toBeInTheDocument();
+  });
 });

+ 58 - 1
frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx

@@ -16,7 +16,7 @@
  *      the user can zero an archived spool's tracking counter without having
  *      to un-archive it first.
  */
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -244,3 +244,60 @@ describe('InventoryPage — resetting all counters says what failed', () => {
     expect(await screen.findByText('Counter reset for 2 spool(s)')).toBeInTheDocument();
   });
 });
+
+// SM-C8: a Spoolman spool priced only by its filament showed "-" in Cost/kg,
+// although every print off it was charged that price.
+describe('InventoryPage — Cost/kg shows what prints are charged', () => {
+  const show = async (spool: Record<string, unknown>) => {
+    vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
+      key === 'bambuddy-inventory-columns'
+        ? JSON.stringify([
+            { id: 'brand', label: 'Brand', visible: true },
+            { id: 'cost_per_kg', label: 'Cost/kg', visible: true },
+          ])
+        : null,
+    );
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json([{ ...mockSpools[0], ...spool }])),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
+    );
+    render(<InventoryPageRouter />);
+    await screen.findAllByText('Polymaker');
+  };
+
+  it("falls back to the filament's price, marked as such", async () => {
+    await show({ cost_per_kg: null, effective_cost_per_kg: 24 });
+
+    expect(await screen.findByText('$24.00')).toBeInTheDocument();
+    expect(screen.getByText('(filament price)')).toBeInTheDocument();
+  });
+
+  it('falls back past a spool price of 0, as the charge does', async () => {
+    // Spoolman imports often write 0 for "no price"; prints then pay the filament's.
+    await show({ cost_per_kg: 0, effective_cost_per_kg: 24 });
+
+    expect(await screen.findByText('$24.00')).toBeInTheDocument();
+    expect(screen.getByText('(filament price)')).toBeInTheDocument();
+  });
+
+  it('keeps a built-in spool priced 0 at $0.00', async () => {
+    await show({ cost_per_kg: 0 });
+
+    expect(await screen.findByText('$0.00')).toBeInTheDocument();
+  });
+
+  it("shows the spool's own price unmarked", async () => {
+    await show({ cost_per_kg: 30, effective_cost_per_kg: 30 });
+
+    expect(await screen.findByText('$30.00')).toBeInTheDocument();
+    expect(screen.queryByText('(filament price)')).toBeNull();
+  });
+
+  it('shows "-" for an unpriced spool', async () => {
+    await show({ cost_per_kg: null, effective_cost_per_kg: null });
+
+    await waitFor(() => expect(screen.queryByText(/^\$\d/)).toBeNull());
+  });
+});

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

@@ -3906,6 +3906,9 @@ export interface InventorySpool {
   created_at: string;
   updated_at: string;
   cost_per_kg: number | null;
+  // Spoolman mode: what prints are charged per kg, the spool's own price or
+  // else its filament's. Display only; the form edits cost_per_kg.
+  effective_cost_per_kg?: number | null;
   last_scale_weight: number | null;
   last_weighed_at: string | null;
   // User-defined category + per-spool low-stock threshold override (#729).

+ 5 - 2
frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx

@@ -6,6 +6,7 @@ import type { InventorySpool } from '../../api/client';
 import { spoolbuddyApi, api } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
 import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
+import { shownCostPerKg } from '../../utils/spoolCost';
 
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
 
@@ -112,6 +113,8 @@ export function InventorySpoolInfoCard({
   // Inventory fallback so gross is always populated across spools.
   const inventoryDerivedGrossWeight = Math.round(calculatedWeight);
   const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight;
+  // The rate prints off this spool are charged (SM-C8).
+  const costPerKg = shownCostPerKg(spool);
   const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null)
     ? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C`
     : null;
@@ -233,10 +236,10 @@ export function InventorySpoolInfoCard({
             <span className="font-mono text-zinc-300">{nozzleTempRange}</span>
           </div>
         )}
-        {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
+        {costPerKg.rate != null && costPerKg.rate > 0 && (
           <div className="flex justify-between items-center">
             <span className="text-zinc-500">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>
-            <span className="font-mono text-zinc-300">{spool.cost_per_kg.toFixed(2)}/kg</span>
+            <span className="font-mono text-zinc-300">{costPerKg.rate.toFixed(2)}/kg</span>
           </div>
         )}
         {kFactorSummary && (

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -5157,6 +5157,7 @@ export default {
     measuredWeight: 'Gemessenes Gewicht',
     spoolName: 'Spule',
     costPerKg: 'Kosten pro kg',
+    costFromFilament: '(Filamentpreis)',
     storageLocation: 'Lagerstandort',
     temperature: 'Temperatur',
     humidity: 'Luftfeuchtigkeit',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -5195,6 +5195,7 @@ export default {
     measuredWeight: 'Measured Weight',
     spoolName: 'Spool',
     costPerKg: 'Cost per kg',
+    costFromFilament: '(filament price)',
     storageLocation: 'Storage Location',
     temperature: 'Temperature',
     humidity: 'Humidity',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -5159,6 +5159,7 @@ export default {
     measuredWeight: 'Peso medido',
     spoolName: 'Bobina',
     costPerKg: 'Coste por kg',
+    costFromFilament: '(precio del filamento)',
     storageLocation: 'Ubicación de almacenamiento',
     temperature: 'Temperatura',
     humidity: 'Humedad',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -5145,6 +5145,7 @@ export default {
     measuredWeight: 'Poids mesuré',
     spoolName: 'Bobine',
     costPerKg: 'Coût par kg',
+    costFromFilament: '(prix du filament)',
     storageLocation: 'Emplacement de stockage',
     temperature: 'Température',
     humidity: 'Humidité',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -5144,6 +5144,7 @@ export default {
     measuredWeight: 'Peso Misurato',
     spoolName: 'Bobina',
     costPerKg: 'Costo per kg',
+    costFromFilament: '(prezzo del filamento)',
     storageLocation: 'Posizione di archiviazione',
     temperature: 'Temperatura',
     humidity: 'Umidità',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -5157,6 +5157,7 @@ export default {
     measuredWeight: '計測重量',
     spoolName: 'スプール',
     costPerKg: 'kgあたりのコスト',
+    costFromFilament: '(フィラメント価格)',
     storageLocation: '保管場所',
     temperature: '温度',
     humidity: '湿度',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4945,6 +4945,7 @@ export default {
     measuredWeight: '측정된 무게',
     spoolName: '스풀',
     costPerKg: 'kg당 비용',
+    costFromFilament: '(필라멘트 가격)',
     storageLocation: '보관 위치',
     temperature: '온도',
     humidity: '습도',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -5193,6 +5193,7 @@ export default {
     measuredWeight: 'Gemeten gewicht',
     spoolName: 'Spoel',
     costPerKg: 'Kosten per kg',
+    costFromFilament: '(filamentprijs)',
     storageLocation: 'Opslaglocatie',
     temperature: 'Temperatuur',
     humidity: 'Luchtvochtigheid',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5144,6 +5144,7 @@ export default {
     measuredWeight: 'Peso Medido',
     spoolName: 'Bobina',
     costPerKg: 'Custo por kg',
+    costFromFilament: '(preço do filamento)',
     storageLocation: 'Local de armazenamento',
     temperature: 'Temperatura',
     humidity: 'Umidade',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4935,6 +4935,7 @@ export default {
     measuredWeight: "Измеренная масса",
     spoolName: "Катушка",
     costPerKg: "Стоимость за кг",
+    costFromFilament: "(цена филамента)",
     storageLocation: "Место хранения",
     temperature: "Температура",
     humidity: "Влажность",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -5192,6 +5192,7 @@ errors: {
     measuredWeight: 'Uppmätt vikt',
     spoolName: 'Rulle',
     costPerKg: 'Kostnad per kg',
+    costFromFilament: '(filamentpris)',
     storageLocation: 'Lagringsplats',
     temperature: 'Temperatur',
     humidity: 'Luftfuktighet',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -5146,6 +5146,7 @@ export default {
     measuredWeight: 'Ölçülen Ağırlık',
     spoolName: 'Makara',
     costPerKg: 'kg başına maliyet',
+    costFromFilament: '(filament fiyatı)',
     storageLocation: 'Depolama Konumu',
     temperature: 'Sıcaklık',
     humidity: 'Nem',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -5189,6 +5189,7 @@ export default {
     measuredWeight: "Виміряна вага",
     spoolName: "Котушка",
     costPerKg: "Вартість за кг",
+    costFromFilament: "(ціна філаменту)",
     storageLocation: "Місце зберігання",
     temperature: "Температура",
     humidity: "Вологість",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5144,6 +5144,7 @@ export default {
     measuredWeight: '称量重量',
     spoolName: '线轴',
     costPerKg: '每公斤成本',
+    costFromFilament: '(耗材价格)',
     storageLocation: '存放位置',
     temperature: '温度',
     humidity: '湿度',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5144,6 +5144,7 @@ export default {
     measuredWeight: '稱量重量',
     spoolName: '料盤',
     costPerKg: '每公斤成本',
+    costFromFilament: '(耗材價格)',
     storageLocation: '存放位置',
     temperature: '溫度',
     humidity: '濕度',

+ 13 - 6
frontend/src/pages/InventoryPage.tsx

@@ -29,6 +29,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { colorSortKey, resolveSpoolColorName } from '../utils/colors';
 import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { getCurrencySymbol } from '../utils/currency';
+import { shownCostPerKg } from '../utils/spoolCost';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatSlotLabel } from '../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
@@ -487,11 +488,17 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     const entry = spool.core_weight_catalog_id != null ? catalogMap[spool.core_weight_catalog_id] : undefined;
     return <span className="text-sm text-bambu-gray">{entry?.name || '-'}</span>;
   },
-  cost_per_kg: ({ spool, currencySymbol }) => (
-    <span className="text-sm text-bambu-gray">
-      {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}
-    </span>
-  ),
+  cost_per_kg: ({ spool, currencySymbol, t }) => {
+    // A Spoolman spool without a price of its own is charged its filament's,
+    // shown here and marked as such (SM-C8).
+    const { rate, fromFilament } = shownCostPerKg(spool);
+    return (
+      <span className="text-sm text-bambu-gray">
+        {rate != null ? `${currencySymbol}${rate.toFixed(2)}` : '-'}
+        {fromFilament && <span className="ml-1 text-xs opacity-70">{t('inventory.costFromFilament')}</span>}
+      </span>
+    );
+  },
   weight_check: ({ spool, onSyncWeight }) => {
     const scaleWeight = spool.last_scale_weight;
     if (scaleWeight == null) return <span className="text-sm text-bambu-gray/50" title="No scale measurement">-</span>;
@@ -597,7 +604,7 @@ const columnSortValues: Record<
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   stock: (s) => s.slicer_filament ? 1 : 0,
   spool_name: (s) => s.core_weight_catalog_id ?? 0,
-  cost_per_kg: (s) => s.cost_per_kg ?? 0,
+  cost_per_kg: (s) => shownCostPerKg(s).rate ?? 0,
   weight_check: (s) => {
     if (s.last_scale_weight == null) return -1;
     const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;

+ 18 - 0
frontend/src/utils/spoolCost.ts

@@ -0,0 +1,18 @@
+import type { InventorySpool } from '../api/client';
+
+/**
+ * The cost per kg to show for a spool: the rate prints off it are charged
+ * (SM-C8). A Spoolman spool's own price wins when it is above 0; otherwise
+ * prints pay its filament's catalogue price (``effective_cost_per_kg``), as
+ * the charge treats a missing or 0 spool price as "not set". Without one, the
+ * spool's own value stands, so a built-in spool priced 0 still shows 0.
+ */
+export function shownCostPerKg(
+  spool: Pick<InventorySpool, 'cost_per_kg' | 'effective_cost_per_kg'>,
+): { rate: number | null; fromFilament: boolean } {
+  const own = spool.cost_per_kg;
+  if (own != null && own > 0) return { rate: own, fromFilament: false };
+  const charged = spool.effective_cost_per_kg;
+  if (charged != null) return { rate: charged, fromFilament: true };
+  return { rate: own ?? null, fromFilament: false };
+}