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

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 1 день назад
Родитель
Сommit
30e530a881

+ 2 - 0
CHANGELOG.md

@@ -29,6 +29,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **Items Printed could not be set to 0 after a total plate failure (#3051, reported by @tdavis75)** — a jam ruined everything on the plate while the printer reported the job a success, so the honest count of usable parts was zero; the field refused to go below one. The manual quantity override is what the documentation points at for exactly this correction, and a project's completed-items count sums that column, so there was no way to tell a project that a job produced nothing. The floor was in the edit dialog alone — the API had always stored whatever it was given, which also meant a negative count was accepted and would have subtracted from the project totals. Zero is now typeable and the column is bounded at zero, and the Filament Trends widget counts a zeroed archive as no prints rather than silently reading the 0 as "unset" and charging one.
+- **Bulk edit could not turn G-code injection on or off (#3058)** — every other per-item print option in the queue can be changed for a whole selection at once, but **Inject G-code** was only ever settable one item at a time, in the item's own edit dialog. The bulk dialog simply had no control for it, which on a queue of a few dozen jobs meant opening every single one to arm the start and end snippets an auto-print system needs — the thing bulk edit exists to avoid. It is now a tri-state next to **Auto power off after print**, unchanged by default like the rest, and it appears only once a G-code snippet has actually been saved for some printer model, matching the checkbox in the print dialog. Nothing changed on the server: the bulk endpoint has always accepted the field, so this was a missing control rather than a missing capability.
 - **AMS Filament Backup switched itself off with every print started from the queue (#3040, reported by @frnzzle)** — it never actually did: the printer had auto-refill on the whole time, and Bambuddy was reading its own request back as telemetry. Every `project_file` Bambuddy sent carried `"cfg": "0"`, a field Bambu Studio has never sent and the firmware ignores, but the printer echoes a command's fields back in its acknowledgement — and `cfg` is the device-config bitmask whose bit 18 is auto-refill. The acknowledgement was ingested as though it were a status frame, so 25 ms after every dispatch the badge flipped to off. On a P2S, H2C or X2D it flickered back a second later, because those repeat `cfg` in their periodic status; the P1S, A1, A1 Mini and A2L send it only in a full status dump, which arrives on connect and on Force Refresh and otherwise not at all, so there the wrong value stood until someone toggled it by hand. That mattered beyond the badge: the "prefer lowest remaining" sort is deliberately skipped while the printer reports backup off, since without a second spool to fall back on, feeding a print from the emptiest one risks running dry mid-job — which is why near-empty spools were being left untouched, the symptom that opened the report. On the A1 family it was worse still: they report no `cfg` at all, and the state is meant to stay "unknown" so that behaviour is left exactly as it was before the feature existed; the echo turned that into a definite "off" on the first queue print. Command acknowledgements are no longer read as status — for the backup bit or for the per-job timelapse flag they also echo — and the `cfg` field is gone from the print command, which is not a place to be writing printer settings from.
 - **K values missing on a second AMS, and its slots unconfigurable (#3044, reported by @Zib-Astian)** — an X2D with two AMS 2 Pro, one per hotend, showed K on every slot of the first and nothing on any slot of the second. Configure Slot was worse than blank there: the picker offered no matching profile, the slot read as though it had none bound, and choosing one changed nothing visible. Both came from the same rule. A calibration index can mean two different profiles on a dual-nozzle printer — on the maintainer's H2C, index 16 is the left hotend's black PLA at K=0.018 and 15 is the right's at K=0.020 — so Bambuddy resolves it against the slot's own hotend and shows nothing rather than the wrong number. But a printer does not always file one profile per hotend. This one files one per filament, so the second AMS's slots point at the same entries as the first, all tagged with a single extruder, and requiring a match found nothing at all. The hotend now has to actually appear in the table the printer sent before it is used to narrow anything; where it does not, the index stands on its own, which is what BambuStudio does for the same card. Printers that do file per hotend are unaffected — the wrong-nozzle number still cannot be shown, and the other hotend's profiles stay under **Other K profiles**. Also fixed alongside it: on a dual-nozzle printer the K-profile dropdown named the hotend twice on every matching option and three times on every other one.
 - **The Timeline ignored Shortest Job First (#3043)** — turning SJF on reordered the pending list and the scheduler itself, but the Timeline tab kept drawing the queue in its pre-SJF order, indefinitely. It chained each swimlane's bars by queue position alone and was never told the setting existed, so the one view whose entire job is to say *when* each print will run was the one view answering for an order the scheduler had no intention of using. Bars now chain in the scheduler's own order — jumped items first, then shortest print time, then position — and the starvation guard is visible there too, so a long print that has finally come up shows as such instead of staying buried. All three surfaces that claim to show queue order (the pending list, the "if started now" ETA, and the Timeline) now share one comparator rather than three hand-copied ones. Along the way the pending list stopped folding a model name down to its first letter when grouping lanes, which had been interleaving *Any X1C* with *Any X2D* (and *Any P1S* with *Any P1P*) into a single run of rows.

+ 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(

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


BIN
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}
         />
       )}

Разница между файлами не показана из-за своего большого размера
+ 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>

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