Przeglądaj źródła

fix(archives): failed print card says its cost is one full print (issue #3318)

- After a failed or cancelled print, the archive card showed the whole
  file's cost and grams with no hint, as if that run cost that much.
  The archive keeps one full print's figures on purpose (COST-3); the
  card now says so in a tooltip on the cost and grams for failed,
  aborted, cancelled and stopped archives, pointing to the Print Log
  for what the run used. New key in all 15 locales.
- archive_cost_estimate.py docstring no longer claims a failed print
  ends up cheaper on the archive.
- ArchivesCostEstimate test covers a failed and a cancelled card and
  finds cards by value rather than order.
maziggy 9 godzin temu
rodzic
commit
3c78bf24bf

+ 1 - 0
CHANGELOG.md

@@ -174,6 +174,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A failed or cancelled print's card says its cost is for one full print (#3318, reported by @ojimpo)** — After a print failed or was cancelled, the archive card showed the cost and grams of the whole file with no hint, as if that were what the run cost. The archive keeps one full print's figures on purpose (reprints are estimated and billed from them); the cost and grams now carry a tooltip saying so, pointing to the archive's Print Log for what the run used. With Spoolman, that run's cost is priced from the spools it was charged to.
 - **A print that fails or is cancelled early is charged only for the filament it used (#3317, reported by @ojimpo)** — Two errors added up to a failed print being charged to Spoolman as if it had finished, sometimes for more than the finished print would have used.
   - **The previous print's layer:** right after a print starts, the printer can send one update that still carries the layer the previous print ended on (seen on an H2S: layer 357 of 357 at 0 %). Bambuddy took it as this print's last layer, so a print that failed before its first layer was charged up to layer 357. That value is now ignored until the new print reports a layer of its own. It also no longer counts as a layer change, so it no longer adds a layer-timelapse frame at print start.
   - **Retractions:** partial usage is worked out from the extrusion in the file's G-code, which counted each un-retract but never the retract before it. Every retraction added to the total, putting partial usage up to 1.7x above the slicer's weight. Retractions are now subtracted. The split between spools after an AMS switch to a backup spool mid-print uses the same figures.

+ 4 - 2
backend/app/services/archive_cost_estimate.py

@@ -12,8 +12,10 @@ This runs the completion-time pricing once at print start, on the 3MF's
 per-slot estimates instead of charged grams: each slot is priced at the spool
 in the tray it is mapped to, grams no spool could price are covered at the
 default rate, and the result replaces the placeholder. The figure is still an
-estimate -- a failed or stopped print ends up cheaper -- and the archive card
-says so while the print runs.
+estimate, and the archive card says so while the print runs. A completed first
+run is re-priced from what was charged; a run that does not finish keeps this
+figure as one full print of the file (COST-3) and logs its own cost in the
+Print Log, which the card points to (#3318).
 
 Only mappings that are trustworthy at print start are used: the print
 command's own ``ams_mapping``, the queue item's stored mapping, and a colour

+ 49 - 3
frontend/src/__tests__/pages/ArchivesCostEstimate.test.tsx

@@ -1,7 +1,9 @@
 /**
  * A running print's cost is an estimate until completion re-prices it from
  * the spools that actually fed it (#3261). The card says so; a finished
- * print's cost is shown as final.
+ * print's cost is shown as final. A run that failed or was cancelled leaves
+ * one full print's grams and cost on the archive, and the card says that
+ * the run's own figures are in the Print Log (#3318).
  */
 
 import { describe, it, expect, beforeEach } from 'vitest';
@@ -50,8 +52,28 @@ const archives = [
     completed_at: '2026-10-07T11:00:00Z',
     cost: 10.94,
   },
+  {
+    ...baseArchive,
+    id: 3,
+    filename: 'failed.gcode.3mf',
+    print_name: 'Failed',
+    status: 'failed',
+    completed_at: '2026-10-07T10:02:00Z',
+    cost: 20.22,
+  },
+  {
+    ...baseArchive,
+    id: 4,
+    filename: 'cancelled.gcode.3mf',
+    print_name: 'Cancelled',
+    status: 'cancelled',
+    completed_at: '2026-10-07T10:05:00Z',
+    cost: 5.5,
+  },
 ];
 
+const FULL_PRINT = 'One full print of this file. What this run used is in the Print Log';
+
 describe('ArchivesPage cost estimate (#3261)', () => {
   beforeEach(() => {
     setAuthToken(null);
@@ -76,9 +98,11 @@ describe('ArchivesPage cost estimate (#3261)', () => {
     render(<ArchivesPage />);
 
     await waitFor(() => {
-      expect(screen.getAllByTestId('archive-cost')).toHaveLength(2);
+      expect(screen.getAllByTestId('archive-cost')).toHaveLength(4);
     });
-    const [running, finished] = screen.getAllByTestId('archive-cost');
+    const costs = screen.getAllByTestId('archive-cost');
+    const running = costs.find((el) => el.textContent?.endsWith('7.80'))!;
+    const finished = costs.find((el) => el.textContent?.endsWith('10.94'))!;
 
     expect(running.textContent).toMatch(/^~.*7\.80$/);
     expect(running).toHaveAttribute('title', 'Estimate — the final cost is set when the print finishes');
@@ -87,4 +111,26 @@ describe('ArchivesPage cost estimate (#3261)', () => {
     expect(finished.textContent).toMatch(/10\.94$/);
     expect(finished).not.toHaveAttribute('title');
   });
+
+  it('says a failed or cancelled run shows one full print, not what the run used (#3318)', async () => {
+    render(<ArchivesPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('archive-cost')).toHaveLength(4);
+    });
+    const costs = screen.getAllByTestId('archive-cost');
+    const grams = screen.getAllByTestId('archive-grams');
+    const byCost = (value: string) => costs.findIndex((el) => el.textContent?.endsWith(value));
+
+    for (const value of ['20.22', '5.50']) {
+      const i = byCost(value);
+      expect(costs[i].textContent).not.toContain('~');
+      expect(costs[i]).toHaveAttribute('title', FULL_PRINT);
+      expect(grams[i]).toHaveAttribute('title', FULL_PRINT);
+    }
+    for (const value of ['7.80', '10.94']) {
+      expect(costs[byCost(value)]).not.toHaveAttribute('title', FULL_PRINT);
+      expect(grams[byCost(value)]).not.toHaveAttribute('title');
+    }
+  });
 });

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} Schichten',
       wearCost: 'Druckerverschleiß',
       costEstimate: 'Schätzung – die endgültigen Kosten stehen fest, wenn der Druck abgeschlossen ist',
+      fullPrintFigure: 'Ein vollständiger Druck dieser Datei. Was dieser Lauf verbraucht hat, steht im Druckprotokoll',
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',

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

@@ -1194,6 +1194,7 @@ export default {
       layers: '{{count}} layers',
       wearCost: 'Printer wear',
       costEstimate: 'Estimate — the final cost is set when the print finishes',
+      fullPrintFigure: 'One full print of this file. What this run used is in the Print Log',
       object: '{{count}} object',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} capas',
       wearCost: 'Desgaste de la impresora',
       costEstimate: 'Estimación: el coste final se fija cuando termina la impresión',
+      fullPrintFigure: 'Una impresión completa de este archivo. Lo que consumió esta ejecución está en el registro de impresión',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       slicedFor: 'Laminado para {{model}}',

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} couches',
       wearCost: 'Usure de l\'imprimante',
       costEstimate: 'Estimation : le coût final est fixé à la fin de l\'impression',
+      fullPrintFigure: 'Une impression complète de ce fichier. Ce que cette exécution a consommé figure dans le journal d\'impression',
       object: '{{count}} objet',
       objects: '{{count}} objets',
       slicedFor: 'Découpé pour {{model}}',

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} strati',
       wearCost: 'Usura della stampante',
       costEstimate: 'Stima: il costo finale viene fissato al termine della stampa',
+      fullPrintFigure: 'Una stampa completa di questo file. Quanto ha consumato questa esecuzione è nel «Registro stampe»',
       object: '{{count}} oggetto',
       objects: '{{count}} oggetti',
       slicedFor: 'Sliced per {{model}}',

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

@@ -1186,6 +1186,7 @@ export default {
       layers: '{{count}} レイヤー',
       wearCost: 'プリンターの摩耗',
       costEstimate: '見積もり — 最終コストは印刷完了時に確定します',
+      fullPrintFigure: 'このファイルを最後まで印刷した場合の値です。今回の実行で使用した量は印刷ログにあります',
       object: '{{count}}オブジェクト',
       objects: '{{count}}オブジェクト',
       slicedFor: '{{model}}用にスライス',

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

@@ -1141,6 +1141,7 @@ export default {
       layers: '{{count}}층',
       wearCost: '프린터 마모',
       costEstimate: '예상치 — 최종 비용은 출력이 끝나면 확정됩니다',
+      fullPrintFigure: '이 파일을 한 번 끝까지 인쇄한 값입니다. 이번 실행의 사용량은 인쇄 기록에 있습니다',
       object: '{{count}}개 객체',
       objects: '{{count}}개 객체',
       slicedFor: '{{model}}용으로 슬라이싱됨',

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

@@ -1192,6 +1192,7 @@ export default {
       layers: '{{count}} lagen',
       wearCost: 'Printerslijtage',
       costEstimate: 'Schatting — de definitieve kosten worden vastgesteld als de print klaar is',
+      fullPrintFigure: 'Eén volledige afdruk van dit bestand. Wat deze run verbruikte staat in het afdruklogboek',
       object: '{{count}} object',
       objects: '{{count}} objecten',
       slicedFor: 'Gesliced voor {{model}}',

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} camadas',
       wearCost: 'Desgaste da impressora',
       costEstimate: 'Estimativa — o custo final é definido quando a impressão termina',
+      fullPrintFigure: 'Uma impressão completa deste arquivo. O que esta execução usou está no histórico de impressões',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       slicedFor: 'Fatiado para {{model}}',

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

@@ -1142,6 +1142,7 @@ export default {
       layers: "{{count}} слоёв",
       wearCost: 'Износ принтера',
       costEstimate: 'Оценка — итоговая стоимость определяется по завершении печати',
+      fullPrintFigure: 'Одна полная печать этого файла. Расход этого запуска — в журнале печати',
       object: "{{count}} объект",
       objects: "{{count}} объектов",
       slicedFor: "Нарезано для {{model}}",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -1192,6 +1192,7 @@ export default {
       layers: '{{count}} skikt',
       wearCost: 'Skrivarslitage',
       costEstimate: 'Uppskattning – den slutliga kostnaden fastställs när utskriften är klar',
+      fullPrintFigure: 'En hel utskrift av den här filen. Vad den här körningen förbrukade finns i utskriftsloggen',
       object: '{{count}} objekt',
       objects: '{{count}} objekt',
       slicedFor: 'Slicad för {{model}}',

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} katman',
       wearCost: 'Yazıcı aşınması',
       costEstimate: 'Tahmin — nihai maliyet baskı bittiğinde belirlenir',
+      fullPrintFigure: 'Bu dosyanın tam bir baskısı. Bu çalıştırmanın kullandığı miktar baskı günlüğündedir',
       object: '{{count}} nesne',
       objects: '{{count}} nesne',
       slicedFor: '{{model}} için dilimlendi',

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

@@ -1191,6 +1191,7 @@ export default {
       layers: "Шарів: {{count}}",
       wearCost: 'Знос принтера',
       costEstimate: 'Оцінка — остаточна вартість визначається після завершення друку',
+      fullPrintFigure: 'Один повний друк цього файлу. Витрату цього запуску наведено в журналі друку',
       object: "{{count}} об’єкт",
       objects: "Об’єктів: {{count}}",
       slicedFor: "Нарізаний для {{model}}",

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} 层',
       wearCost: '打印机磨损',
       costEstimate: '估算值——最终成本在打印完成时确定',
+      fullPrintFigure: '此文件完整打印一次的数值。本次运行的实际用量见打印记录',
       object: '{{count}} 个对象',
       objects: '{{count}} 个对象',
       slicedFor: '为 {{model}} 切片',

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

@@ -1187,6 +1187,7 @@ export default {
       layers: '{{count}} 層',
       wearCost: '印表機磨損',
       costEstimate: '估算值——最終成本在列印完成時確定',
+      fullPrintFigure: '此檔案完整列印一次的數值。本次執行的實際用量見列印記錄',
       object: '{{count}} 個物件',
       objects: '{{count}} 個物件',
       slicedFor: '為 {{model}} 切片',

+ 19 - 3
frontend/src/pages/ArchivesPage.tsx

@@ -280,6 +280,10 @@ async function openInSlicerWithToken(
   }
 }
 
+// Ended without finishing: the archive's grams and cost stay one full print of
+// the file (COST-3), not what this run used.
+const UNFINISHED_STATUSES = new Set(['failed', 'aborted', 'cancelled', 'stopped']);
+
 function ArchiveCard({
   archive,
   printerName,
@@ -1291,7 +1295,11 @@ function ArchiveCard({
             </div>
           )}
           {archive.filament_used_grams && (
-            <div className="flex items-center gap-1.5 text-bambu-gray">
+            <div
+              className="flex items-center gap-1.5 text-bambu-gray"
+              title={UNFINISHED_STATUSES.has(archive.status) ? t('archives.card.fullPrintFigure') : undefined}
+              data-testid="archive-grams"
+            >
               <Package className="w-3 h-3" />
               {archive.filament_used_grams.toFixed(1)}g
             </div>
@@ -1299,10 +1307,18 @@ function ArchiveCard({
           {(archive.cost != null || archive.energy_cost != null || archive.wear_cost != null) && (
             <div className="flex items-center gap-3 text-bambu-gray">
               {archive.cost != null && (
-                // A running print's cost is an estimate until completion re-prices it (#3261)
+                // A running print's cost is an estimate until completion re-prices it (#3261).
+                // A run that did not finish leaves one full print's figure; its
+                // own cost is in the Print Log (#3318).
                 <div
                   className="flex items-center gap-1.5"
-                  title={archive.status === 'printing' ? t('archives.card.costEstimate') : undefined}
+                  title={
+                    archive.status === 'printing'
+                      ? t('archives.card.costEstimate')
+                      : UNFINISHED_STATUSES.has(archive.status)
+                        ? t('archives.card.fullPrintFigure')
+                        : undefined
+                  }
                   data-testid="archive-cost"
                 >
                   <Coins className="w-3 h-3" />