Browse Source

[Feature] Add material mismatch and insufficient filament checks (#720)

[Feature] Add material mismatch and insufficient filament checks (#720)
Keybored 2 months ago
parent
commit
3ca91eb6d1

+ 1 - 0
backend/app/api/routes/settings.py

@@ -87,6 +87,7 @@ async def get_settings(
                 "spoolman_enabled",
                 "spoolman_disable_weight_sync",
                 "spoolman_report_partial_usage",
+                "disable_filament_warnings",
                 "check_updates",
                 "check_printer_firmware",
                 "include_beta_updates",

+ 5 - 0
backend/app/schemas/settings.py

@@ -31,6 +31,10 @@ class AppSettings(BaseModel):
         default=True,
         description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
     )
+    disable_filament_warnings: bool = Field(
+        default=False,
+        description="Disable insufficient filament warnings when printing or queueing prints",
+    )
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -190,6 +194,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_sync_mode: str | None = None
     spoolman_disable_weight_sync: bool | None = None
     spoolman_report_partial_usage: bool | None = None
+    disable_filament_warnings: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None

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

@@ -810,6 +810,8 @@ export interface AppSettings {
   // Date/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';
+  // Filament tracking
+  disable_filament_warnings: boolean;  // Disable filament warnings (print insufficiency and assignment mismatch)
   // Default printer
   default_printer_id: number | null;
   // Dark mode theme settings

+ 172 - 8
frontend/src/components/AssignSpoolModal.tsx

@@ -5,6 +5,7 @@ import { X, Loader2, Package, Check, Search } from 'lucide-react';
 import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 
 interface AssignSpoolModalProps {
@@ -15,6 +16,8 @@ interface AssignSpoolModalProps {
   trayId: number;
   trayInfo?: {
     type: string;
+    material?: string;
+    profile?: string;
     color: string;
     location: string;
   };
@@ -26,6 +29,15 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   const { showToast } = useToast();
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [searchFilter, setSearchFilter] = useState('');
+  const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);
+  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
+  const [mismatchDetails, setMismatchDetails] = useState<{
+    type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';
+    spoolMaterial: string;
+    trayMaterial: string;
+    spoolProfile?: string;
+    trayProfile?: string;
+  } | null>(null);
 
   const { data: spools, isLoading } = useQuery({
     queryKey: ['inventory-spools'],
@@ -39,6 +51,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     enabled: isOpen,
   });
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+    enabled: isOpen,
+  });
+
   const assignMutation = useMutation({
     mutationFn: (spoolId: number) =>
       api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
@@ -53,6 +71,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       showToast(t('inventory.assignSuccess'), 'success');
+      setShowMismatchConfirm(false);
+      setPendingAssignId(null);
+      setMismatchDetails(null);
       onClose();
     },
     onError: (error: Error) => {
@@ -60,6 +81,38 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     },
   });
 
+  // --- Material/profile mismatch logic ---
+  const normalizeValue = (value: string | undefined | null) =>
+    (value ?? '').trim().toUpperCase();
+
+  const checkMaterialMatch = (
+    spoolMaterial: string | undefined | null,
+    trayMaterial: string | undefined | null
+  ): 'exact' | 'partial' | 'none' => {
+    const normalizedSpool = normalizeValue(spoolMaterial);
+    const normalizedTray = normalizeValue(trayMaterial);
+
+    if (!normalizedSpool || !normalizedTray) return 'none';
+    if (normalizedSpool === normalizedTray) return 'exact';
+    if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
+      return 'partial';
+    }
+
+    return 'none';
+  };
+
+  const checkProfileMatch = (
+    spoolProfile: string | undefined | null,
+    trayProfile: string | undefined | null
+  ): boolean => {
+    const normalizedSpoolProfile = normalizeValue(spoolProfile);
+    const normalizedTrayProfile = normalizeValue(trayProfile);
+
+    if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
+
+    return normalizedSpoolProfile === normalizedTrayProfile;
+  };
+
   if (!isOpen) return null;
 
   // Filter out spools already assigned to other slots
@@ -87,17 +140,63 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   });
 
   const handleAssign = () => {
-    if (selectedSpoolId) {
-      assignMutation.mutate(selectedSpoolId);
+    if (!selectedSpoolId) return;
+    const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);
+    if (!selectedSpool) {
+      showToast(t('inventory.assignFailed'), 'error');
+      return;
     }
+
+    if (!settings?.disable_filament_warnings && trayInfo) {
+      const trayMaterial = trayInfo.material || trayInfo.type;
+      const materialMatchResult = checkMaterialMatch(selectedSpool.material, trayMaterial);
+      const spoolProfile = selectedSpool.slicer_filament_name || selectedSpool.slicer_filament;
+      const trayProfile = trayInfo.profile || trayInfo.type;
+      const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
+
+      // Always evaluate both checks; if both fail, show a combined warning.
+      if (materialMatchResult !== 'exact' || !profileMatches) {
+        let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';
+
+        if (materialMatchResult === 'none' && !profileMatches) {
+          mismatchType = 'material_profile';
+        } else if (materialMatchResult === 'partial' && !profileMatches) {
+          mismatchType = 'partial_profile';
+        } else if (materialMatchResult === 'none') {
+          mismatchType = 'material';
+        } else if (materialMatchResult === 'partial') {
+          mismatchType = 'partial';
+        }
+
+        setPendingAssignId(selectedSpoolId);
+        setMismatchDetails({
+          type: mismatchType,
+          spoolMaterial: selectedSpool.material || '',
+          trayMaterial: trayMaterial || '',
+          spoolProfile: spoolProfile || undefined,
+          trayProfile: trayProfile || undefined,
+        });
+        setShowMismatchConfirm(true);
+        return;
+      }
+    }
+    assignMutation.mutate(selectedSpoolId);
+  };
+
+  const handleConfirmMismatch = () => {
+    if (!pendingAssignId) return;
+    assignMutation.mutate(pendingAssignId);
+    setShowMismatchConfirm(false);
+    setPendingAssignId(null);
   };
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center">
-      <div
-        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
-        onClick={onClose}
-      />
+    <>
+      <div className="fixed inset-0 z-50 flex items-center justify-center">
+        <div
+          className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+          onClick={onClose}
+        />
 
       <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
         {/* Header */}
@@ -222,12 +321,77 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
           </Button>
         </div>
 
+
         {assignMutation.isError && (
           <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
             {(assignMutation.error as Error).message}
           </div>
         )}
+
+      </div>
       </div>
-    </div>
+
+      {showMismatchConfirm && trayInfo && selectedSpoolId && mismatchDetails && (() => {
+        let message = '';
+
+        if (mismatchDetails.type === 'material') {
+          message = t('inventory.assignMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          });
+        } else if (mismatchDetails.type === 'partial') {
+          message = t('inventory.assignPartialMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          });
+        } else if (mismatchDetails.type === 'material_profile') {
+          message = `${t('inventory.assignMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          })}\n\n${t('inventory.assignProfileMismatchMessage', {
+            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+            location: trayInfo.location,
+          })}`;
+        } else if (mismatchDetails.type === 'partial_profile') {
+          message = `${t('inventory.assignPartialMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          })}\n\n${t('inventory.assignProfileMismatchMessage', {
+            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+            location: trayInfo.location,
+          })}`;
+        } else if (mismatchDetails.type === 'profile') {
+          message = t('inventory.assignProfileMismatchMessage', {
+            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+            location: trayInfo.location,
+          });
+        }
+
+        return (
+          <ConfirmModal
+            title={t('inventory.assignMismatchTitle')}
+            message={message}
+            confirmText={t('inventory.assignMismatchConfirm')}
+            variant="warning"
+            isLoading={assignMutation.isPending}
+            onConfirm={handleConfirmMismatch}
+            onCancel={() => {
+              if (!assignMutation.isPending) {
+                setShowMismatchConfirm(false);
+                setPendingAssignId(null);
+                setMismatchDetails(null);
+              }
+            }}
+          />
+        );
+      })()}
+    </>
   );
 }

+ 2 - 2
frontend/src/components/ConfirmModal.tsx

@@ -51,7 +51,7 @@ export function ConfirmModal({
     },
     warning: {
       icon: 'text-yellow-400',
-      button: 'bg-yellow-500 hover:bg-yellow-600',
+      button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
     },
     default: {
       icon: 'text-bambu-green',
@@ -77,7 +77,7 @@ export function ConfirmModal({
             </div>
             <div className="flex-1">
               <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
-              <p className="text-bambu-gray text-sm">{message}</p>
+              <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
             </div>
           </div>
           <div className="flex gap-3 mt-6">

+ 130 - 6
frontend/src/components/PrintModal/index.tsx

@@ -2,18 +2,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
+import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
 import { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
+import { Card, CardContent } from '../Card';
+import { Button } from '../Button';
+import { ConfirmModal } from '../ConfirmModal';
 import { useToast } from '../../contexts/ToastContext';
-import { useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { buildLoadedFilaments, useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
-import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { getCurrencySymbol } from '../../utils/currency';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
-import { Button } from '../Button';
-import { Card, CardContent } from '../Card';
+import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
@@ -56,6 +57,13 @@ export function PrintModal({
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
 
+  type FilamentWarningItem = {
+    printerName: string;
+    slotLabel: string;
+    requiredGrams: number;
+    remainingGrams: number;
+  };
+
   // Multiple printer selection (used for all modes now)
   const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
     // Initialize with the queue item's printer if editing
@@ -191,6 +199,8 @@ export function PrintModal({
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
 
+  const [filamentWarningItems, setFilamentWarningItems] = useState<FilamentWarningItem[] | null>(null);
+
   // Track which printers have had the "Expand custom mapping by default" setting applied
   // This ensures the setting only affects initial state, not preventing unchecking
   const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
@@ -214,6 +224,13 @@ export function PrintModal({
     queryFn: api.getPrinters,
   });
 
+  const { data: spoolAssignments } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+    staleTime: 30 * 1000,
+    enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),
+  });
+
   // Fetch archive details to get sliced_for_model
   const { data: archiveDetails } = useQuery({
     queryKey: ['archive', archiveId],
@@ -396,6 +413,36 @@ export function PrintModal({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const plates = platesData?.plates ?? [];
 
+  const spoolAssignmentsByPrinter = useMemo(() => {
+    const map = new Map<number, Map<number, SpoolAssignment>>();
+    if (!spoolAssignments) return map;
+    spoolAssignments.forEach((assignment) => {
+      const isExternal = assignment.ams_id === 255;
+      const globalTrayId = getGlobalTrayId(
+        assignment.ams_id,
+        assignment.tray_id,
+        isExternal
+      );
+      const printerMap = map.get(assignment.printer_id) ?? new Map();
+      printerMap.set(globalTrayId, assignment);
+      map.set(assignment.printer_id, printerMap);
+    });
+    return map;
+  }, [spoolAssignments]);
+
+  const filamentWarningMessage = useMemo(() => {
+    if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
+    const lines = filamentWarningItems.map((item) =>
+      t('printModal.insufficientFilamentLine', {
+        printer: item.printerName,
+        slot: item.slotLabel,
+        required: Math.round(item.requiredGrams),
+        remaining: Math.round(item.remainingGrams),
+      })
+    );
+    return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
+  }, [filamentWarningItems, t]);
+
   // Add to queue mutation (single printer)
   const addToQueueMutation = useMutation({
     mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
@@ -415,9 +462,71 @@ export function PrintModal({
     },
   });
 
-  const handleSubmit = async (e?: React.FormEvent) => {
+  const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
+    if (
+      !options?.skipFilamentCheck &&
+      !settings?.disable_filament_warnings &&
+      (mode === 'reprint' || mode === 'add-to-queue') &&
+      assignmentMode === 'printer'
+    ) {
+      const warningItems: FilamentWarningItem[] = [];
+      const filamentReqs = effectiveFilamentReqs?.filaments ?? [];
+
+      if (filamentReqs.length > 0 && spoolAssignmentsByPrinter.size > 0) {
+        const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
+          if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
+          if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
+          return Math.max(0, labelWeight - weightUsed);
+        };
+
+        for (const printerId of selectedPrinters) {
+          const printerMapping = selectedPrinters.length > 1
+            ? multiPrinterMapping.getFinalMapping(printerId)
+            : amsMapping;
+          if (!printerMapping) continue;
+
+          const printerStatusForWarning = selectedPrinters.length > 1
+            ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
+            : printerStatus;
+
+          const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
+          const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
+          const assignments = spoolAssignmentsByPrinter.get(printerId);
+          const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
+
+          if (!assignments) continue;
+
+          filamentReqs.forEach((req) => {
+            if (!req.slot_id || req.slot_id <= 0) return;
+            const globalTrayId = printerMapping[req.slot_id - 1];
+            if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
+
+            const assignment = assignments.get(globalTrayId);
+            const spool = assignment?.spool;
+            if (!spool) return;
+
+            const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
+            if (remainingGrams === null) return;
+            if (remainingGrams >= req.used_grams) return;
+
+            warningItems.push({
+              printerName,
+              slotLabel: slotLabelByTray.get(globalTrayId) ?? `Slot ${req.slot_id}`,
+              requiredGrams: req.used_grams,
+              remainingGrams,
+            });
+          });
+        }
+      }
+
+      if (warningItems.length > 0) {
+        setFilamentWarningItems(warningItems);
+        return;
+      }
+    }
+
     // Validate printer/model selection
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
       showToast('Please select at least one printer', 'error');
@@ -892,6 +1001,21 @@ export function PrintModal({
           </form>
         </CardContent>
       </Card>
+
+      {filamentWarningItems && filamentWarningItems.length > 0 && (
+        <ConfirmModal
+          title={t('printModal.insufficientFilamentTitle')}
+          message={filamentWarningMessage}
+          confirmText={t('printModal.printAnyway')}
+          cancelText={t('common.cancel')}
+          variant="warning"
+          onConfirm={() => {
+            setFilamentWarningItems(null);
+            void handleSubmit(undefined, { skipFilamentCheck: true });
+          }}
+          onCancel={() => setFilamentWarningItems(null)}
+        />
+      )}
     </div>
   );
 }

+ 1 - 0
frontend/src/components/SpoolmanSettings.tsx

@@ -89,6 +89,7 @@ export function SpoolmanSettings() {
       queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
       showToast(t('settings.toast.settingsSaved'));
     },
   });

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

@@ -1330,6 +1330,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Filament-Verfolgung',
     filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',
+    filamentChecks: 'Filament-Prüfungen',
+    disableFilamentWarnings: 'Filament-Warnungen deaktivieren',
+    disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
     trackingModeBuiltIn: 'Integriertes Inventar',
     trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
     trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
@@ -2979,6 +2982,11 @@ export default {
     historyCleared: 'Verbrauchshistorie gelöscht',
     fillSourceLabel: '(Inv)',
     lowStockThresholdError: 'Der Schwellenwert muss zwischen 0.1 und 99.9 liegen',
+    assignMismatchTitle: 'Material stimmt nicht überein',
+    assignMismatchMessage: 'Das ausgewählte Spulenmaterial "{{spoolMaterial}}" stimmt nicht mit dem Tray-Material "{{trayMaterial}}" für {{location}} überein. Trotzdem zuweisen?',
+    assignMismatchConfirm: 'Trotzdem zuweisen',
+    assignPartialMismatchMessage: 'Das Spulenmaterial "{{spoolMaterial}}" ist ähnlich, stimmt aber nicht genau mit "{{trayMaterial}}" in {{location}} überein. Möchten Sie fortfahren?',
+    assignProfileMismatchMessage: 'Das Spulenprofil "{{spoolProfile}}" stimmt nicht mit dem Fachprofil "{{trayProfile}}" in {{location}} überein. Möchten Sie fortfahren?',
   },
 
   // Timelapse
@@ -3050,6 +3058,10 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Ersetzen mit',
     resetToOriginal: 'Auf Original zurücksetzen',
+    insufficientFilamentTitle: 'Nicht genug Filament',
+    insufficientFilamentMessage: 'Einige zugewiesene Spulen haben weniger Filament als dieser Druck benötigt:',
+    insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
+    printAnyway: 'Trotzdem drucken',
     forceColorMatch: 'Farbe erzwingen',
   },
 

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

@@ -1330,6 +1330,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Filament Tracking',
     filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',
+    filamentChecks: 'Filament checks',
+    disableFilamentWarnings: 'Disable filament warnings',
+    disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
     trackingModeBuiltIn: 'Built-in Inventory',
     trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
     trackingModeSpoolmanDesc: 'External filament management server',
@@ -2983,6 +2986,11 @@ export default {
     historyCleared: 'Usage history cleared',
     fillSourceLabel: '(Inv)',
     lowStockThresholdError: 'Threshold must be between 0.1 and 99.9',
+    assignMismatchTitle: 'Material mismatch',
+    assignMismatchMessage: 'The selected spool material "{{spoolMaterial}}" does not match the tray material "{{trayMaterial}}" for {{location}}. Assign anyway?',
+    assignMismatchConfirm: 'Assign Anyway',
+    assignPartialMismatchMessage: 'The spool material "{{spoolMaterial}}" is similar to but not exactly matching "{{trayMaterial}}" in {{location}}. Do you want to proceed?',
+    assignProfileMismatchMessage: 'The spool profile "{{spoolProfile}}" does not match the tray profile "{{trayProfile}}" in {{location}}. Do you want to proceed?',
   },
 
   // Timelapse
@@ -3054,6 +3062,10 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Override with',
     resetToOriginal: 'Reset to original',
+    insufficientFilamentTitle: 'Not enough filament',
+    insufficientFilamentMessage: 'Some assigned spools have less filament remaining than this print needs:',
+    insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
+    printAnyway: 'Print anyway',
     forceColorMatch: 'Force color match',
   },
 

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

@@ -1330,6 +1330,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Suivi de Filament',
     filamentTrackingDesc: 'Choisissez comment suivre vos bobines. Utilisez l\'inventaire intégré ou connectez un serveur Spoolman.',
+    filamentChecks: 'Vérifications du filament',
+    disableFilamentWarnings: 'Désactiver les avertissements de filament',
+    disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
     trackingModeBuiltIn: 'Inventaire Intégré',
     trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',
     trackingModeSpoolmanDesc: 'Serveur de gestion externe',
@@ -2970,6 +2973,11 @@ export default {
     historyCleared: 'Historique effacé',
     fillSourceLabel: '(Inv)',
     lowStockThresholdError: 'Le seuil doit être compris entre 0.1 et 99.9',
+    assignMismatchTitle: 'Incompatibilité de matériau',
+    assignMismatchMessage: 'Le matériau de la bobine sélectionnée "{{spoolMaterial}}" ne correspond pas au matériau du plateau "{{trayMaterial}}" pour {{location}}. Assigner quand même ?',
+    assignMismatchConfirm: 'Assigner quand même',
+    assignPartialMismatchMessage: 'Le matériau de la bobine "{{spoolMaterial}}" est similaire, mais ne correspond pas exactement à "{{trayMaterial}}" dans {{location}}. Voulez-vous continuer ?',
+    assignProfileMismatchMessage: 'Le profil de la bobine "{{spoolProfile}}" ne correspond pas au profil du plateau "{{trayProfile}}" dans {{location}}. Voulez-vous continuer ?',
   },
 
   // Timelapse
@@ -3041,7 +3049,11 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Remplacer par',
     resetToOriginal: 'Revenir à l\'original',
-    forceColorMatch: 'Forcer la correspondance couleur',
+    insufficientFilamentTitle: 'Filament insuffisant',
+    insufficientFilamentMessage: 'Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :',
+    insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
+    printAnyway: 'Imprimer quand même',
+    forceColorMatch: 'Forcer correspondance des couleurs',
   },
 
   // Backup

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

@@ -1330,6 +1330,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Tracciamento filamento',
     filamentTrackingDesc: 'Scegli come tracciare le bobine di filamento. Puoi usare l\'inventario integrato o collegare un server Spoolman esterno.',
+    filamentChecks: 'Controlli filamento',
+    disableFilamentWarnings: 'Disabilita avvisi filamento',
+    disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
     trackingModeBuiltIn: 'Inventario integrato',
     trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\'uso inclusi',
     trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento',
@@ -2969,6 +2972,11 @@ export default {
     historyCleared: 'Cronologia utilizzo cancellata',
     fillSourceLabel: '(Inv)',
     lowStockThresholdError: 'La soglia deve essere tra 0.1 e 99.9',
+    assignMismatchTitle: 'Materiale non corrispondente',
+    assignMismatchMessage: 'Il materiale della bobina selezionata "{{spoolMaterial}}" non corrisponde al materiale del vassoio "{{trayMaterial}}" per {{location}}. Assegnare comunque?',
+    assignMismatchConfirm: 'Assegna comunque',
+    assignPartialMismatchMessage: 'Il materiale della bobina "{{spoolMaterial}}" è simile ma non corrisponde esattamente a "{{trayMaterial}}" in {{location}}. Vuoi procedere?',
+    assignProfileMismatchMessage: 'Il profilo della bobina "{{spoolProfile}}" non corrisponde al profilo del vassoio "{{trayProfile}}" in {{location}}. Vuoi procedere?',
   },
 
   // Timelapse
@@ -3040,7 +3048,11 @@ export default {
     originalFilament: 'Originale',
     overrideWith: 'Sostituisci con',
     resetToOriginal: 'Ripristina originale',
-    forceColorMatch: 'Corrispondenza colore forzata',
+    insufficientFilamentTitle: 'Filamento insufficiente',
+    insufficientFilamentMessage: 'Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:',
+    insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
+    printAnyway: 'Stampa comunque',
+    forceColorMatch: 'Forza corrispondenza colore',
   },
 
   // Backup

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

@@ -112,7 +112,6 @@ export default {
     left: '左',
     right: '右',
   },
-
   // Printers page
   printers: {
     title: 'プリンター',
@@ -1330,6 +1329,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'フィラメント追跡',
     filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',
+    filamentChecks: 'フィラメントチェック',
+    disableFilamentWarnings: 'フィラメント警告を無効化',
+    disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
     trackingModeBuiltIn: '内蔵インベントリ',
     trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
     trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
@@ -2983,6 +2985,11 @@ export default {
     historyCleared: '使用履歴がクリアされました',
     fillSourceLabel: '(Inv)',
     lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません',
+    assignMismatchTitle: '材料の不一致',
+    assignMismatchMessage: '選択したスプールの材料「{{spoolMaterial}}」は、{{location}} のトレイ材料「{{trayMaterial}}」と一致しません。割り当てますか?',
+    assignMismatchConfirm: '強制的に割り当て',
+    assignPartialMismatchMessage: 'スプールの材料「{{spoolMaterial}}」は「{{trayMaterial}}」に似ていますが、{{location}} と完全には一致しません。続行しますか?',
+    assignProfileMismatchMessage: 'スプールのプロファイル「{{spoolProfile}}」は {{location}} のトレイプロファイル「{{trayProfile}}」と一致しません。続行しますか?',
   },
 
   // Timelapse
@@ -3054,6 +3061,10 @@ export default {
     originalFilament: 'オリジナル',
     overrideWith: '変更先',
     resetToOriginal: 'オリジナルに戻す',
+    insufficientFilamentTitle: 'フィラメントが不足しています',
+    insufficientFilamentMessage: '割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:',
+    insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
+    printAnyway: 'それでも印刷',
     forceColorMatch: 'カラーマッチを強制',
   },
 

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

@@ -1330,6 +1330,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Rastreamento de Filamento',
     filamentTrackingDesc: 'Escolha como rastrear seus rolos de filamento. Você pode usar o inventário interno ou conectar a um servidor Spoolman externo.',
+    filamentChecks: 'Verificações de filamento',
+    disableFilamentWarnings: 'Desativar avisos de filamento',
+    disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',
     trackingModeBuiltIn: 'Inventário Interno',
     trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
     trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',
@@ -2969,6 +2972,11 @@ export default {
     historyCleared: 'Histórico de uso limpo',
     fillSourceLabel: '(Inv)',
     lowStockThresholdError: 'O limite deve estar entre 0.1 e 99.9',
+    assignMismatchTitle: 'Incompatibilidade de material',
+    assignMismatchMessage: 'O material do carretel selecionado "{{spoolMaterial}}" não corresponde ao material da bandeja "{{trayMaterial}}" para {{location}}. Atribuir mesmo assim?',
+    assignMismatchConfirm: 'Atribuir mesmo assim',
+    assignPartialMismatchMessage: 'O material do carretel "{{spoolMaterial}}" é semelhante, mas não corresponde exatamente a "{{trayMaterial}}" em {{location}}. Deseja prosseguir?',
+    assignProfileMismatchMessage: 'O perfil do carretel "{{spoolProfile}}" não corresponde ao perfil da bandeja "{{trayProfile}}" em {{location}}. Deseja prosseguir?',
   },
 
   // Timelapse
@@ -3040,6 +3048,10 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Substituir por',
     resetToOriginal: 'Restaurar original',
+    insufficientFilamentTitle: 'Filamento insuficiente',
+    insufficientFilamentMessage: 'Alguns dos carretéis atribuídos têm menos filamento restante do que o necessário para esta impressão:',
+    insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
+    printAnyway: 'Imprimir mesmo assim',
     forceColorMatch: 'Forçar correspondência de cor',
   },
 

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

@@ -1330,6 +1330,9 @@ export default {
     // Filament Tracking Mode
     filamentTracking: '耗材追踪',
     filamentTrackingDesc: '选择如何追踪您的耗材。您可以使用内置库存或连接外部 Spoolman 服务器。',
+    filamentChecks: '耗材检查',
+    disableFilamentWarnings: '禁用耗材警告',
+    disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',
     trackingModeBuiltIn: '内置库存',
     trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',
     trackingModeSpoolmanDesc: '外部耗材管理服务器',
@@ -2851,6 +2854,11 @@ export default {
     unassignSpool: '取消分配',
     assignSuccess: '耗材已分配,AMS 槽位已配置',
     assignFailed: '分配耗材失败',
+    assignMismatchTitle: '材料不匹配',
+    assignMismatchMessage: '所选线轴材料 "{{spoolMaterial}}" 与 {{location}} 的料槽材料 "{{trayMaterial}}" 不匹配。仍要分配吗?',
+    assignMismatchConfirm: '仍然分配',
+    assignPartialMismatchMessage: '线轴材料 "{{spoolMaterial}}" 与 {{location}} 的 "{{trayMaterial}}" 相近但不完全一致。是否继续?',
+    assignProfileMismatchMessage: '线轴配置 "{{spoolProfile}}" 与 {{location}} 的料槽配置 "{{trayProfile}}" 不一致。是否继续?',
     selectSpool: '选择要分配到此槽位的耗材',
     assigned: '已分配',
     assigning: '分配中...',
@@ -3040,6 +3048,10 @@ export default {
     originalFilament: '原始',
     overrideWith: '覆盖为',
     resetToOriginal: '恢复为原始',
+    insufficientFilamentTitle: '耗材不足',
+    insufficientFilamentMessage: '部分已分配线轴的剩余耗材少于本次打印所需:',
+    insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
+    printAnyway: '仍然打印',
     forceColorMatch: '强制颜色匹配',
   },
 

+ 10 - 4
frontend/src/pages/PrintersPage.tsx

@@ -1610,7 +1610,7 @@ function PrinterCard({
     printerId: number;
     amsId: number;
     trayId: number;
-    trayInfo: { type: string; color: string; location: string };
+    trayInfo: { type: string; color: string; location: string; material?: string; profile?: string };
   } | null>(null);
   const [configureSlotModal, setConfigureSlotModal] = useState<{
     amsId: number;
@@ -3330,7 +3330,9 @@ function PrinterCard({
                                               amsId: ams.id,
                                               trayId: slotIdx,
                                               trayInfo: {
-                                                type: filamentData.profile,
+                                                type: tray?.tray_type || filamentData.profile,
+                                                material: tray?.tray_type ?? undefined,
+                                                profile: filamentData.profile,
                                                 color: filamentData.colorHex || '',
                                                 location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                               },
@@ -3644,7 +3646,9 @@ function PrinterCard({
                                           amsId: ams.id,
                                           trayId: htSlotId,
                                           trayInfo: {
-                                            type: filamentData.profile,
+                                            type: tray?.tray_type || filamentData.profile,
+                                            material: tray?.tray_type ?? undefined,
+                                            profile: filamentData.profile,
                                             color: filamentData.colorHex || '',
                                             location: getAmsLabel(ams.id, ams.tray.length),
                                           },
@@ -3855,7 +3859,9 @@ function PrinterCard({
                                             amsId: 255,
                                             trayId: slotTrayId,
                                             trayInfo: {
-                                              type: extFilamentData.profile,
+                                              type: extTray.tray_type || extFilamentData.profile,
+                                              material: extTray.tray_type ?? undefined,
+                                              profile: extFilamentData.profile,
                                               color: extFilamentData.colorHex || '',
                                               location: extLabel || t('printers.external'),
                                             },

+ 27 - 0
frontend/src/pages/SettingsPage.tsx

@@ -709,6 +709,7 @@ export function SettingsPage() {
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
+      settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
       (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
@@ -779,6 +780,7 @@ export function SettingsPage() {
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_history_retention_days: localSettings.ams_history_retention_days,
+        disable_filament_warnings: localSettings.disable_filament_warnings,
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_block: localSettings.queue_drying_block,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
@@ -3240,6 +3242,31 @@ export function SettingsPage() {
           <div className="lg:w-1/3 space-y-6">
             <SpoolmanSettings />
 
+            <Card>
+              <CardHeader>
+                <h2 className="text-lg font-semibold text-white">{t('settings.filamentChecks')}</h2>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white">{t('settings.disableFilamentWarnings')}</p>
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.disableFilamentWarningsDesc')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.disable_filament_warnings}
+                      onChange={(e) => updateSetting('disable_filament_warnings', e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              </CardContent>
+            </Card>
+
             <Card>
               <CardHeader>
                 <h2 className="text-lg font-semibold text-white">{t('settings.amsDisplayThresholds')}</h2>