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

Fix cross-model queue items being misrepresented and editable into a broken state (#671)

The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.

Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".

The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
maziggy 1 месяц назад
Родитель
Сommit
ea63355fde

+ 26 - 1
backend/app/api/routes/print_queue.py

@@ -1348,7 +1348,14 @@ async def update_queue_item(
     """Update a queue item."""
     user, can_modify_all = auth_result
 
-    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
+    result = await db.execute(
+        select(PrintQueueItem)
+        # Needed by the cross-model guard below, and by the response builder —
+        # without it _variant_summaries falls back to [] and a PATCH would strip
+        # the alternatives out of the payload it echoes back.
+        .options(selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file))
+        .where(PrintQueueItem.id == item_id)
+    )
     item = result.scalar_one_or_none()
     if not item:
         raise HTTPException(404, "Queue item not found")
@@ -1376,6 +1383,24 @@ async def update_queue_item(
     if "target_model" in update_data and update_data["target_model"]:
         update_data["target_model"] = normalize_model_name(update_data["target_model"])
 
+    # A cross-model item (#671) owns its own printer decision: each candidate
+    # carries its model, and the resolver folds the winner onto the row at
+    # dispatch. Assigning a printer here would leave a row with variants *and* a
+    # printer_id, and the fixed-printer branch of the scheduler wins that race —
+    # so it would dispatch a row whose library_file_id is still null and die in
+    # the upload. Narrowing target_model is refused for the same reason: it
+    # would silently discard every alternative the user queued.
+    #
+    # Compared against the current value rather than merely present, because the
+    # edit dialog re-sends target_model unchanged on every save.
+    if item.variants:
+        for field in ("printer_id", "target_model"):
+            if field in update_data and update_data[field] != getattr(item, field):
+                raise HTTPException(
+                    400,
+                    "This job has printer alternatives — remove them before assigning a printer or model",
+                )
+
     # Cannot specify both printer_id and target_model
     new_printer_id = update_data.get("printer_id", item.printer_id)
     new_target_model = update_data.get("target_model", item.target_model)

+ 52 - 0
backend/tests/integration/test_queue_variants_api.py

@@ -222,6 +222,58 @@ class TestQueueWithVariants:
         assert r.status_code == 400
         assert "No active printers" in r.json()["detail"]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_assigning_a_printer_is_refused(self, async_client, db_session, sliced_file_factory, printer_factory):
+        """The edit dialog offers a printer picker for every queue item. Taking it
+        would leave a row with variants AND a printer_id — and the fixed-printer
+        branch of the scheduler wins that race, dispatching a row whose
+        library_file_id is still null."""
+        printer = await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"printer_id": printer.id})
+        assert r.status_code == 400
+        assert "alternatives" in r.json()["detail"]
+
+        assert len(await _variants_of(db_session, item_id)) == 2, "the alternatives survive the refusal"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_narrowing_to_one_model_is_refused(self, async_client, sliced_file_factory, printer_factory):
+        """Saving "Any H2C" over a two-candidate job would silently discard the
+        H2S alternative the user deliberately queued."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"target_model": "H2C"})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resending_the_unchanged_model_is_allowed(self, async_client, sliced_file_factory, printer_factory):
+        """The edit dialog re-sends target_model on every save, so an unchanged
+        value must not block editing the schedule or print options."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        created = (await _queue_variants(async_client, h2s.id, h2c.id)).json()
+
+        r = await async_client.patch(
+            f"/api/v1/queue/{created['id']}",
+            json={"target_model": created["target_model"], "timelapse": True},
+        )
+        assert r.status_code == 200
+        assert r.json()["timelapse"] is True
+        assert len(r.json()["variants"]) == 2, "the response still carries the alternatives"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_quantity_gives_each_copy_its_own_candidates(

+ 143 - 0
frontend/src/__tests__/components/PrintModalCrossModel.test.tsx

@@ -0,0 +1,143 @@
+/**
+ * PrintModal in cross-model mode (#671).
+ *
+ * Selecting several sliced files puts the modal in model-based assignment with
+ * no single target model. That combination used to fall through every gate the
+ * override UI depends on, leaving the user with *less* control than the
+ * ordinary "Any X1C" flow — no AMS mapping (correct, there is no printer yet)
+ * and no filament override either (wrong).
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrintModal } from '../../components/PrintModal';
+
+const CANDIDATES = [
+  { id: 11, filename: 'x1c.gcode.3mf', sliced_for_model: 'X1C' },
+  { id: 12, filename: 'h2d.gcode.3mf', sliced_for_model: 'H2D' },
+];
+
+/** Loaded filaments differ per model — the union is what the user may pick.
+ *  The dropdown only ever offers the slot's own material (overriding PLA with
+ *  PETG is not a colour choice), so the spool that proves the union works has
+ *  to be a PLA the X1C does not have. */
+const BY_MODEL: Record<string, Array<Record<string, unknown>>> = {
+  X1C: [{ type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null }],
+  H2D: [
+    { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+    { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Matte', extruder_id: null },
+  ],
+};
+
+function mockBackend() {
+  server.use(
+    http.get('/api/v1/printers/', () =>
+      HttpResponse.json([
+        { id: 1, name: 'X1C-1', model: 'X1C', ip_address: '10.0.0.1', is_active: true, enabled: true },
+        { id: 2, name: 'H2D-1', model: 'H2D', ip_address: '10.0.0.2', is_active: true, enabled: true },
+      ]),
+    ),
+    http.get('/api/v1/printers/available-filaments', ({ request }) => {
+      const model = new URL(request.url).searchParams.get('model') ?? '';
+      return HttpResponse.json(BY_MODEL[model] ?? []);
+    }),
+    http.get('/api/v1/library/files/:id', ({ params }) =>
+      HttpResponse.json({
+        id: Number(params.id),
+        filename: 'x1c.gcode.3mf',
+        file_type: 'gcode.3mf',
+        sliced_for_model: 'X1C',
+      }),
+    ),
+    http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+      HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
+    ),
+    http.get('/api/v1/library/files/:id/filament-requirements', () =>
+      HttpResponse.json({
+        filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 15, used_meters: 5 }],
+      }),
+    ),
+  );
+}
+
+function renderCrossModel() {
+  render(
+    <PrintModal
+      mode="create"
+      libraryFileId={CANDIDATES[0].id}
+      variantFiles={CANDIDATES}
+      archiveName="bracket"
+      onClose={() => {}}
+    />,
+  );
+}
+
+describe('PrintModal cross-model mode', () => {
+  beforeEach(() => mockBackend());
+
+  it('replaces the printer picker with the candidate list', async () => {
+    renderCrossModel();
+    expect(await screen.findByText('x1c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('h2d.gcode.3mf')).toBeInTheDocument();
+    // Choosing these files already answered "which printer".
+    expect(screen.queryByText('Select Printer')).not.toBeInTheDocument();
+  });
+
+  it('offers filament overrides drawn from every candidate model', async () => {
+    renderCrossModel();
+
+    expect(await screen.findByText('Filament Override')).toBeInTheDocument();
+
+    // PLA Matte is loaded only on the H2D. It has to be offered anyway: the job
+    // can land there, and choosing it simply narrows which candidates match.
+    await waitFor(() => {
+      const options = screen.getAllByRole('option').map((o) => o.textContent ?? '');
+      expect(options.some((o) => o.includes('PLA Matte'))).toBe(true);
+      expect(options.some((o) => o.includes('PLA Basic'))).toBe(true);
+    });
+  });
+
+  it('shows a queued job its alternatives instead of a printer picker', async () => {
+    // Before this, editing a cross-model item showed "Any H2D" with a live
+    // Target Model dropdown and a Specific Printer toggle. Saving that left a
+    // row with variants AND a printer_id, and the fixed-printer branch of the
+    // scheduler wins — dispatching a row whose library_file_id is still null.
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        libraryFileId={CANDIDATES[0].id}
+        archiveName="bracket"
+        queueItem={
+          {
+            id: 9,
+            printer_id: null,
+            target_model: 'H2D',
+            status: 'pending',
+            variants: [
+              { library_file_id: 12, filename: 'h2d.gcode.3mf', target_model: 'H2D', position: 0 },
+              { library_file_id: 11, filename: 'x1c.gcode.3mf', target_model: 'X1C', position: 1 },
+            ],
+          } as never
+        }
+        onClose={() => {}}
+      />,
+    );
+
+    expect(await screen.findByText('h2d.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('x1c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.queryByText('Target Model')).not.toBeInTheDocument();
+    // Read-only: reordering after queueing would need a variant-level API.
+    expect(screen.queryByLabelText('Move down')).not.toBeInTheDocument();
+  });
+
+  it('shows no AMS slot mapping, because no printer has been chosen yet', async () => {
+    renderCrossModel();
+    await screen.findByText('x1c.gcode.3mf');
+    // The scheduler derives the mapping against whichever printer it picks —
+    // collecting tray numbers here would only be thrown away.
+    expect(screen.queryByText('Filament Mapping')).not.toBeInTheDocument();
+  });
+});

+ 2 - 1
frontend/src/components/CompactHistoryRow.tsx

@@ -16,6 +16,7 @@ import { api } from '../api/client';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, Permission } from '../api/client';
 import { Button } from './Button';
+import { queueItemDisplayName } from '../utils/queueItemName';
 
 const STATUS_CONFIG = {
   completed: { icon: CheckCircle, color: 'text-emerald-600 dark:text-emerald-400', border: 'border-l-emerald-500' },
@@ -54,7 +55,7 @@ export function CompactHistoryRow({
 }) {
   const config = STATUS_CONFIG[item.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.cancelled;
   const StatusIcon = config.icon;
-  const displayName = item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`;
+  const displayName = queueItemDisplayName(item);
 
   const thumbnailUrl = item.archive_thumbnail
     ? api.getArchiveThumbnail(item.archive_id!)

+ 41 - 26
frontend/src/components/PrintModal/VariantCandidates.tsx

@@ -17,6 +17,11 @@ interface VariantCandidatesProps {
   /** file id -> chosen plate, for the multi-plate candidates only. */
   plateByFile: Record<number, number | null>;
   onPlateChange: (fileId: number, plateId: number | null) => void;
+  /** Show the set without offering to change it — the edit-queue-item case,
+   *  where reordering would need a variant-level API that doesn't exist. */
+  readOnly?: boolean;
+  /** Replaces the help line under the heading when read-only. */
+  readOnlyNote?: string;
 }
 
 /**
@@ -40,15 +45,21 @@ export function VariantCandidates({
   onReorder,
   plateByFile,
   onPlateChange,
+  readOnly = false,
+  readOnlyNote,
 }: VariantCandidatesProps) {
   const { t } = useTranslation();
 
   const plateQueries = useQueries({
-    queries: candidates.map((c) => ({
-      queryKey: ['library-file-plates', c.id],
-      queryFn: () => api.getLibraryFilePlates(c.id),
-      staleTime: 60_000,
-    })),
+    // Read-only mode shows a job that is already queued — its plates were
+    // chosen when it was created, so there is nothing to fetch or offer.
+    queries: readOnly
+      ? []
+      : candidates.map((c) => ({
+          queryKey: ['library-file-plates', c.id],
+          queryFn: () => api.getLibraryFilePlates(c.id),
+          staleTime: 60_000,
+        })),
   });
 
   const platesByFile = useMemo(() => {
@@ -76,7 +87,9 @@ export function VariantCandidates({
         <PrinterIcon className="w-4 h-4 text-bambu-gray" />
         <span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
       </div>
-      <p className="text-xs text-bambu-gray mb-2">{t('printModal.variants.help')}</p>
+      <p className="text-xs text-bambu-gray mb-2">
+        {readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
+      </p>
 
       <div className="space-y-2">
         {candidates.map((candidate, index) => {
@@ -116,26 +129,28 @@ export function VariantCandidates({
                 </label>
               )}
 
-              <div className="flex items-center gap-1 shrink-0">
-                <button
-                  type="button"
-                  onClick={() => move(index, index - 1)}
-                  disabled={index === 0}
-                  className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
-                  aria-label={t('printModal.variants.moveUp')}
-                >
-                  <ArrowUp className="w-3.5 h-3.5" />
-                </button>
-                <button
-                  type="button"
-                  onClick={() => move(index, index + 1)}
-                  disabled={index === candidates.length - 1}
-                  className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
-                  aria-label={t('printModal.variants.moveDown')}
-                >
-                  <ArrowDown className="w-3.5 h-3.5" />
-                </button>
-              </div>
+              {!readOnly && (
+                <div className="flex items-center gap-1 shrink-0">
+                  <button
+                    type="button"
+                    onClick={() => move(index, index - 1)}
+                    disabled={index === 0}
+                    className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
+                    aria-label={t('printModal.variants.moveUp')}
+                  >
+                    <ArrowUp className="w-3.5 h-3.5" />
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => move(index, index + 1)}
+                    disabled={index === candidates.length - 1}
+                    className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
+                    aria-label={t('printModal.variants.moveDown')}
+                  >
+                    <ArrowDown className="w-3.5 h-3.5" />
+                  </button>
+                </div>
+              )}
             </div>
           );
         })}

+ 70 - 3
frontend/src/components/PrintModal/index.tsx

@@ -73,6 +73,21 @@ export function PrintModal({
   // Cross-model alternatives (#671). One candidate is not a choice, so a
   // single-entry list behaves exactly like an ordinary print.
   const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
+  // Editing an already-queued cross-model item. The candidates are shown so the
+  // dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
+  // did before, offering a printer picker whose Save would have left a row with
+  // both variants and a printer_id. They are not editable here: changing the
+  // set after queueing needs a variant-level API that doesn't exist, and the
+  // backend refuses the printer/model change either way.
+  const editingVariants: VariantCandidate[] =
+    mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1
+      ? queueItem!.variants!.map((v) => ({
+          id: v.library_file_id,
+          filename: v.filename,
+          sliced_for_model: v.target_model,
+        }))
+      : [];
+  const hasEditingVariants = editingVariants.length > 0;
   const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
   const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
 
@@ -398,6 +413,42 @@ export function PrintModal({
     enabled: assignmentMode === 'model' && !!targetModel,
   });
 
+  // A cross-model job (#671) has no single target model, so the query above is
+  // disabled and the override UI would silently vanish — leaving less control
+  // than the ordinary "Any X1C" flow offers. Ask each candidate's model instead
+  // and offer the union: the job can land on any of them, so anything loaded on
+  // any of them is a legitimate choice. Picking one only some models have is
+  // allowed and meaningful — it narrows which candidates can match.
+  const candidateModels = useMemo(
+    () => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))),
+    [candidates],
+  );
+  const candidateFilamentQueries = useQueries({
+    queries: isCrossModel
+      ? candidateModels.map((model) => ({
+          queryKey: ['available-filaments', model, targetLocation],
+          queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined),
+        }))
+      : [],
+  });
+  const crossModelFilaments = useMemo(() => {
+    const seen = new Set<string>();
+    const merged: NonNullable<typeof availableFilaments> = [];
+    for (const query of candidateFilamentQueries) {
+      for (const filament of query.data ?? []) {
+        // Same type+colour loaded on two models is one choice, not two.
+        const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`;
+        if (!seen.has(key)) {
+          seen.add(key);
+          merged.push(filament);
+        }
+      }
+    }
+    return merged;
+  }, [candidateFilamentQueries]);
+
+  const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments;
+
   // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
     queryKey: ['printer-status', effectivePrinterId],
@@ -1261,8 +1312,13 @@ export function PrintModal({
   // is the filament each slot must be printed in, which the scheduler matches
   // against whatever printer of the model it picks. Needs the model's loaded
   // filaments to offer as alternatives.
+  // Cross-model items have no targetModel by design — their candidates each
+  // carry their own — so gate on having somewhere to source choices from.
   const showFilamentOverride =
-    assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
+    assignmentMode === 'model'
+    && (isCrossModel || !!targetModel)
+    && !!effectiveAvailableFilaments
+    && effectiveAvailableFilaments.length > 0;
 
   // Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
   // Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
@@ -1371,8 +1427,19 @@ export function PrintModal({
               />
             )}
 
+            {hasEditingVariants && (
+              <VariantCandidates
+                candidates={editingVariants}
+                readOnly
+                readOnlyNote={t('printModal.variants.editNote')}
+                onReorder={() => {}}
+                plateByFile={{}}
+                onPlateChange={() => {}}
+              />
+            )}
+
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
-            {!isCrossModel && !initialSelectedPrinterIds?.length && (
+            {!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
               <PrinterSelector
                 printers={printers || []}
                 selectedPrinterIds={selectedPrinters}
@@ -1404,7 +1471,7 @@ export function PrintModal({
             {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
               <FilamentOverride
                 filamentReqs={effectiveFilamentReqs}
-                availableFilaments={availableFilaments!}
+                availableFilaments={effectiveAvailableFilaments!}
                 overrides={filamentOverrides}
                 onChange={setFilamentOverrides}
                 forceColorMatch={forceColorMatch}

+ 2 - 1
frontend/src/components/PrinterQueueWidget.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { formatRelativeTime } from '../utils/date';
 import { filterCompatibleQueueItems } from '../utils/printer';
+import { queueItemDisplayName } from '../utils/queueItemName';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
@@ -53,7 +54,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
           <div className="min-w-0 flex-1">
             <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-sm text-white truncate">
-              {nextItem?.archive_name || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}
+              {nextItem ? queueItemDisplayName(nextItem) : ''}
             </p>
           </div>
         </div>

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} weitere',
     save: 'Speichern',
     saving: 'Speichern...',
     cancel: 'Abbrechen',
@@ -4636,6 +4637,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'Diese Alternativen wurden beim Einreihen festgelegt. Zum Ändern abbrechen und neu einreihen.',
       title: 'Drucker-Alternativen',
       help: 'Ein Auftrag, ein Warteschlangenplatz. Der erste passende Drucker, der frei wird, druckt seine Datei.',
       unknownModel: 'Unbekanntes Modell',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} more',
     save: 'Save',
     saving: 'Saving...',
     cancel: 'Cancel',
@@ -4679,6 +4680,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'These alternatives were set when the job was queued. Cancel and re-queue to change them.',
       title: 'Printer alternatives',
       help: 'One job, one queue slot. The first matching printer to free up runs its file.',
       unknownModel: 'Unknown model',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} más',
     save: 'Guardar',
     saving: 'Guardando...',
     cancel: 'Cancelar',
@@ -4644,6 +4645,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'Estas alternativas se fijaron al poner el trabajo en cola. Cancela y vuelve a encolar para cambiarlas.',
       title: 'Alternativas de impresora',
       help: 'Un trabajo, un puesto en la cola. La primera impresora compatible que quede libre imprime su archivo.',
       unknownModel: 'Modelo desconocido',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} autres',
     save: 'Enregistrer',
     saving: 'Enregistrement...',
     cancel: 'Annuler',
@@ -4625,6 +4626,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'Ces alternatives ont été définies lors de la mise en file. Annulez et remettez en file pour les modifier.',
       title: 'Alternatives d\'imprimante',
       help: 'Un travail, une place dans la file. La première imprimante compatible qui se libère imprime son fichier.',
       unknownModel: 'Modèle inconnu',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} altri',
     save: 'Salva',
     saving: 'Salvataggio...',
     cancel: 'Annulla',
@@ -4624,6 +4625,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'Queste alternative sono state definite al momento dell\'accodamento. Annulla e riaccoda per modificarle.',
       title: 'Alternative di stampante',
       help: 'Un lavoro, un posto in coda. La prima stampante compatibile che si libera stampa il suo file.',
       unknownModel: 'Modello sconosciuto',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '他{{count}}件',
     save: '保存',
     saving: '保存中...',
     cancel: 'キャンセル',
@@ -4636,6 +4637,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'これらの候補はキュー追加時に決まります。変更するにはキャンセルして追加し直してください。',
       title: 'プリンターの候補',
       help: '1つのジョブ、キューは1枠。条件に合う最初に空いたプリンターがそのファイルを印刷します。',
       unknownModel: '不明なモデル',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -30,6 +30,7 @@ export default {
     installAppSuccess: 'Bambuddy가 설치되었습니다'
   },
   common: {
+    plusNMore: '외 {{count}}개',
     save: '저장',
     saving: '저장 중...',
     cancel: '취소',
@@ -4408,6 +4409,7 @@ export default {
   },
   printModal: {
     variants: {
+      editNote: '이 대안은 대기열에 추가할 때 정해집니다. 변경하려면 취소 후 다시 추가하세요.',
       title: '프린터 대안',
       help: '작업 하나, 대기열 한 자리. 조건이 맞는 프린터 중 먼저 비는 프린터가 해당 파일을 인쇄합니다.',
       unknownModel: '알 수 없는 모델',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} outros',
     save: 'Salvar',
     saving: 'Salvando...',
     cancel: 'Cancelar',
@@ -4624,6 +4625,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'Estas alternativas foram definidas ao enfileirar o trabalho. Cancele e enfileire de novo para alterá-las.',
       title: 'Alternativas de impressora',
       help: 'Um trabalho, uma vaga na fila. A primeira impressora compatível que ficar livre imprime o arquivo dela.',
       unknownModel: 'Modelo desconhecido',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -30,6 +30,7 @@ export default {
     installAppSuccess: "Bambuddy установлен",
   },
   common: {
+    plusNMore: 'ещё {{count}}',
     save: "Сохранить",
     saving: "Сохранение...",
     cancel: "Отмена",
@@ -4397,6 +4398,7 @@ export default {
   },
   printModal: {
     variants: {
+      editNote: 'Эти варианты заданы при добавлении в очередь. Чтобы изменить их, отмените и добавьте заново.',
       title: 'Варианты принтера',
       help: 'Одна задача, одно место в очереди. Первый подходящий освободившийся принтер напечатает свой файл.',
       unknownModel: 'Неизвестная модель',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -33,6 +33,7 @@ export default {
 
   // Ortak
   common: {
+    plusNMore: '+{{count}} tane daha',
     save: 'Kaydet',
     saving: 'Kaydediliyor...',
     cancel: 'İptal',
@@ -4614,6 +4615,7 @@ export default {
   // Baskı modali
   printModal: {
     variants: {
+      editNote: 'Bu alternatifler iş kuyruğa alınırken belirlendi. Değiştirmek için iptal edip yeniden kuyruğa alın.',
       title: 'Yazıcı alternatifleri',
       help: 'Tek iş, tek kuyruk yeri. Uygun olan ilk boşalan yazıcı kendi dosyasını yazdırır.',
       unknownModel: 'Bilinmeyen model',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: 'ще {{count}}',
     save: "Зберегти",
     saving: "Збереження...",
     cancel: "Скасувати",
@@ -4679,6 +4680,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: 'Ці варіанти задано під час додавання в чергу. Щоб змінити, скасуйте та додайте знову.',
       title: 'Варіанти принтера',
       help: 'Одне завдання, одне місце в черзі. Перший відповідний принтер, що звільниться, надрукує свій файл.',
       unknownModel: 'Невідома модель',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '另 {{count}} 个',
     save: '保存',
     saving: '保存中...',
     cancel: '取消',
@@ -4624,6 +4625,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: '这些备选在任务加入队列时确定。如需更改,请取消后重新加入队列。',
       title: '打印机备选',
       help: '一个任务,占一个队列位。第一台空闲且匹配的打印机会打印它对应的文件。',
       unknownModel: '未知型号',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '另 {{count}} 個',
     save: '儲存',
     saving: '儲存中...',
     cancel: '取消',
@@ -4624,6 +4625,7 @@ export default {
   // Print modal
   printModal: {
     variants: {
+      editNote: '這些備選在工作加入佇列時確定。如需變更,請取消後重新加入佇列。',
       title: '印表機備選',
       help: '一項工作,佔一個佇列位。第一台空閒且相符的印表機會列印它對應的檔案。',
       unknownModel: '未知型號',

+ 7 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -2816,7 +2816,13 @@ export function FileManagerPage() {
           mode="create"
           libraryFileId={printVariantFiles?.[0]?.id ?? printFile.id}
           variantFiles={printVariantFiles}
-          archiveName={printFile.print_name || printFile.filename}
+          // Naming a cross-model job after one of its files reads as though the
+          // others aren't part of it.
+          archiveName={
+            printVariantFiles && printVariantFiles.length > 1
+              ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}`
+              : printFile.print_name || printFile.filename
+          }
           onClose={() => setPrintFile(null)}
           onSuccess={() => {
             setPrintFile(null);

+ 18 - 3
frontend/src/pages/QueuePage.tsx

@@ -19,6 +19,7 @@ import {
   verticalListSortingStrategy,
 } from '@dnd-kit/sortable';
 import { CSS } from '@dnd-kit/utilities';
+import { queueItemDisplayName } from '../utils/queueItemName';
 import {
   Clock,
   Trash2,
@@ -576,7 +577,7 @@ function SortableQueueItem({
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-sm sm:text-base text-white font-medium truncate">
-              {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
+              {queueItemDisplayName(item, (n) => t('common.plusNMore', { count: n }))}
               {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
             </p>
             {item.archive_id ? (
@@ -2195,6 +2196,20 @@ export function QueuePage() {
           isUnassigned: false,
         };
       }
+      // A cross-model item (#671) is waiting on several models. Its own
+      // target_model is just the first candidate mirrored onto the row, so
+      // bucketing on it would file the job under one printer it might never
+      // run on — and the row underneath already says "Any H2D / X1C".
+      if ((item.variants?.length ?? 0) > 1) {
+        const models = item.variants!.map((v) => v.target_model).join(' / ');
+        return {
+          key: `models:${models}`,
+          label: `${t('queue.filter.any')} ${models}`,
+          printerId: null,
+          targetModel: null,
+          isUnassigned: false,
+        };
+      }
       if (item.target_model) {
         return {
           key: `model:${item.target_model}`,
@@ -2792,7 +2807,7 @@ export function QueuePage() {
           mode="edit-queue-item"
           archiveId={editItem.archive_id ?? undefined}
           libraryFileId={editItem.library_file_id ?? undefined}
-          archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
+          archiveName={queueItemDisplayName(editItem, (n) => t('common.plusNMore', { count: n }))}
           queueItem={editItem}
           onClose={() => setEditItem(null)}
         />
@@ -2804,7 +2819,7 @@ export function QueuePage() {
           mode="create"
           archiveId={requeueItem.archive_id ?? undefined}
           libraryFileId={requeueItem.library_file_id ?? undefined}
-          archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
+          archiveName={queueItemDisplayName(requeueItem, (n) => t('common.plusNMore', { count: n }))}
           onClose={() => setRequeueItem(null)}
         />
       )}

+ 44 - 0
frontend/src/utils/queueItemName.ts

@@ -0,0 +1,44 @@
+/**
+ * Display name for a queue item, wherever one is shown.
+ *
+ * Every surface used to inline the same fallback chain, which produced
+ * `File #null` for a cross-model item (#671): those deliberately hold neither
+ * `archive_id` nor `library_file_id` until dispatch resolves a candidate, so
+ * that the ON DELETE CASCADE on `library_file_id` can't destroy the whole job
+ * when one alternative is deleted. Nothing to point at is the design working;
+ * the label just had nowhere to look.
+ */
+
+interface NameableQueueItem {
+  archive_name?: string | null;
+  library_file_name?: string | null;
+  archive_id?: number | null;
+  library_file_id?: number | null;
+  variants?: Array<{ filename: string }>;
+}
+
+/**
+ * @param item      the queue item to name
+ * @param moreLabel formats the "+N more" suffix for a cross-model item; pass
+ *                  the caller's `t` binding so the count stays translated.
+ *                  Omitted in compact surfaces that only have room for a name.
+ */
+export function queueItemDisplayName(
+  item: NameableQueueItem,
+  moreLabel?: (count: number) => string,
+): string {
+  if (item.archive_name) return item.archive_name;
+  if (item.library_file_name) return item.library_file_name;
+
+  // Cross-model item: name it after the candidate the user put first — the one
+  // the scheduler will try first — and say how many others are behind it.
+  const variants = item.variants ?? [];
+  if (variants.length > 0) {
+    const first = variants[0].filename;
+    const others = variants.length - 1;
+    if (others > 0 && moreLabel) return `${first} ${moreLabel(others)}`;
+    return first;
+  }
+
+  return `File #${item.archive_id ?? item.library_file_id}`;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Dg-sOUv0.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-p7qlPsqM.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dg-sOUv0.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов