Jelajahi Sumber

fix(archives): let Items Printed go to 0 for a ruined plate (issue #3051)

    A jam can destroy everything on the plate while the printer still reports the
    job as a success, so the honest count of usable parts is zero. The edit dialog
    floored the field at one, and a project's completed-items count sums that
    column, so there was no way to record that a job produced nothing.

    The floor was in the dialog only; the API stored whatever it was given, which
    also meant a negative count was accepted and would have subtracted from the
    project totals. The column is now bounded at zero instead.

    Filament Trends counted prints as `quantity || 1`, which would have read a
    deliberate 0 as "unset" and charged the ruined plate as one print while the
    project page counted none.
maziggy 4 hari lalu
induk
melakukan
ad3b295312

+ 5 - 1
backend/app/schemas/archive.py

@@ -18,7 +18,11 @@ class ArchiveBase(BaseModel):
     notes: str | None = None
     cost: float | None = None
     failure_reason: str | None = None
-    quantity: int | None = None  # Number of items printed
+    # Number of items printed. 0 is a legal answer -- a plate that jammed and
+    # came off ruined produced nothing, and the project's completed-items count
+    # sums this column (#3051). Bounded for the same reason as the grams below:
+    # it feeds project totals, and a negative would subtract from them.
+    quantity: Annotated[int | None, Field(ge=0, le=10_000)] = None
     # User-defined link (Printables, Thingiverse, etc.)
     external_url: str | None = None
 

+ 33 - 0
backend/tests/integration/test_archives_api.py

@@ -902,6 +902,39 @@ class TestArchivesAPI:
         assert response.status_code == 200, response.text
         assert response.json()["filament_used_grams"] == 12.5
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_items_printed_accepts_zero_for_a_ruined_plate(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """A jam can ruin every part on the plate while the printer still
+        reports success (#3051). The project's completed-items count sums this
+        column, so zero has to be storable, not floored to one.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, quantity=4)
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": 0})
+
+        assert response.status_code == 200, response.text
+        assert response.json()["quantity"] == 0
+        await db_session.refresh(archive)
+        assert archive.quantity == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_items_printed_refuses_a_negative_count(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """Zero means "nothing came off the plate"; below that would subtract
+        from the project totals this column feeds."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, quantity=4)
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": -1})
+
+        assert response.status_code == 422, response.text
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_archive_failure_reason_mirrors_to_print_log_entry(

TEMPAT SAMPAH
frontend/public/img/sponsors/northpole-3d-printing.jpg


TEMPAT SAMPAH
frontend/public/img/sponsors/notify.png


+ 68 - 0
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -520,4 +520,72 @@ describe('EditArchiveModal', () => {
       await waitFor(() => expect(seen.body?.project_id).toBe(2));
     });
   });
+  describe('items printed (#3051)', () => {
+    // A plate that jammed and came off ruined produced nothing, even when the
+    // printer called the job a success. The project's completed-items count
+    // sums this column, so 0 has to be typeable.
+
+    function patchSpy() {
+      const seen: { body?: Record<string, unknown> } = {};
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          seen.body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockArchive, ...seen.body });
+        }),
+      );
+      return seen;
+    }
+
+    it('sends 0 for a plate that produced nothing', async () => {
+      const user = userEvent.setup();
+      const seen = patchSpy();
+
+      render(
+        <EditArchiveModal
+          archive={{ ...mockArchive, quantity: 4 }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+      const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
+      await user.clear(field);
+      await user.type(field, '0');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => expect(seen.body?.quantity).toBe(0));
+    });
+
+    it('does not floor a cleared field back to 1', async () => {
+      const user = userEvent.setup();
+
+      render(
+        <EditArchiveModal
+          archive={{ ...mockArchive, quantity: 4 }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+      const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
+      await user.clear(field);
+
+      expect(field.value).toBe('0');
+    });
+
+    it('still refuses a negative count', async () => {
+      const user = userEvent.setup();
+
+      render(
+        <EditArchiveModal
+          archive={{ ...mockArchive, quantity: 4 }}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />,
+      );
+      const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
+      await user.clear(field);
+      await user.type(field, '-3');
+
+      expect(Number(field.value)).toBeGreaterThanOrEqual(0);
+    });
+  });
 });

+ 55 - 0
frontend/src/__tests__/components/FilamentTrends.test.tsx

@@ -0,0 +1,55 @@
+/**
+ * Tests for the FilamentTrends widget's print counting.
+ *
+ * An archive edited down to 0 items produced nothing (#3051). The count here
+ * used to read `quantity || 1`, which turned that 0 back into one print and
+ * left this widget contradicting the project page.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { FilamentTrends } from '../../components/FilamentTrends';
+import type { ArchiveSlim } from '../../api/client';
+
+const archive = (overrides: Partial<ArchiveSlim>): ArchiveSlim => ({
+  printer_id: 1,
+  print_name: 'Benchy',
+  print_time_seconds: 3600,
+  actual_time_seconds: 3600,
+  filament_used_grams: 20,
+  filament_type: 'PLA',
+  filament_color: '#00ae42',
+  status: 'completed',
+  started_at: '2026-09-06T10:00:00Z',
+  completed_at: '2026-09-06T11:00:00Z',
+  cost: 1,
+  energy_kwh: 0.1,
+  energy_cost: 0.02,
+  quantity: 1,
+  created_at: '2026-09-06T10:00:00Z',
+  ...overrides,
+});
+
+/** The "<n> prints" caption under the summary heading. */
+const printCount = () =>
+  screen
+    .getAllByText((_, el) => el?.tagName === 'P' && /^\d+ prints$/.test(el.textContent ?? ''))
+    .map((el) => el.textContent)[0];
+
+describe('FilamentTrends print count (#3051)', () => {
+  it('counts a ruined plate as zero prints, not one', () => {
+    render(<FilamentTrends archives={[archive({ quantity: 0 }), archive({ quantity: 3 })]} />);
+
+    expect(printCount()).toBe('3 prints');
+  });
+
+  it('still treats a missing quantity as a single print', () => {
+    const noQuantity = archive({});
+    delete (noQuantity as Partial<ArchiveSlim>).quantity;
+
+    render(<FilamentTrends archives={[noQuantity as ArchiveSlim]} />);
+
+    expect(printCount()).toBe('1 prints');
+  });
+});

+ 80 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -950,4 +950,84 @@ describe('QueuePage', () => {
       expect(screen.getAllByTitle('Move Down')[2]).toBeDisabled();
     });
   });
+  describe('bulk edit G-code injection (#3058)', () => {
+    /**
+     * gcode_injection is a per-item column the single-item edit modal has
+     * always exposed, and PATCH /queue/bulk has always accepted — only the
+     * bulk dialog left it out, so turning injection on for a whole queue
+     * meant opening every item.
+     */
+    const settingsWithSnippets = {
+      auto_archive: true,
+      gcode_snippets: '{"X1C":{"start_gcode":"","end_gcode":"M400"}}',
+    };
+
+    /** Select the one pending row and open the bulk edit dialog. */
+    const openBulkEdit = async () => {
+      render(<QueuePage />);
+      await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
+      await userEvent.click(screen.getByText('Select All'));
+      await userEvent.click(await screen.findByTitle('Edit Selected'));
+      return screen.getByText('Edit 1 Item').closest('div')!.parentElement!;
+    };
+
+    it('offers the toggle when a G-code snippet is configured', async () => {
+      server.use(http.get('/api/v1/settings/', () => HttpResponse.json(settingsWithSnippets)));
+
+      await openBulkEdit();
+
+      expect(await screen.findByText('Inject G-code')).toBeInTheDocument();
+    });
+
+    it('hides the toggle when no snippet is configured', async () => {
+      await openBulkEdit();
+
+      // The dialog is open — other queue options are there, injection is not.
+      expect(screen.getByText('Staged (manual start)')).toBeInTheDocument();
+      expect(screen.queryByText('Inject G-code')).not.toBeInTheDocument();
+    });
+
+    it('sends gcode_injection with the bulk PATCH once toggled on', async () => {
+      let patchBody: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(settingsWithSnippets)),
+        http.patch('/api/v1/queue/bulk', async ({ request }) => {
+          patchBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' });
+        }),
+      );
+
+      const dialog = await openBulkEdit();
+
+      const row = (await screen.findByText('Inject G-code')).closest('div')!;
+      await userEvent.click(within(row).getByText('On'));
+      await userEvent.click(within(dialog).getByText('Apply Changes'));
+
+      await waitFor(() => expect(patchBody).not.toBeNull());
+      expect(patchBody!.item_ids).toEqual([1]);
+      expect(patchBody!.gcode_injection).toBe(true);
+    });
+
+    it('leaves gcode_injection out of the PATCH while it stays on "no change"', async () => {
+      let patchBody: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(settingsWithSnippets)),
+        http.patch('/api/v1/queue/bulk', async ({ request }) => {
+          patchBody = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' });
+        }),
+      );
+
+      const dialog = await openBulkEdit();
+
+      // Change something else entirely; injection must not ride along as false.
+      const stagedRow = screen.getByText('Staged (manual start)').closest('div')!;
+      await userEvent.click(within(stagedRow).getByText('On'));
+      await userEvent.click(within(dialog).getByText('Apply Changes'));
+
+      await waitFor(() => expect(patchBody).not.toBeNull());
+      expect(patchBody!.manual_start).toBe(true);
+      expect('gcode_injection' in patchBody!).toBe(false);
+    });
+  });
 });

+ 7 - 3
frontend/src/components/EditArchiveModal.tsx

@@ -353,15 +353,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
 
           {/* Quantity - number of items printed */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">
+            <label className="block text-sm text-bambu-gray mb-1" htmlFor="archive-items-printed">
               <Hash className="w-4 h-4 inline mr-1" />
               {t('editArchive.itemsPrinted')}
             </label>
             <input
+              id="archive-items-printed"
               type="number"
-              min={1}
+              min={0}
               value={quantity}
-              onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
+              // 0 is a real answer, not an empty field: a plate that jammed and
+              // came off ruined produced nothing, and the project's completed
+              // count has to be able to say so (#3051).
+              onChange={(e) => setQuantity(Math.max(0, parseInt(e.target.value) || 0))}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               placeholder="1"
             />

+ 6 - 3
frontend/src/components/FilamentTrends.tsx

@@ -47,7 +47,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
       existing.energy += archive.energy_kwh || 0;
-      existing.prints += archive.quantity || 1;
+      existing.prints += archive.quantity ?? 1;
       dataMap.set(key, existing);
     });
 
@@ -88,7 +88,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
       existing.energy += archive.energy_kwh || 0;
-      existing.prints += archive.quantity || 1;
+      existing.prints += archive.quantity ?? 1;
       dataMap.set(key, existing);
     });
 
@@ -242,7 +242,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
   const totalEnergy = archives.reduce((sum, a) => sum + (a.energy_kwh || 0), 0);
   const totalEnergyCost = archives.reduce((sum, a) => sum + (a.energy_cost || 0), 0);
-  const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
+  // `??`, not `||`: an archive edited down to 0 produced nothing (#3051), and
+  // `0 || 1` would count the ruined plate as one print here while the project
+  // page correctly counts none.
+  const totalPrints = archives.reduce((sum, a) => sum + (a.quantity ?? 1), 0);
   const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
 
   return (

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

@@ -1513,6 +1513,7 @@ export default {
       staged: 'Bereitgestellt (manueller Start)',
       autoPowerOff: 'Nach Druck automatisch ausschalten',
       requirePrevious: 'Vorherigen Erfolg erfordern',
+      gcodeInjection: 'G-Code einfügen',
       printOptions: 'Druckoptionen',
       bedLevelling: 'Bett-Nivellierung',
       flowCalibration: 'Fluss-Kalibrierung',
@@ -5404,7 +5405,7 @@ export default {
     project: 'Projekt',
     noProject: 'Kein Projekt',
     itemsPrinted: 'Gedruckte Teile',
-    itemsPrintedHelp: 'Anzahl der in diesem Druckauftrag produzierten Teile',
+    itemsPrintedHelp: 'Anzahl der in diesem Druckauftrag produzierten Teile. 0, wenn die ganze Platte unbrauchbar war.',
     filamentUsed: 'Verbrauchtes Filament (g)',
     filamentUsedPlaceholder: 'z. B. 46.16',
     filamentUsedHelp: 'Von Hand eintragen, wenn ein Druck ohne 3MF archiviert wurde, damit er in die Filament-Summen eingeht. Beim erneuten Einlesen eines Archivs mit 3MF wird der Wert wieder aus der Datei übernommen.',

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

@@ -1529,6 +1529,7 @@ export default {
       staged: 'Staged (manual start)',
       autoPowerOff: 'Auto power off after print',
       requirePrevious: 'Require previous success',
+      gcodeInjection: 'Inject G-code',
       printOptions: 'Print Options',
       bedLevelling: 'Bed levelling',
       flowCalibration: 'Flow calibration',
@@ -5455,7 +5456,7 @@ export default {
     project: 'Project',
     noProject: 'No project',
     itemsPrinted: 'Items Printed',
-    itemsPrintedHelp: 'Number of items produced in this print job',
+    itemsPrintedHelp: 'Number of items produced in this print job. Use 0 if the whole plate was ruined.',
     filamentUsed: 'Filament used (g)',
     filamentUsedPlaceholder: 'e.g. 46.16',
     filamentUsedHelp: 'Set this by hand when a print archived without its 3MF, so it still counts towards the filament totals. Rescanning an archive that has its 3MF reads the figure back from the file.',

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

@@ -1513,6 +1513,7 @@ export default {
       staged: 'Preparado (inicio manual)',
       autoPowerOff: 'Apagado automático tras la impresión',
       requirePrevious: 'Requerir éxito previo',
+      gcodeInjection: 'Inyectar G-code',
       printOptions: 'Opciones de impresión',
       bedLevelling: 'Nivelación de la cama',
       flowCalibration: 'Calibración del flujo',
@@ -5412,7 +5413,7 @@ export default {
     project: 'Proyecto',
     noProject: 'Sin proyecto',
     itemsPrinted: 'Elementos impresos',
-    itemsPrintedHelp: 'Número de elementos producidos en este trabajo de impresión',
+    itemsPrintedHelp: 'Número de elementos producidos en este trabajo de impresión. Use 0 si toda la placa se estropeó.',
     filamentUsed: 'Filamento usado (g)',
     filamentUsedPlaceholder: 'p. ej. 46.16',
     filamentUsedHelp: 'Introdúcelo a mano cuando una impresión se haya archivado sin su 3MF, para que siga contando en los totales de filamento. Al volver a escanear un archivo que sí tiene su 3MF, el valor se lee de nuevo del fichero.',

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

@@ -1513,6 +1513,7 @@ export default {
       staged: 'Préparé (manuel)',
       autoPowerOff: 'Extinction auto après',
       requirePrevious: 'Requiert succès précédent',
+      gcodeInjection: 'Injecter le G-code',
       printOptions: 'Options d\'impression',
       bedLevelling: 'Nivellement plateau',
       flowCalibration: 'Calibration débit',
@@ -5394,7 +5395,7 @@ export default {
     project: 'Projet',
     noProject: 'Aucun projet',
     itemsPrinted: 'Nombre de pièces',
-    itemsPrintedHelp: 'Nombre d\'objets produits',
+    itemsPrintedHelp: 'Nombre d\'objets produits. Indiquez 0 si tout le plateau est raté.',
     filamentUsed: 'Filament utilisé (g)',
     filamentUsedPlaceholder: 'p. ex. 46.16',
     filamentUsedHelp: 'À saisir à la main lorsqu\'une impression a été archivée sans son 3MF, afin qu\'elle compte quand même dans les totaux de filament. Une nouvelle analyse d\'une archive qui possède son 3MF relit la valeur depuis le fichier.',

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

@@ -1513,6 +1513,7 @@ export default {
       staged: 'In staging (avvio manuale)',
       autoPowerOff: 'Spegnimento automatico dopo stampa',
       requirePrevious: 'Richiede successo precedente',
+      gcodeInjection: 'Inietta G-code',
       printOptions: 'Opzioni stampa',
       bedLevelling: 'Livellamento piatto',
       flowCalibration: 'Calibrazione flusso',
@@ -5393,7 +5394,7 @@ export default {
     project: 'Progetto',
     noProject: 'Nessun progetto',
     itemsPrinted: 'Elementi stampati',
-    itemsPrintedHelp: 'Numero di elementi prodotti in questo job di stampa',
+    itemsPrintedHelp: 'Numero di elementi prodotti in questo job di stampa. Usa 0 se l\'intero piatto è rovinato.',
     filamentUsed: 'Filamento usato (g)',
     filamentUsedPlaceholder: 'es. 46.16',
     filamentUsedHelp: 'Inseriscilo a mano quando una stampa è stata archiviata senza il suo 3MF, così da farla rientrare comunque nei totali del filamento. La riscansione di un archivio che ha il suo 3MF rilegge il valore dal file.',

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

@@ -1512,6 +1512,7 @@ export default {
       staged: 'ステージ済み',
       autoPowerOff: '印刷後に自動電源オフ',
       requirePrevious: '前の成功を必要とする',
+      gcodeInjection: 'G-codeを挿入',
       printOptions: '印刷オプション',
       bedLevelling: 'ベッドレベリング',
       flowCalibration: 'フローキャリブレーション',
@@ -5405,7 +5406,7 @@ export default {
     project: 'プロジェクト',
     noProject: 'プロジェクトなし',
     itemsPrinted: '印刷数',
-    itemsPrintedHelp: 'この印刷ジョブで製造したアイテム数',
+    itemsPrintedHelp: 'この印刷ジョブで製造したアイテム数。プレート全体が失敗した場合は 0 を入力します。',
     filamentUsed: '使用フィラメント (g)',
     filamentUsedPlaceholder: '例: 46.16',
     filamentUsedHelp: '3MF なしでアーカイブされた印刷は、手入力するとフィラメント合計に反映されます。3MF があるアーカイブを再スキャンすると、この値はファイルから読み直されます。',

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

@@ -1440,6 +1440,7 @@ export default {
       staged: '준비됨 (수동 시작)',
       autoPowerOff: '인쇄 후 자동 전원 끄기',
       requirePrevious: '이전 성공 필요',
+      gcodeInjection: 'G코드 삽입',
       printOptions: '인쇄 옵션',
       bedLevelling: '베드 레벨링',
       flowCalibration: '유량 캘리브레이션',
@@ -5154,7 +5155,7 @@ export default {
     project: '프로젝트',
     noProject: '프로젝트 없음',
     itemsPrinted: '인쇄된 항목',
-    itemsPrintedHelp: '이 인쇄 작업에서 생산된 항목 수',
+    itemsPrintedHelp: '이 인쇄 작업에서 생산된 항목 수. 플레이트 전체를 버린 경우 0을 입력하세요.',
     filamentUsed: '사용된 필라멘트 (g)',
     filamentUsedPlaceholder: '예: 46.16',
     filamentUsedHelp: '3MF 없이 보관된 인쇄는 직접 입력하면 필라멘트 합계에 반영됩니다. 3MF가 있는 보관 항목을 다시 스캔하면 이 값은 파일에서 다시 읽어옵니다.',

+ 2 - 1
frontend/src/i18n/locales/nl.ts

@@ -1529,6 +1529,7 @@ export default {
       staged: 'Gereed voor handmatige start',
       autoPowerOff: 'Automatisch uitschakelen na afdruk',
       requirePrevious: 'Vereis dat vorige taak slaagt',
+      gcodeInjection: 'G-code injecteren',
       printOptions: 'Afdrukopties',
       bedLevelling: 'Bednivellering',
       flowCalibration: 'Flowkalibratie',
@@ -5455,7 +5456,7 @@ export default {
     project: 'Project',
     noProject: 'Geen project',
     itemsPrinted: 'Afgedrukte items',
-    itemsPrintedHelp: 'Aantal items dat met deze afdruktaak is geproduceerd',
+    itemsPrintedHelp: 'Aantal items dat met deze afdruktaak is geproduceerd. Gebruik 0 als de hele plaat mislukt is.',
     filamentUsed: 'Gebruikt filament (g)',
     filamentUsedPlaceholder: 'bijv. 46.16',
     filamentUsedHelp: 'Stel dit handmatig in wanneer een afdruk zonder 3MF is gearchiveerd, zodat deze toch meetelt in de filamenttotalen. Bij het opnieuw scannen van een archief met een 3MF wordt de waarde weer uit het bestand gelezen.',

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

@@ -1513,6 +1513,7 @@ export default {
       staged: 'Preparado (início manual)',
       autoPowerOff: 'Desligamento automático após impressão',
       requirePrevious: 'Requer sucesso anterior',
+      gcodeInjection: 'Injetar G-code',
       printOptions: 'Opções de Impressão',
       bedLevelling: 'Nivelamento da Mesa',
       flowCalibration: 'Calibração de Fluxo',
@@ -5393,7 +5394,7 @@ export default {
     project: 'Projeto',
     noProject: 'Nenhum projeto',
     itemsPrinted: 'Itens Impressos',
-    itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão',
+    itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão. Use 0 se a placa inteira foi perdida.',
     filamentUsed: 'Filamento usado (g)',
     filamentUsedPlaceholder: 'ex.: 46.16',
     filamentUsedHelp: 'Informe manualmente quando uma impressão for arquivada sem o 3MF, para que ela ainda conte nos totais de filamento. Reescanear um arquivo que tem o 3MF lê o valor novamente do arquivo.',

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

@@ -1450,6 +1450,7 @@ export default {
       staged: "Подготовлено (ручной запуск)",
       autoPowerOff: "Отключить питание после печати",
       requirePrevious: "Требовать успешного завершения предыдущего задания",
+      gcodeInjection: "Вставлять G-код",
       printOptions: "Параметры печати",
       bedLevelling: "Калибровка стола",
       flowCalibration: "Калибровка потока",
@@ -5142,7 +5143,7 @@ export default {
     project: "Проект",
     noProject: "Без проекта",
     itemsPrinted: "Напечатано изделий",
-    itemsPrintedHelp: "Количество изделий, полученных в этом задании печати",
+    itemsPrintedHelp: "Количество изделий, полученных в этом задании печати. Укажите 0, если испорчена вся пластина.",
     filamentUsed: "Израсходованный филамент (г)",
     filamentUsedPlaceholder: "напр. 46.16",
     filamentUsedHelp: "Укажите вручную, если печать заархивирована без 3MF — тогда она попадёт в итоги по филаменту. При повторном сканировании архива с 3MF значение снова считывается из файла.",

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

@@ -1513,6 +1513,7 @@ export default {
       staged: 'Hazırlandı (manuel başlatma)',
       autoPowerOff: 'Baskı sonrası otomatik kapanma',
       requirePrevious: 'Önceki başarı gerekli',
+      gcodeInjection: 'G-kod enjekte et',
       printOptions: 'Baskı Seçenekleri',
       bedLevelling: 'Tabla seviyelendirme',
       flowCalibration: 'Akış kalibrasyonu',
@@ -5378,7 +5379,7 @@ export default {
     project: 'Proje',
     noProject: 'Proje yok',
     itemsPrinted: 'Yazdırılan Öğeler',
-    itemsPrintedHelp: 'Bu baskı işinde üretilen öğe sayısı',
+    itemsPrintedHelp: 'Bu baskı işinde üretilen öğe sayısı. Tüm tabla bozulduysa 0 girin.',
     filamentUsed: 'Kullanılan filament (g)',
     filamentUsedPlaceholder: 'ör. 46.16',
     filamentUsedHelp: 'Bir baskı 3MF dosyası olmadan arşivlendiyse elle girin; böylece filament toplamlarına yine de dahil olur. 3MF dosyası olan bir arşiv yeniden tarandığında bu değer dosyadan yeniden okunur.',

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

@@ -1528,6 +1528,7 @@ export default {
       staged: "Очікує ручного запуску",
       autoPowerOff: "Автоматичне вимкнення після друку",
       requirePrevious: "Вимагати успішного завершення попереднього завдання",
+      gcodeInjection: "Вставляти G-код",
       printOptions: "Параметри друку",
       bedLevelling: "Вирівнювання столу",
       flowCalibration: "Калібрування потоку",
@@ -5447,7 +5448,7 @@ export default {
     project: "Проєкт",
     noProject: "Жодного проєкту",
     itemsPrinted: "Надруковано об’єктів",
-    itemsPrintedHelp: "Кількість елементів, виготовлених у цьому завданні друку",
+    itemsPrintedHelp: "Кількість елементів, виготовлених у цьому завданні друку. Укажіть 0, якщо зіпсовано всю пластину.",
     filamentUsed: "Витрачений філамент (г)",
     filamentUsedPlaceholder: "напр. 46.16",
     filamentUsedHelp: "Вкажіть вручну, якщо друк заархівовано без 3MF — тоді він потрапить до підсумків з філаменту. Повторне сканування архіву, який має 3MF, зчитує значення з файлу.",

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

@@ -1513,6 +1513,7 @@ export default {
       staged: '暂存(手动开始)',
       autoPowerOff: '打印后自动关机',
       requirePrevious: '要求前一个成功',
+      gcodeInjection: '注入G-code',
       printOptions: '打印选项',
       bedLevelling: '热床调平',
       flowCalibration: '流量校准',
@@ -5393,7 +5394,7 @@ export default {
     project: '项目',
     noProject: '无项目',
     itemsPrinted: '打印数量',
-    itemsPrintedHelp: '此打印任务中生产的物品数量',
+    itemsPrintedHelp: '此打印任务中生产的物品数量。整块打印板报废时填 0。',
     filamentUsed: '已用耗材 (g)',
     filamentUsedPlaceholder: '例如 46.16',
     filamentUsedHelp: '当打印任务在没有 3MF 的情况下归档时,可手动填写,使其仍计入耗材总量。重新扫描带有 3MF 的归档时,该数值会从文件中重新读取。',

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

@@ -1513,6 +1513,7 @@ export default {
       staged: '暫存(手動開始)',
       autoPowerOff: '列印後自動關機',
       requirePrevious: '要求前一個成功',
+      gcodeInjection: '注入G-code',
       printOptions: '列印選項',
       bedLevelling: '熱床調平',
       flowCalibration: '流量校準',
@@ -5393,7 +5394,7 @@ export default {
     project: '專案',
     noProject: '無專案',
     itemsPrinted: '列印數量',
-    itemsPrintedHelp: '此列印任務中生產的物品數量',
+    itemsPrintedHelp: '此列印任務中生產的物品數量。整塊列印板報廢時填 0。',
     filamentUsed: '已用耗材 (g)',
     filamentUsedPlaceholder: '例如 46.16',
     filamentUsedHelp: '當列印工作在沒有 3MF 的情況下封存時,可手動填寫,使其仍計入耗材總量。重新掃描具有 3MF 的封存項目時,此數值會從檔案重新讀取。',

+ 12 - 1
frontend/src/pages/QueuePage.tsx

@@ -133,6 +133,7 @@ function BulkEditModal({
   onClose,
   isSaving,
   canControlPrinter,
+  hasGcodeSnippets,
   t,
 }: {
   selectedCount: number;
@@ -141,12 +142,14 @@ function BulkEditModal({
   onClose: () => void;
   isSaving: boolean;
   canControlPrinter: boolean;
+  hasGcodeSnippets: boolean;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
   const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
   const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
   const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
+  const [gcodeInjection, setGcodeInjection] = useState<boolean | 'unchanged'>('unchanged');
   const [bedLevelling, setBedLevelling] = useState<CalibrationMode | 'unchanged'>('unchanged');
   const [flowCali, setFlowCali] = useState<CalibrationMode | 'unchanged'>('unchanged');
   const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
@@ -166,6 +169,7 @@ function BulkEditModal({
     if (manualStart !== 'unchanged') data.manual_start = manualStart;
     if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
     if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
+    if (gcodeInjection !== 'unchanged') data.gcode_injection = gcodeInjection;
     if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
     if (flowCali !== 'unchanged') data.flow_cali = flowCali;
     if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
@@ -179,7 +183,7 @@ function BulkEditModal({
   const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
     requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
     vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' ||
-    nozzleOffsetCali !== 'unchanged';
+    nozzleOffsetCali !== 'unchanged' || gcodeInjection !== 'unchanged';
 
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
@@ -226,6 +230,12 @@ function BulkEditModal({
               <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
               <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} disabled={!canControlPrinter} t={t} />
               <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
+              {/* Same gate as the print modal's checkbox (#3058): hidden until an
+                  admin has saved a snippet for some printer model, so the toggle
+                  never promises an injection that has nothing to inject. */}
+              {hasGcodeSnippets && (
+                <TriStateToggle label={t('queue.bulkEdit.gcodeInjection')} value={gcodeInjection} onChange={setGcodeInjection} t={t} />
+              )}
             </div>
           </div>
 
@@ -2946,6 +2956,7 @@ export function QueuePage() {
           onClose={() => setShowBulkEditModal(false)}
           isSaving={bulkUpdateMutation.isPending}
           canControlPrinter={hasPermission('printers:control')}
+          hasGcodeSnippets={!!settings?.gcode_snippets}
           t={t}
         />
       )}

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-CI9aSCio.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-DQmhpLf8.js"></script>
+    <script type="module" crossorigin src="/assets/index-CI9aSCio.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini