Parcourir la source

Forecast: no built-in usage history in Spoolman mode

- The Forecast view read spool_usage_history in every mode. That table
  is the built-in inventory's, keyed by its spool ids, so a Spoolman
  spool whose id matched a built-in spool from before the switch took
  over its prints: 2.5 g/d showed as 46 g/d (Trend)
- In Spoolman mode the history query is off and any cached copy is
  ignored; the rate is the spool's own consumption delta, as the stock
  alerts already compute it
- InventoryPage passes its spoolmanMode to ForecastPanel
maziggy il y a 1 jour
Parent
commit
7049193399

+ 1 - 0
CHANGELOG.md

@@ -199,6 +199,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 stock forecast no longer borrows a built-in spool's usage history (#3406, reported by @ojimpo)** — The Forecast view read Bambuddy's own usage history in every mode. That history belongs to the built-in inventory and is keyed by its spool ids, so after switching to Spoolman, a Spoolman spool whose id matched an old built-in spool took over its prints: a spool using 2.5 g a day could show 46 g a day ("Trend") and run out in days. In Spoolman mode the forecast now works from each spool's own consumption, as the stock alerts already did.
 - **Spoolman: an external-spool print on an X2D or H2D is charged to the holder it ran from (#3405, reported by @juandelarossa)** — A print command that carries no `ams_mapping2` (#3166) writes the external spool as -1. In Spoolman mode that charged the left holder (254) whenever the printer listed it, which the X2D always does, empty or not, and in the daily builds no spool at all, so a spool on the right holder (255) was never charged. The built-in inventory treats such a mapping as no mapping and lets the printer's `tray_now` decide, and Spoolman mode now does the same, for finished and for aborted prints. In both modes `tray_now` 255 now also counts as the right-hand holder ahead of the last tray loaded before the print, which a print from that holder never updates, so the AMS tray or left holder an earlier print used was charged instead.
 - **A printer that does not answer the K-profile read is no longer a printer without K-profiles (#3404, reported by @Spegeli)** — When the printer did not answer any of the three requests, the read returned an empty table, the same as a printer with no profiles for that nozzle. The K-Profiles page then said "No K-Profiles" and offered to create the first one, and the Configure AMS Slot dialog offered only the default K. Worse, a Git restore matched the backed-up profiles against that empty table and sent every one of them as a new profile, next to the originals still on the printer. The read is now an error: the API answers HTTP 504 "The printer did not answer the K-profile request", the K-Profiles page says the profiles could not be read and offers Refresh, and the slot dialog says so above the default. A restore skips that nozzle, counts its profiles failed and says the printer did not answer. The read-back after an edit no longer has to treat an empty table as a possible timeout.
 - **The Slice dialog picks the right material for H2C filaments with OrcaSlicer preferred (#3403, reported by @pleasantone)** — Bambu Cloud lists its filament profiles without their material, which Bambuddy takes from the bundled profile of the same name. The OrcaSlicer bundle has no H2C profiles, so every "@BBL H2C" filament stayed without one, and a PETG-CF slot could be preselected as Generic PETG, also with "Only loaded spools" on. Such a profile now takes the material of the same filament's bundled profiles for other printers ("Generic PETG-CF @BBL X1C" for "Generic PETG-CF @BBL H2C 0.4 nozzle"), when they all agree. An OrcaSlicer Cloud filament saved without a material of its own takes it from the profile it was saved from.

+ 134 - 0
frontend/src/__tests__/components/ForecastPanelSpoolmanHistory.test.tsx

@@ -0,0 +1,134 @@
+/**
+ * In Spoolman mode the forecast reads no usage history (#3406). That history
+ * is the built-in inventory's, keyed by its own spool ids: a Spoolman spool
+ * whose id matches one, left over from before the switch, borrowed that
+ * spool's prints. The rate is the consumption delta there, as in the
+ * backend's stock alerts.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render } from '../utils';
+import { ForecastPanel } from '../../components/ForecastPanel';
+import { api } from '../../api/client';
+import type { InventorySpool, SpoolUsageRecord } from '../../api/client';
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return {
+    ...actual,
+    useAuth: () => ({ hasPermission: () => true, hasAnyPermission: () => true, authEnabled: false, user: null }),
+  };
+});
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getSettings: vi.fn(),
+      getUiSettings: vi.fn(),
+      getAuthStatus: vi.fn(),
+      getSkuSettings: vi.fn(),
+      getAllUsageHistory: vi.fn(),
+      getShoppingList: vi.fn(),
+      addToShoppingList: vi.fn(),
+      removeFromShoppingList: vi.fn(),
+      clearShoppingList: vi.fn(),
+      updateShoppingListStatus: vi.fn(),
+      bulkCreateSpools: vi.fn(),
+      upsertSkuSettings: vi.fn(),
+      updateSettings: vi.fn(),
+    },
+  };
+});
+
+const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+
+const spool = (id: number, over: Partial<InventorySpool> = {}): InventorySpool => ({
+  id,
+  material: 'PLA',
+  subtype: 'Matte',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 750,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: 'manual',
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  weight_locked: false,
+  category: 'Active',
+  low_stock_threshold_pct: null,
+  ...over,
+});
+
+const DAY = 86400000;
+const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
+
+// The reporter's repro: spool 6, registered 40 days ago, 100 g used (2.5 g/d).
+const spoolman6 = spool(6, { label_weight: 1000, weight_used: 100, created_at: ago(40) });
+// Seven prints of the built-in spool 6, from half a year ago.
+const builtInHistory = [190, 187, 184, 181, 178, 175, 172].map(
+  (daysAgo, i) => ({ id: i + 1, spool_id: 6, created_at: ago(daysAgo), weight_used: 120 }) as SpoolUsageRecord,
+);
+
+describe('ForecastPanel: usage history in Spoolman mode (#3406)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocked.getSettings.mockResolvedValue({ forecast_global_lead_time_days: 0 });
+    mocked.getUiSettings.mockResolvedValue({ forecast_global_lead_time_days: 0 });
+    mocked.getAuthStatus.mockResolvedValue({ auth_enabled: false, requires_setup: false });
+    mocked.getSkuSettings.mockResolvedValue([]);
+    mocked.getAllUsageHistory.mockResolvedValue(builtInHistory);
+    mocked.getShoppingList.mockResolvedValue([]);
+  });
+
+  it("a Spoolman spool's rate is its own consumption, not a built-in spool's history", async () => {
+    render(<ForecastPanel spools={[spoolman6]} spoolmanMode />);
+
+    expect(await screen.findByText('2.5g/d')).toBeInTheDocument();
+    expect(screen.queryByText('Trend')).toBeNull();
+    expect(mocked.getAllUsageHistory).not.toHaveBeenCalled();
+  });
+
+  it('history cached in built-in mode is not used after switching to Spoolman', async () => {
+    // Same query key in both modes: a disabled query still hands out its cache.
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    client.setQueryData(['all-usage-history-forecast'], builtInHistory);
+    render(
+      <QueryClientProvider client={client}>
+        <ForecastPanel spools={[spoolman6]} spoolmanMode />
+      </QueryClientProvider>,
+    );
+
+    expect(await screen.findByText('2.5g/d')).toBeInTheDocument();
+    expect(screen.queryByText('Trend')).toBeNull();
+  });
+
+  it('the built-in inventory still uses its history', async () => {
+    render(<ForecastPanel spools={[spoolman6]} />);
+
+    expect(await screen.findByText('Trend')).toBeInTheDocument();
+    expect(screen.queryByText('2.5g/d')).toBeNull();
+  });
+});

+ 29 - 0
frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx

@@ -294,3 +294,32 @@ describe('InventoryPage - LOCATION column (Spoolman mode)', () => {
     expect(container.textContent).not.toContain('C4');
   });
 });
+
+describe('InventoryPage - Forecast in Spoolman mode (#3406)', () => {
+  it("the forecast does not read the built-in inventory's usage history", async () => {
+    localStorage.clear();
+    let usageHistoryRead = false;
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+      http.get('/api/v1/settings/spoolman', () =>
+        HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' })
+      ),
+      http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])),
+      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
+      http.get('/api/v1/inventory/usage', () => {
+        usageHistoryRead = true;
+        return HttpResponse.json([]);
+      }),
+    );
+
+    const { container } = render(<InventoryPageRouter />);
+    await waitFor(() => expect(container.textContent).toContain('Bambu Lab'));
+    screen.getByRole('button', { name: /forecast/i }).click();
+
+    // The forecast is up, with the rate from the spool's own consumption.
+    expect(await screen.findByText('Est.')).toBeInTheDocument();
+    expect(usageHistoryRead).toBe(false);
+  });
+});

+ 7 - 2
frontend/src/components/ForecastPanel.tsx

@@ -205,7 +205,7 @@ function buildProjectionSeries(
 
 // ── Main component ────────────────────────────────────────────────────────────
 
-export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
+export function ForecastPanel({ spools, spoolmanMode = false }: { spools: InventorySpool[]; spoolmanMode?: boolean }) {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { t } = useTranslation();
@@ -227,7 +227,12 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
 
   const { data: settings } = useUiSettings({ enabled: canRead });
   const { data: skuSettingsList = [] } = useQuery({ queryKey: ['sku-settings'], queryFn: api.getSkuSettings, staleTime: 60_000, enabled: canRead });
-  const { data: usageHistory = [] } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead });
+  // Bambuddy's usage history is the built-in inventory's, keyed by its own
+  // spool ids. In Spoolman mode a Spoolman id that matches one, left over from
+  // before the switch, would borrow that spool's history (#3406): the rate is
+  // the consumption delta there, as in the backend's stock alerts.
+  const { data: fetchedUsageHistory } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead && !spoolmanMode });
+  const usageHistory = useMemo(() => (spoolmanMode ? [] : (fetchedUsageHistory ?? [])), [spoolmanMode, fetchedUsageHistory]);
   const { data: shoppingList = [] } = useQuery({ queryKey: ['shopping-list'], queryFn: api.getShoppingList, staleTime: 30_000, enabled: canRead });
 
   const globalLeadTime = settings?.forecast_global_lead_time_days ?? 0;

+ 1 - 1
frontend/src/pages/InventoryPage.tsx

@@ -2244,7 +2244,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         </div>
       ) : viewMode === 'forecast' ? (
         /* Forecast view */
-        <ForecastPanel spools={spools || []} />
+        <ForecastPanel spools={spools || []} spoolmanMode={spoolmanMode} />
       ) : viewMode === 'cards' ? (
         /* Cards view */
         pagedItems.length > 0 ? (

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchivesPage-DmDTHBbH.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CamWallPage-ZfupDQyn.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraPage-zl3R3HV7.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/FileManagerPage-CNy0Ah3w.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/InventoryPage-BSph10iL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/InventoryPage-Bx7z-agm.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ModelSourcesPage-C74ZDeJ6.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PhotoGalleryModal-43MG2Oko.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PrintersPage-DR9NO5Vt.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SettingsPage-DUr-Q9Ge.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SliceModal-8qa-seiy.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CPl8uENr.js


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
static/assets/SpoolBuddySettingsPage-mRvot8Td.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CEuljnWb.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/locationSensorDefaults-CYZglzLL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/spoolCost-B9nbkPfK.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DNGzxHyh.js"></script>
+    <script type="module" crossorigin src="/assets/index-CEuljnWb.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Wixdakxn.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-9hW1swN1.js">

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff