|
|
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
|
|
|
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
-import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
|
|
|
+import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
|
|
|
import { api } from '../../api/client';
|
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
|
import { Card, CardContent } from '../Card';
|
|
|
@@ -21,7 +21,7 @@ import { isGcodeCompatible } from '../../utils/printer';
|
|
|
import { getCurrencySymbol } from '../../utils/currency';
|
|
|
import { getBedTypeInfo } from '../../utils/bedType';
|
|
|
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
|
|
|
-import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
|
|
|
+import { isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
|
|
|
import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
|
|
|
import { FilamentMapping } from './FilamentMapping';
|
|
|
import { FilamentOverride } from './FilamentOverride';
|
|
|
@@ -97,6 +97,9 @@ export function PrintModal({
|
|
|
slotLabel: string;
|
|
|
requiredGrams: number;
|
|
|
remainingGrams: number;
|
|
|
+ /** True when AMS Filament Backup pooled more than one spool for this slot;
|
|
|
+ * `requiredGrams` / `remainingGrams` are then the pooled totals. */
|
|
|
+ pooled?: boolean;
|
|
|
};
|
|
|
|
|
|
// Multiple printer selection (used for all modes now)
|
|
|
@@ -339,13 +342,6 @@ export function PrintModal({
|
|
|
setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id);
|
|
|
}, [printableCostCenters, selectedCostCenterId]);
|
|
|
|
|
|
- const { data: spoolAssignments } = useQuery({
|
|
|
- queryKey: ['spool-assignments'],
|
|
|
- queryFn: () => api.getAssignments(),
|
|
|
- staleTime: 30 * 1000,
|
|
|
- enabled: !isEditing && assignmentMode === 'printer',
|
|
|
- });
|
|
|
-
|
|
|
// Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
|
|
|
// backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
|
|
|
// so internal and Spoolman modes both work uniformly, VT/external slots are
|
|
|
@@ -374,6 +370,23 @@ export function PrintModal({
|
|
|
return result;
|
|
|
}, [selectedPrinters, inventoryRemainQueries]);
|
|
|
|
|
|
+ // Same endpoint, the other half of its payload: every inventory-bound slot on
|
|
|
+ // the printer with the backend's material identity and extruder side. The
|
|
|
+ // pre-flight filament check groups on these instead of resolving spools
|
|
|
+ // itself, which is what makes it agree with the dispatcher and work in
|
|
|
+ // Spoolman mode (where the modal has no assignment rows of its own).
|
|
|
+ const slotMaterialsPerPrinter = useMemo(() => {
|
|
|
+ const result = new Map<number, Map<number, SlotMaterial>>();
|
|
|
+ selectedPrinters.forEach((printerId, idx) => {
|
|
|
+ const slots = inventoryRemainQueries[idx]?.data?.slot_materials;
|
|
|
+ if (!slots) return;
|
|
|
+ const printerMap = new Map<number, SlotMaterial>();
|
|
|
+ slots.forEach((slot) => printerMap.set(slot.global_tray_id, slot));
|
|
|
+ result.set(printerId, printerMap);
|
|
|
+ });
|
|
|
+ return result;
|
|
|
+ }, [selectedPrinters, inventoryRemainQueries]);
|
|
|
+
|
|
|
// Fetch archive details to get sliced_for_model
|
|
|
const { data: archiveDetails } = useQuery({
|
|
|
queryKey: ['archive', archiveId],
|
|
|
@@ -747,32 +760,26 @@ 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),
|
|
|
- })
|
|
|
+ // Under AMS Filament Backup the shortfall is against the pooled spools,
|
|
|
+ // not the one slot — quoting that slot's remaining next to a pooled
|
|
|
+ // requirement reads as a contradiction ("needs 1441g, remaining 1000g"
|
|
|
+ // while a second full spool sits next to it).
|
|
|
+ item.pooled
|
|
|
+ ? t('printModal.insufficientFilamentLinePooled', {
|
|
|
+ printer: item.printerName,
|
|
|
+ slot: item.slotLabel,
|
|
|
+ required: Math.round(item.requiredGrams),
|
|
|
+ remaining: Math.round(item.remainingGrams),
|
|
|
+ })
|
|
|
+ : 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]);
|
|
|
@@ -846,13 +853,7 @@ export function PrintModal({
|
|
|
? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
|
|
|
: [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
|
|
|
|
|
|
- if (plateJobs.some((job) => job.reqs.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);
|
|
|
- };
|
|
|
-
|
|
|
+ if (plateJobs.some((job) => job.reqs.length > 0) && slotMaterialsPerPrinter.size > 0) {
|
|
|
for (const printerId of selectedPrinters) {
|
|
|
const printerStatusForWarning = selectedPrinters.length > 1
|
|
|
? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
|
|
|
@@ -860,10 +861,13 @@ export function PrintModal({
|
|
|
|
|
|
const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
|
|
|
const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
|
|
|
- const assignments = spoolAssignmentsByPrinter.get(printerId);
|
|
|
+ // Slots the backend could price. A slot missing here is one with no
|
|
|
+ // inventory binding, or one whose Spoolman spool it could not read —
|
|
|
+ // both mean "nothing to weigh", never "empty".
|
|
|
+ const slotMaterials = slotMaterialsPerPrinter.get(printerId);
|
|
|
const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
|
|
|
|
|
|
- if (!assignments) continue;
|
|
|
+ if (!slotMaterials || slotMaterials.size === 0) continue;
|
|
|
|
|
|
const gramsByTray = new Map<number, number>();
|
|
|
for (const job of plateJobs) {
|
|
|
@@ -880,19 +884,62 @@ export function PrintModal({
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- for (const [globalTrayId, requiredGrams] of gramsByTray) {
|
|
|
- const spool = assignments.get(globalTrayId)?.spool;
|
|
|
- if (!spool) continue;
|
|
|
+ // With AMS Filament Backup ON the firmware switches to any other slot
|
|
|
+ // holding the same material, so the print is only short when the whole
|
|
|
+ // pool is (#1762). The dispatcher has accounted for this since #1762 —
|
|
|
+ // this check did not, and blocked prints the dispatcher would have run.
|
|
|
+ // Dual-extruder printers pool per side: the firmware cannot cross
|
|
|
+ // nozzles even with the backup bit set, which is why `extruder` is part
|
|
|
+ // of the key the backend hands us.
|
|
|
+ const backupOn = printerStatusForWarning?.ams_filament_backup === true;
|
|
|
+ const poolKey = (slot: SlotMaterial) => `${slot.material_key}#${slot.extruder}`;
|
|
|
+
|
|
|
+ const pooledGrams = new Map<string, number>();
|
|
|
+ const pooledSlotCount = new Map<string, number>();
|
|
|
+ const pooledRequired = new Map<string, number>();
|
|
|
+ if (backupOn) {
|
|
|
+ slotMaterials.forEach((slot) => {
|
|
|
+ const key = poolKey(slot);
|
|
|
+ pooledGrams.set(key, (pooledGrams.get(key) ?? 0) + slot.remaining_g);
|
|
|
+ pooledSlotCount.set(key, (pooledSlotCount.get(key) ?? 0) + 1);
|
|
|
+ });
|
|
|
+ for (const [globalTrayId, requiredGrams] of gramsByTray) {
|
|
|
+ const slot = slotMaterials.get(globalTrayId);
|
|
|
+ if (!slot) continue;
|
|
|
+ const key = poolKey(slot);
|
|
|
+ pooledRequired.set(key, (pooledRequired.get(key) ?? 0) + requiredGrams);
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
|
|
|
- if (remainingGrams === null) continue;
|
|
|
- if (remainingGrams >= requiredGrams) continue;
|
|
|
+ for (const [globalTrayId, requiredGrams] of gramsByTray) {
|
|
|
+ const slot = slotMaterials.get(globalTrayId);
|
|
|
+ if (!slot) continue;
|
|
|
+
|
|
|
+ const slotLabel = slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`;
|
|
|
+
|
|
|
+ if (backupOn) {
|
|
|
+ const key = poolKey(slot);
|
|
|
+ const available = pooledGrams.get(key) ?? 0;
|
|
|
+ const needed = pooledRequired.get(key) ?? 0;
|
|
|
+ if (available >= needed) continue;
|
|
|
+ warningItems.push({
|
|
|
+ printerName,
|
|
|
+ slotLabel,
|
|
|
+ requiredGrams: needed,
|
|
|
+ remainingGrams: available,
|
|
|
+ // A pool of one is just the slot itself — same numbers, so use
|
|
|
+ // the plain wording rather than talk about spools that aren't there.
|
|
|
+ pooled: (pooledSlotCount.get(key) ?? 1) > 1,
|
|
|
+ });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
+ if (slot.remaining_g >= requiredGrams) continue;
|
|
|
warningItems.push({
|
|
|
printerName,
|
|
|
- slotLabel: slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`,
|
|
|
+ slotLabel,
|
|
|
requiredGrams,
|
|
|
- remainingGrams,
|
|
|
+ remainingGrams: slot.remaining_g,
|
|
|
});
|
|
|
}
|
|
|
}
|