Просмотр исходного кода

Name a SKU's colour once in the stock forecast (#3400)

- In Spoolman mode a filament with no colour name takes its subtype
  as one, so "PLA Basic" named "Black" read "Bambu Lab PLA Basic
  Black Black" in the forecast list, chart legend, alerts, the
  add-to-list dialog and the shopping list.
- A colour that is the same word as the subtype is left out of the
  name. The SKU key, its settings and shopping list entries stay as
  they are.
maziggy 2 дней назад
Родитель
Сommit
2b76e84a85

+ 1 - 0
CHANGELOG.md

@@ -193,6 +193,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.
 - **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
 ### Fixed
+- **The stock forecast names a SKU's colour once (#3400, reported by @ojimpo)** — In Spoolman mode, a filament with no colour name takes its subtype as one, so a filament named "Black" with material "PLA Basic" read "Bambu Lab PLA Basic Black Black" in the forecast list, the chart legend, the alerts, the add-to-list dialog and the shopping list. A colour that is the same word as the subtype is now left out of the name. The SKU itself, its settings and the shopping list entries are unchanged.
 - **The stock forecast counts used-up spools towards the consumption rate (#3399, reported by @ojimpo)** — Archived spools were left out of the forecast entirely, so once used-up spools were archived, a SKU's rate counted only what its remaining spools had used. A SKU in weekly use could show 0.2 g/day and run out in decades, and the Reorder and Stock Break alerts came late or not at all.
 - **The stock forecast counts used-up spools towards the consumption rate (#3399, reported by @ojimpo)** — Archived spools were left out of the forecast entirely, so once used-up spools were archived, a SKU's rate counted only what its remaining spools had used. A SKU in weekly use could show 0.2 g/day and run out in decades, and the Reorder and Stock Break alerts came late or not at all.
   - A spool archived in the last 90 days now counts towards its SKU's rate, in both inventory modes, on the Forecast page and for the alerts. Older ones don't, so a spool used up years ago doesn't drag the rate down.
   - A spool archived in the last 90 days now counts towards its SKU's rate, in both inventory modes, on the Forecast page and for the alerts. Older ones don't, so a spool used up years ago doesn't drag the rate down.
   - Archived spools still aren't stock, and a SKU whose spools are all archived still isn't forecast. Wiki updated.
   - Archived spools still aren't stock, and a SKU whose spools are all archived still isn't forecast. Wiki updated.

+ 164 - 0
frontend/src/__tests__/components/ForecastPanelLabel.test.tsx

@@ -0,0 +1,164 @@
+/**
+ * SKU labels name the colour once (#3400). In Spoolman mode a filament with no
+ * colour name gets its subtype as one, so subtype and colour are the same word.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type React from 'react';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { ForecastPanel } from '../../components/ForecastPanel';
+import { api } from '../../api/client';
+import type { InventorySpool, ShoppingListItem } 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(),
+    },
+  };
+});
+
+// The chart measures its container, which jsdom has none of: give it a size so
+// the legend renders.
+vi.mock('recharts', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('recharts')>();
+  const { cloneElement } = await import('react');
+  return {
+    ...actual,
+    ResponsiveContainer: ({ children }: { children: React.ReactElement<{ width?: number; height?: number }> }) =>
+      cloneElement(children, { width: 800, height: 220 }),
+  };
+});
+
+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,
+});
+
+// What _map_spoolman_spool makes of a filament named "Black", material "PLA Basic".
+const spoolman = spool(1, { brand: 'Bambu Lab', material: 'PLA Basic', subtype: 'Black', color_name: 'Black', color_name_is_synthesized: true });
+// Nearly empty: 10 g left at ~99 g/day, so the SKU is in alert.
+const low = { ...spoolman, weight_used: 990, label_weight: 1000, created_at: new Date(Date.now() - 10 * 86400000).toISOString() };
+const named = spool(2, { brand: 'Polymaker', material: 'PLA', subtype: 'Matte', color_name: 'Red' });
+
+const listItem: ShoppingListItem = {
+  id: 61, material: 'PLA Basic', subtype: 'Black', brand: 'Bambu Lab', color_name: 'Black',
+  quantity_spools: 1, note: null, status: 'pending', purchased_at: null, added_at: '2025-01-01T00:00:00Z',
+};
+
+describe('ForecastPanel SKU labels (#3400)', () => {
+  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([]);
+    mocked.getShoppingList.mockResolvedValue([listItem]);
+  });
+
+  it('names a colour that is only the subtype once, and a real colour as before', async () => {
+    render(<ForecastPanel spools={[spoolman, named]} />);
+
+    expect(await screen.findAllByText('Bambu Lab PLA Basic Black')).not.toHaveLength(0);
+    expect(screen.getAllByText('Polymaker PLA Matte Red')).not.toHaveLength(0);
+    expect(screen.queryByText(/Black Black/)).toBeNull();
+  });
+
+  it('does the same in the alert banner and the add-to-list dialog', async () => {
+    const user = userEvent.setup();
+    render(<ForecastPanel spools={[low]} />);
+
+    await user.click(await screen.findByRole('button', { name: /1 alert/ }));
+    const banners = screen.getAllByText('Bambu Lab PLA Basic Black');
+    await user.click(screen.getAllByTitle('Add to shopping list')[0]);
+
+    expect(screen.getAllByText('Bambu Lab PLA Basic Black').length).toBeGreaterThan(banners.length);
+    expect(screen.queryByText(/Black Black/)).toBeNull();
+  });
+
+  it('does the same in the chart legend', async () => {
+    const { container } = render(<ForecastPanel spools={[low]} />);
+
+    await screen.findAllByText('Bambu Lab PLA Basic Black');
+    const legend = container.querySelector('.recharts-legend-wrapper');
+    expect(legend).not.toBeNull();
+    expect(legend).toHaveTextContent(/^Bambu Lab PLA Basic Black$/);
+  });
+
+  it('does the same in the shopping list logistics view', async () => {
+    const user = userEvent.setup();
+    render(<ForecastPanel spools={[spoolman]} />);
+    await user.click(await screen.findByRole('button', { name: /Shopping List/ }));
+    await user.click(await screen.findByRole('button', { name: /Logistics/ }));
+
+    expect(await screen.findAllByText('Bambu Lab PLA Basic Black')).not.toHaveLength(0);
+    expect(screen.queryByText(/Black Black/)).toBeNull();
+  });
+
+  it('does the same in the shopping list', async () => {
+    const user = userEvent.setup();
+    render(<ForecastPanel spools={[spoolman]} />);
+    await user.click(await screen.findByRole('button', { name: /Shopping List/ }));
+
+    expect(await screen.findAllByText('Bambu Lab PLA Basic Black')).not.toHaveLength(0);
+    expect(screen.queryByText(/Black Black/)).toBeNull();
+  });
+});

+ 14 - 6
frontend/src/components/ForecastPanel.tsx

@@ -81,6 +81,14 @@ function skuKey(material: string, subtype: string | null, brand: string | null,
   return `${material}||${subtype ?? ''}||${brand ?? ''}||${colorName ?? ''}`;
   return `${material}||${subtype ?? ''}||${brand ?? ''}||${colorName ?? ''}`;
 }
 }
 
 
+/** A SKU's name for people. In Spoolman mode a filament with no colour name
+ *  gets its subtype as one ("PLA Basic" named "Black"), which printed the word
+ *  twice: "Bambu Lab PLA Basic Black Black" (#3400). The key keeps the colour. */
+function skuLabel(brand: string | null, material: string, subtype: string | null, colorName: string | null) {
+  const sameAsSubtype = !!colorName && !!subtype && colorName.trim().toLowerCase() === subtype.trim().toLowerCase();
+  return [brand, material, subtype, sameAsSubtype ? null : colorName].filter(Boolean).join(' ');
+}
+
 function addDays(date: Date, days: number): Date {
 function addDays(date: Date, days: number): Date {
   const d = new Date(date);
   const d = new Date(date);
   d.setDate(d.getDate() + Math.round(days));
   d.setDate(d.getDate() + Math.round(days));
@@ -653,7 +661,7 @@ function SortableTh({
 
 
 function AlertBanner({ forecast: f, onCart }: { forecast: SkuForecast; onCart: () => void }) {
 function AlertBanner({ forecast: f, onCart }: { forecast: SkuForecast; onCart: () => void }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
+  const label = skuLabel(f.group.brand, f.group.material, f.group.subtype, f.group.colorName);
   const isBreak = f.stockBreakAlert;
   const isBreak = f.stockBreakAlert;
 
 
   return (
   return (
@@ -701,7 +709,7 @@ function UsageChart({ forecasts, days: maxDays, onDaysChange }: {
 
 
   const series = forecasts.map((f, idx) => ({
   const series = forecasts.map((f, idx) => ({
     key: f.group.key,
     key: f.group.key,
-    label: [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' '),
+    label: skuLabel(f.group.brand, f.group.material, f.group.subtype, f.group.colorName),
     color: CHART_COLORS[idx % CHART_COLORS.length],
     color: CHART_COLORS[idx % CHART_COLORS.length],
     rop: f.reorderPointG,
     rop: f.reorderPointG,
     points: buildProjectionSeries(f, maxDays),
     points: buildProjectionSeries(f, maxDays),
@@ -909,7 +917,7 @@ function ForecastRow({
 
 
   const snoozed = f.settings?.alerts_snoozed ?? false;
   const snoozed = f.settings?.alerts_snoozed ?? false;
 
 
-  const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
+  const label = skuLabel(f.group.brand, f.group.material, f.group.subtype, f.group.colorName);
   // Use getSwatchStyle so a Clear (alpha=00) lead spool renders as a
   // Use getSwatchStyle so a Clear (alpha=00) lead spool renders as a
   // checkerboard rather than collapsing to solid black (#1545).
   // checkerboard rather than collapsing to solid black (#1545).
   const colorStyle = f.group.spools[0]?.rgba ? getSwatchStyle(f.group.spools[0].rgba) : { backgroundColor: '#4B5563' };
   const colorStyle = f.group.spools[0]?.rgba ? getSwatchStyle(f.group.spools[0].rgba) : { backgroundColor: '#4B5563' };
@@ -1464,7 +1472,7 @@ function ShoppingListPanel({
             </thead>
             </thead>
             <tbody className="divide-y divide-bambu-dark-tertiary">
             <tbody className="divide-y divide-bambu-dark-tertiary">
               {items.map((item) => {
               {items.map((item) => {
-                const lbl = [item.brand, item.material, item.subtype, item.color_name].filter(Boolean).join(' ');
+                const lbl = skuLabel(item.brand, item.material, item.subtype, item.color_name);
                 const hasBreak = breakAlerts.some((a) => a.item.id === item.id);
                 const hasBreak = breakAlerts.some((a) => a.item.id === item.id);
                 const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand, item.color_name)) ?? null;
                 const f = forecastMap.get(skuKey(item.material, item.subtype, item.brand, item.color_name)) ?? null;
                 const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
                 const avgSpoolG = f && f.totalSpools > 0 ? f.totalLabelG / f.totalSpools : 1000;
@@ -1596,7 +1604,7 @@ function CartLogisticsRow({
   onRemove: () => void;
   onRemove: () => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const label = [item.brand, item.material, item.subtype, item.color_name].filter(Boolean).join(' ');
+  const label = skuLabel(item.brand, item.material, item.subtype, item.color_name);
 
 
   // Build a timeline showing stock depletion, arrival bump, then post-arrival depletion.
   // Build a timeline showing stock depletion, arrival bump, then post-arrival depletion.
   // Two points are inserted at day `lt` (just-before and just-after arrival) so the
   // Two points are inserted at day `lt` (just-before and just-after arrival) so the
@@ -1831,7 +1839,7 @@ function AddToCartModal({
   onAdd: (item: { material: string; subtype: string | null; brand: string | null; color_name: string | null; quantity_spools: number; note: string | null }) => void;
   onAdd: (item: { material: string; subtype: string | null; brand: string | null; color_name: string | null; quantity_spools: number; note: string | null }) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const label = [f.group.brand, f.group.material, f.group.subtype, f.group.colorName].filter(Boolean).join(' ');
+  const label = skuLabel(f.group.brand, f.group.material, f.group.subtype, f.group.colorName);
   const [mode, setMode] = useState<'qty' | 'duration'>('qty');
   const [mode, setMode] = useState<'qty' | 'duration'>('qty');
   const [qty, setQty] = useState('1');
   const [qty, setQty] = useState('1');
   const [durationDays, setDurationDays] = useState('30');
   const [durationDays, setDurationDays] = useState('30');