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

Merge pull request #414 from Keybored02/feature/filament-fix

Fix: Spool Empty Weight Catalog Matching by ID
MartinNYHC 3 месяцев назад
Родитель
Сommit
d8007fca62

+ 6 - 0
backend/app/core/database.py

@@ -1185,6 +1185,12 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Create spool_usage_history table for filament consumption tracking
     # Migration: Create spool_usage_history table for filament consumption tracking
     try:
     try:
         await conn.execute(
         await conn.execute(

+ 3 - 0
backend/app/models/spool.py

@@ -19,6 +19,9 @@ class Spool(Base):
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
+    core_weight_catalog_id: Mapped[int | None] = mapped_column(
+        Integer
+    )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer

+ 2 - 0
backend/app/schemas/spool.py

@@ -11,6 +11,7 @@ class SpoolBase(BaseModel):
     brand: str | None = None
     brand: str | None = None
     label_weight: int = 1000
     label_weight: int = 1000
     core_weight: int = 250
     core_weight: int = 250
+    core_weight_catalog_id: int | None = None
     weight_used: float = 0
     weight_used: float = 0
     slicer_filament: str | None = None
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
     slicer_filament_name: str | None = None
@@ -35,6 +36,7 @@ class SpoolUpdate(BaseModel):
     brand: str | None = None
     brand: str | None = None
     label_weight: int | None = None
     label_weight: int | None = None
     core_weight: int | None = None
     core_weight: int | None = None
+    core_weight_catalog_id: int | None = None
     weight_used: float | None = None
     weight_used: float | None = None
     slicer_filament: str | None = None
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
     slicer_filament_name: str | None = None

+ 135 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -62,6 +62,7 @@ const existingSpool: InventorySpool = {
   rgba: 'FF0000FF',
   rgba: 'FF0000FF',
   label_weight: 1000,
   label_weight: 1000,
   core_weight: 250,
   core_weight: 250,
+  core_weight_catalog_id: null,
   weight_used: 300,
   weight_used: 300,
   slicer_filament: 'GFL99',
   slicer_filament: 'GFL99',
   slicer_filament_name: 'Generic PLA',
   slicer_filament_name: 'Generic PLA',
@@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => {
     // weight_used MUST be included for new spools (default value 0)
     // weight_used MUST be included for new spools (default value 0)
     expect(payload).toHaveProperty('weight_used', 0);
     expect(payload).toHaveProperty('weight_used', 0);
   });
   });
+
+  it('preserves core_weight_catalog_id when editing other fields', async () => {
+    const spoolWithCatalogId: InventorySpool = {
+      ...existingSpool,
+      core_weight_catalog_id: 5,
+    };
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCatalogId}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Change the note field (unrelated to catalog ID)
+    const noteInputs = screen.getAllByPlaceholderText(/note/i);
+    expect(noteInputs.length).toBeGreaterThan(0);
+    fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
+
+    // Click Save
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // core_weight_catalog_id MUST be preserved when editing other fields
+    expect(payload).toHaveProperty('core_weight_catalog_id', 5);
+    // Other changes should also be present
+    expect(payload).toHaveProperty('note', 'Updated note');
+  });
+
+  it('includes core_weight_catalog_id when selecting from catalog', async () => {
+    const mockCatalog = [
+      { id: 1, name: 'Generic 250g', weight: 250 },
+      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
+      { id: 3, name: 'Standard 300g', weight: 300 },
+    ];
+
+    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Wait for catalog to load
+    await waitFor(() => {
+      expect(api.getSpoolCatalog).toHaveBeenCalled();
+    });
+
+    // Click on the empty spool weight field to open dropdown
+    const weightInputs = screen.getAllByPlaceholderText(/search/i);
+    const weightPicker = weightInputs.find(input =>
+      input.getAttribute('placeholder')?.toLowerCase().includes('spool')
+    );
+    expect(weightPicker).toBeTruthy();
+    fireEvent.focus(weightPicker!);
+
+    // Click on "Bambu Lab 250g" option
+    const bambuOption = await screen.findByText('Bambu Lab 250g');
+    fireEvent.click(bambuOption);
+
+    // Click the add spool button
+    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
+    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
+    expect(submitButton).toBeTruthy();
+    fireEvent.click(submitButton!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
+    // Both weight AND catalog ID should be sent
+    expect(payload).toHaveProperty('core_weight', 250);
+    expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
+  });
+
+  it('displays correct catalog name when duplicates exist', async () => {
+    const spoolWithCatalogId: InventorySpool = {
+      ...existingSpool,
+      core_weight: 250,
+      core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
+    };
+
+    const mockCatalog = [
+      { id: 1, name: 'Generic 250g', weight: 250 },
+      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
+      { id: 3, name: 'Standard 300g', weight: 300 },
+    ];
+
+    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCatalogId}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Wait for catalog to load
+    await waitFor(() => {
+      expect(api.getSpoolCatalog).toHaveBeenCalled();
+    });
+
+    // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
+    await waitFor(() => {
+      const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
+      const bambuFound = weightInputs.some(input =>
+        input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
+      );
+      expect(bambuFound).toBeTruthy();
+    });
+  });
 });
 });

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

@@ -1771,6 +1771,7 @@ export interface InventorySpool {
   brand: string | null;
   brand: string | null;
   label_weight: number;
   label_weight: number;
   core_weight: number;
   core_weight: number;
+  core_weight_catalog_id: number | null;
   weight_used: number;
   weight_used: number;
   slicer_filament: string | null;
   slicer_filament: string | null;
   slicer_filament_name: string | null;
   slicer_filament_name: string | null;

+ 2 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
           rgba: spool.rgba || '808080FF',
           rgba: spool.rgba || '808080FF',
           label_weight: spool.label_weight || 1000,
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
           core_weight: spool.core_weight || 250,
+          core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
           weight_used: spool.weight_used || 0,
           weight_used: spool.weight_used || 0,
           slicer_filament: spool.slicer_filament || '',
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
           note: spool.note || '',
@@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
       rgba: formData.rgba || null,
       rgba: formData.rgba || null,
       label_weight: formData.label_weight,
       label_weight: formData.label_weight,
       core_weight: formData.core_weight,
       core_weight: formData.core_weight,
+      core_weight_catalog_id: formData.core_weight_catalog_id,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament_name: presetName,
       slicer_filament_name: presetName,
       nozzle_temp_min: null,
       nozzle_temp_min: null,

+ 55 - 9
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -8,15 +8,18 @@ function SpoolWeightPicker({
   catalog,
   catalog,
   value,
   value,
   onChange,
   onChange,
+  catalogId,
+  onCatalogIdChange,
 }: {
 }: {
   catalog: { id: number; name: string; weight: number }[];
   catalog: { id: number; name: string; weight: number }[];
   value: number;
   value: number;
   onChange: (weight: number) => void;
   onChange: (weight: number) => void;
+  catalogId: number | null;
+  onCatalogIdChange: (id: number | null) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
-  const [selectedId, setSelectedId] = useState<number | null>(null);
   const dropdownRef = useRef<HTMLDivElement>(null);
   const dropdownRef = useRef<HTMLDivElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
 
@@ -30,6 +33,35 @@ function SpoolWeightPicker({
     return () => document.removeEventListener('mousedown', handleClick);
     return () => document.removeEventListener('mousedown', handleClick);
   }, []);
   }, []);
 
 
+  // When value changes, auto-select if there's only one matching entry or keep selection if it still matches
+  useEffect(() => {
+    // If no catalog loaded yet, skip matching logic
+    if (catalog.length === 0) {
+      return;
+    }
+
+    const matches = catalog.filter(e => e.weight === value);
+
+    // If currently selected entry still matches the weight, keep it selected
+    if (catalogId) {
+      const selected = catalog.find(e => e.id === catalogId);
+      if (selected && selected.weight === value) {
+        return; // Keep current selection
+      }
+    }
+
+    // If exactly one match, auto-select it
+    if (matches.length === 1) {
+      onCatalogIdChange(matches[0].id);
+    } else if (matches.length === 0) {
+      // No matches, clear selection to prevent stale catalog ID
+      if (catalogId !== null) {
+        onCatalogIdChange(null);
+      }
+    }
+    // If multiple matches, don't auto-select - let user choose
+  }, [value, catalog, catalogId, onCatalogIdChange]);
+
   const filtered = useMemo(() => {
   const filtered = useMemo(() => {
     if (!search) return catalog;
     if (!search) return catalog;
     const s = search.toLowerCase();
     const s = search.toLowerCase();
@@ -39,17 +71,29 @@ function SpoolWeightPicker({
     );
     );
   }, [catalog, search]);
   }, [catalog, search]);
 
 
-  // Display value: show catalog name if selected, or the weight
+  // Find all entries matching the current weight
+  const matchingEntries = useMemo(() => {
+    return catalog.filter(e => e.weight === value);
+  }, [catalog, value]);
+
+  // Display value: show catalog name if selected by ID, otherwise show first match
   const displayValue = useMemo(() => {
   const displayValue = useMemo(() => {
     if (isOpen) return search;
     if (isOpen) return search;
-    if (selectedId) {
-      const entry = catalog.find(e => e.id === selectedId);
+
+    // If a catalog ID is explicitly selected, use that
+    if (catalogId) {
+      const entry = catalog.find(e => e.id === catalogId);
       if (entry) return entry.name;
       if (entry) return entry.name;
     }
     }
-    const match = catalog.find(e => e.weight === value);
-    if (match) return match.name;
+
+    // Otherwise, show the first matching entry as a suggestion
+    if (matchingEntries.length > 0) {
+      return matchingEntries[0].name;
+    }
+
+    // Leave empty if there are no matches
     return '';
     return '';
-  }, [isOpen, search, selectedId, catalog, value]);
+  }, [isOpen, search, catalogId, catalog, matchingEntries]);
 
 
   return (
   return (
     <div>
     <div>
@@ -86,12 +130,12 @@ function SpoolWeightPicker({
                     key={entry.id}
                     key={entry.id}
                     type="button"
                     type="button"
                     className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
                     className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
-                      (selectedId ? entry.id === selectedId : entry.weight === value)
+                      (catalogId ? entry.id === catalogId : entry.weight === value)
                         ? 'bg-bambu-green/10 text-bambu-green'
                         ? 'bg-bambu-green/10 text-bambu-green'
                         : 'text-white'
                         : 'text-white'
                     }`}
                     }`}
                     onClick={() => {
                     onClick={() => {
-                      setSelectedId(entry.id);
+                      onCatalogIdChange(entry.id);
                       onChange(entry.weight);
                       onChange(entry.weight);
                       setIsOpen(false);
                       setIsOpen(false);
                       setSearch('');
                       setSearch('');
@@ -150,6 +194,8 @@ export function AdditionalSection({
         catalog={spoolCatalog}
         catalog={spoolCatalog}
         value={formData.core_weight}
         value={formData.core_weight}
         onChange={(weight) => updateField('core_weight', weight)}
         onChange={(weight) => updateField('core_weight', weight)}
+        catalogId={formData.core_weight_catalog_id}
+        onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
       />
       />
 
 
       {/* Current Weight (remaining filament) */}
       {/* Current Weight (remaining filament) */}

+ 2 - 0
frontend/src/components/spool-form/types.ts

@@ -9,6 +9,7 @@ export interface SpoolFormData {
   rgba: string;
   rgba: string;
   label_weight: number;
   label_weight: number;
   core_weight: number;
   core_weight: number;
+  core_weight_catalog_id: number | null;
   weight_used: number;
   weight_used: number;
   slicer_filament: string;
   slicer_filament: string;
   note: string;
   note: string;
@@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = {
   rgba: '808080FF',
   rgba: '808080FF',
   label_weight: 1000,
   label_weight: 1000,
   core_weight: 250,
   core_weight: 250,
+  core_weight_catalog_id: null,
   weight_used: 0,
   weight_used: 0,
   slicer_filament: '',
   slicer_filament: '',
   note: '',
   note: '',