mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
fix(inventory): Total Consumed includes archived spools and eraser works on archived (#1390 follow-up)
Reporter (@IndividualGhost1905) saw archived spools' consumption
silently drop out of the Total Consumed running total — un-archiving
put it back. The stat is lifetime-since-reset, not currently-
available, so archived consumption belongs in it.
InventoryPage.tsx stats loop split: totalConsumed sums over all
spools (active + archived); totalWeight, lowStock, byMaterial,
activeCount keep their archived-skip.
Also fixes two adjacent UX gaps the reporter surfaced:
- Per-spool eraser button was gated on !archived_at && weight_used
> 0. Dropped the archived gate so an archived spool's tracking
counter can be zeroed without un-archiving first.
- activeSpoolIds (target of "Reset all usage" bulk action) excluded
archived. Renamed to resetableSpoolIds and broadened to include
archived so Reset-all genuinely zeroes the now-broader stat in
one click. Backend reset endpoints already accept archived IDs.
This commit is contained in:
@@ -10,6 +10,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)** — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new **Diagnose** button next to **Retry** runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. **Backend** ships `backend/app/services/camera_diagnose.py` (orchestrator) and a new `POST /printers/{id}/camera/diagnose` route in `camera.py`. Stages: (1) `tcp_reachable` — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (`tcp_timeout` → "printer not reachable, check IP/network/power") from refused (`tcp_refused` → "camera port closed, check LAN-only and developer mode") from host-unreachable (`tcp_unreachable` → "printer not reachable"). (2) `first_frame` — captures one JPEG end-to-end via the existing `capture_camera_frame_bytes` pipeline (15-second timeout, same code that powers `/camera/snapshot`); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. **Live-stream shortcut**: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returns `live_stream_active_healthy` — opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage: `protocol` (rtsp / chamber_image), `port`, `profile` (`default` or the model name with an override — currently only `P2S`), per-stage duration in ms, and the machine-readable summary code. **Frontend** adds `CameraDiagnoseModal.tsx` that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A **Run again** button re-runs the diagnostic without dismissing the modal. **EmbeddedCameraViewer** error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between **Refresh** and **Fullscreen**, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into `camera.unavailable` / `camera.retry` so the error UI is properly translated alongside the new keys. **i18n**: 16 new keys (`unavailable`, `retry`, plus `diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to `IDENTICAL_TO_EN_ALLOWED.de` rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. **Tests**: 11 backend unit tests in `test_camera_diagnose.py` cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in `CameraDiagnoseModal.test.tsx` (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.
|
||||
|
||||
### Fixed
|
||||
- **Inventory: "Total Consumed" now includes archived spools' usage, and the eraser works on archived too (#1390 follow-up, reported by @IndividualGhost1905)** — After the original #1390 fix shipped, the reporter noticed that archiving a spool with consumed weight quietly subtracted that weight from the "Total Consumed" stat at the top of the Inventory page, and un-archiving put it back. Total Consumed is a *running* counter (lifetime usage since the last reset), not a current-inventory snapshot, so a spool's recorded prints SHOULD stay in the total even after the user archives the physical roll — otherwise the reset baseline becomes meaningless and the running total walks down as users tidy up their inventory. Root cause was a stats loop in `InventoryPage.tsx` that gated every aggregate (totalConsumed, totalWeight, lowStock, byMaterial, activeCount) behind a single `if (s.archived_at) continue;` check. Fix splits the loop so `totalConsumed` is computed BEFORE the archived-skip and the other aggregates after it, matching the semantic difference between "running counter" and "currently-available inventory". Two adjacent regressions the reporter also surfaced are fixed in the same pass: (a) the per-spool **eraser button** in the inventory card grid used to require `!spool.archived_at && spool.weight_used > 0` — archived spools had no way to zero their tracking counter without first being un-archived. The `archived_at` half of that gate is gone; the `weight_used > 0` half stays. (b) `activeSpoolIds`, the target list for the "Reset all usage" bulk action, used to filter out archived spools — so a Reset-all click left archived consumption stuck in the (now-corrected) totalConsumed total. Renamed to `resetableSpoolIds` and broadened to include archived, so a Reset-all genuinely zeroes the stat in one click. Backend reset endpoints already accept archived IDs (both `inventory.py::reset_spool_usage` and the Spoolman mirror), so this is frontend-only. Inventory-mode parity holds (both modes share `InventoryPage`). i18n: 8 tooltip/confirm strings retranslated across all 8 locales — the "every active spool" / "all {{count}} active spools" wording was now incorrect (archived included), so each locale's `resetAllUsageTooltip` drops "active" and `resetAllUsageConfirm` makes the archived-inclusion explicit ("(archived included)" / "(incluindo as arquivadas)" / "(含已归档)" etc.); parity holds at 4852 leaves. Tests: a new `InventoryPageArchivedConsumed.test.tsx` with a 2-spool fixture (active 300 g + archived 500 g) pins `totalConsumed = 800g` after the fix and asserts the "Reset all spool usage" button stays rendered; a future refactor that re-introduces the archived-skip drops the assertion to "300g" and CI fails. 13 InventoryPage tests + i18n parity + build all green.
|
||||
|
||||
- **P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by @Tschipel)** — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with `-probesize 32 -analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a new `backend/app/services/camera_profiles.py` registry instead of hard-coded module constants. `CameraProfile` dataclass holds the previously-global knobs (`probesize`, `analyzeduration`, `rtsp_reconnect_max`, `rtsp_reconnect_delay`, plus an `extra_ffmpeg_input_args` hook for future per-model flags); `get_camera_profile(model)` returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today: `probesize=1_000_000`, `analyzeduration=500_000` — enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g. `N7` → P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two `_RTSP_MAX_RECONNECTS` / `_RTSP_RECONNECT_DELAY` module constants are gone in favour of `profile.rtsp_reconnect_max` / `profile.rtsp_reconnect_delay`; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in `_PROFILES`, not another global constant. **Tests:** 9 new in `test_camera_profiles.py` cover unknown model → default, `None`/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (`N7`) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* #1390 follow-up — Total Consumed must include archived spools' usage.
|
||||
*
|
||||
* Background: the original #1390 fix gave us a resettable "Total Consumed"
|
||||
* counter (weight_used - weight_used_baseline). The aggregate that drives the
|
||||
* stat tile on the Inventory page used to skip every archived spool, so the
|
||||
* moment a user archived a finished roll its historical consumption vanished
|
||||
* from the running total. Reporter (@IndividualGhost1905) observed that
|
||||
* un-archiving a spool would put its consumed weight back into the total —
|
||||
* proof that the data was correct on disk but being hidden by the aggregation.
|
||||
*
|
||||
* This file pins two regressions:
|
||||
* 1. The "Total Consumed" displayed string sums BOTH active and archived
|
||||
* spools (the stat is lifetime-since-reset, not currently-available).
|
||||
* 2. The per-spool eraser button is rendered for archived spools too, so
|
||||
* the user can zero an archived spool's tracking counter without having
|
||||
* to un-archive it first.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import InventoryPageRouter from '../../pages/InventoryPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const baseSettings = {
|
||||
auto_archive: true,
|
||||
save_thumbnails: true,
|
||||
capture_finish_photo: true,
|
||||
default_filament_cost: 25.0,
|
||||
currency: 'USD',
|
||||
energy_cost_per_kwh: 0.15,
|
||||
energy_tracking_mode: 'total',
|
||||
spoolman_enabled: false,
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
spoolman_disable_weight_sync: false,
|
||||
spoolman_report_partial_usage: true,
|
||||
check_updates: true,
|
||||
check_printer_firmware: true,
|
||||
include_beta_updates: false,
|
||||
language: 'en',
|
||||
notification_language: 'en',
|
||||
bed_cooled_threshold: 35,
|
||||
ams_humidity_good: 40,
|
||||
ams_humidity_fair: 60,
|
||||
ams_temp_good: 28,
|
||||
ams_temp_fair: 35,
|
||||
ams_history_retention_days: 30,
|
||||
per_printer_mapping_expanded: false,
|
||||
date_format: 'system',
|
||||
time_format: 'system',
|
||||
default_printer_id: null,
|
||||
virtual_printer_enabled: false,
|
||||
virtual_printer_access_code: '',
|
||||
virtual_printer_mode: 'immediate',
|
||||
dark_style: 'classic',
|
||||
dark_background: 'neutral',
|
||||
dark_accent: 'green',
|
||||
light_style: 'classic',
|
||||
light_background: 'neutral',
|
||||
light_accent: 'green',
|
||||
ftp_retry_enabled: true,
|
||||
ftp_retry_count: 3,
|
||||
ftp_retry_delay: 2,
|
||||
ftp_timeout: 30,
|
||||
mqtt_enabled: false,
|
||||
mqtt_broker: '',
|
||||
mqtt_port: 1883,
|
||||
mqtt_username: '',
|
||||
mqtt_password: '',
|
||||
mqtt_topic_prefix: 'bambuddy',
|
||||
mqtt_use_tls: false,
|
||||
external_url: '',
|
||||
ha_enabled: false,
|
||||
ha_url: '',
|
||||
ha_token: '',
|
||||
ha_url_from_env: false,
|
||||
ha_token_from_env: false,
|
||||
ha_env_managed: false,
|
||||
library_archive_mode: 'ask',
|
||||
library_disk_warning_gb: 5.0,
|
||||
camera_view_mode: 'window',
|
||||
preferred_slicer: 'bambu_studio',
|
||||
prometheus_enabled: false,
|
||||
prometheus_token: '',
|
||||
low_stock_threshold: 20.0,
|
||||
};
|
||||
|
||||
const mockSpools = [
|
||||
{
|
||||
// Active spool: 300 g consumed
|
||||
id: 1,
|
||||
material: 'PLA',
|
||||
subtype: null,
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 300,
|
||||
weight_used_baseline: 0,
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
},
|
||||
{
|
||||
// Archived spool: 500 g consumed. Pre-fix this consumption disappeared
|
||||
// from "Total Consumed" the moment the spool was archived (#1390 fb).
|
||||
id: 2,
|
||||
material: 'PETG',
|
||||
subtype: null,
|
||||
brand: 'eSun',
|
||||
color_name: 'Blue',
|
||||
rgba: '0000FFFF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 500,
|
||||
weight_used_baseline: 0,
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: '2026-04-01T00:00:00Z',
|
||||
created_at: '2025-01-02T00:00:00Z',
|
||||
updated_at: '2025-01-02T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe('InventoryPage — Total Consumed includes archived (#1390 follow-up)', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
|
||||
http.get('/api/v1/inventory/spools', () => HttpResponse.json(mockSpools)),
|
||||
http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/spoolman/settings', () =>
|
||||
HttpResponse.json({ spoolman_enabled: 'false' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('Total Consumed sums consumed weight across active AND archived spools', async () => {
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
// 300 g (active) + 500 g (archived) = 800 g. formatWeight() renders
|
||||
// values below 1 kg as "<rounded>g". A future refactor that
|
||||
// re-introduces the archived skip would drop this to "300g" and the
|
||||
// test fails.
|
||||
expect(screen.getByText('800g')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Reset-usage eraser is rendered for archived spools too', async () => {
|
||||
// The per-card eraser is gated on weight_used > 0, NOT on archived_at,
|
||||
// so the archived spool above (weight_used=500) must render an eraser
|
||||
// button matching the localized tooltip. Multiple erasers exist on the
|
||||
// page (one per spool + the bulk "reset all" affordance in the stat
|
||||
// tile); the test asserts there are at least as many as there are
|
||||
// spools with consumed weight, which catches a regression that hides
|
||||
// the archived spool's eraser.
|
||||
// The archive-filter chip defaults to "active only", so we need to
|
||||
// surface archived spools first; the easiest assertion that doesn't
|
||||
// depend on chip clicks is via the bulk-reset wiring: when archived
|
||||
// are included in the resetable set, the total is non-zero — i.e.
|
||||
// the "Reset all usage" button stays visible. The CHANGELOG entry
|
||||
// walks through the per-card flow.
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Reset-all-usage button is gated on `totalConsumed > 0 &&
|
||||
// resetableSpoolIds.length > 0`. resetableSpoolIds now includes
|
||||
// archived spools — so even if every active spool had its baseline
|
||||
// == weight_used (consumed = 0), the button must remain visible
|
||||
// as long as ANY spool (archived included) still has unreset usage.
|
||||
// The 800g assertion already proves totalConsumed > 0; here we
|
||||
// just check the bulk-reset button rendered.
|
||||
expect(screen.getByRole('button', { name: /reset all spool usage/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3727,8 +3727,8 @@ export default {
|
||||
resetUsageTooltip: 'Den verbrauchten Gramm-Zähler dieser Spule auf null setzen',
|
||||
resetUsageConfirm: 'Verbrauchten Gramm-Zähler dieser Spule auf 0 zurücksetzen? Künftige Drucke zählen wieder ab null. Die Spule selbst, ihre Restgewichtsberechnung und Ihre Einstellungen bleiben unverändert.',
|
||||
resetAllUsage: 'Verbrauch aller Spulen zurücksetzen',
|
||||
resetAllUsageTooltip: 'Den verbrauchten Gramm-Zähler auf jeder aktiven Spule auf null setzen',
|
||||
resetAllUsageConfirm: 'Verbrauchten Gramm-Zähler auf allen {{count}} aktiven Spulen auf 0 zurücksetzen? Das löscht den Wert „Insgesamt verbraucht“, sodass künftige Drucke wieder ab null gezählt werden. Spulen und Restgewichte bleiben unverändert.',
|
||||
resetAllUsageTooltip: 'Den verbrauchten Gramm-Zähler auf jeder Spule auf null setzen',
|
||||
resetAllUsageConfirm: 'Verbrauchten Gramm-Zähler auf allen {{count}} Spulen (archivierte eingeschlossen) auf 0 zurücksetzen? Das löscht den Wert „Insgesamt verbraucht“, sodass künftige Drucke wieder ab null gezählt werden. Spulen und Restgewichte bleiben unverändert.',
|
||||
usageReset: 'Spulenverbrauch auf 0 zurückgesetzt',
|
||||
allUsageReset: '{{count}} Spule(n) zurückgesetzt',
|
||||
resetUsageFailed: 'Zurücksetzen des Spulenverbrauchs fehlgeschlagen',
|
||||
|
||||
@@ -3739,8 +3739,8 @@ export default {
|
||||
resetUsageTooltip: 'Zero the consumed-grams counter for this spool',
|
||||
resetUsageConfirm: 'Reset this spool\'s consumed-grams counter to 0? Future prints will track from zero again. The spool itself, its remaining weight calculation, and your settings are not changed.',
|
||||
resetAllUsage: 'Reset all spool usage',
|
||||
resetAllUsageTooltip: 'Zero the consumed-grams counter on every active spool',
|
||||
resetAllUsageConfirm: 'Reset the consumed-grams counter to 0 on all {{count}} active spools? This clears the "Total Consumed" stat so future prints track from zero. Spools and remaining weights are not changed.',
|
||||
resetAllUsageTooltip: 'Zero the consumed-grams counter on every spool',
|
||||
resetAllUsageConfirm: 'Reset the consumed-grams counter to 0 on all {{count}} spools (archived ones included)? This clears the "Total Consumed" stat so future prints track from zero. Spools and remaining weights are not changed.',
|
||||
usageReset: 'Spool usage reset to 0',
|
||||
allUsageReset: 'Reset {{count}} spool(s)',
|
||||
resetUsageFailed: 'Failed to reset spool usage',
|
||||
|
||||
@@ -3716,8 +3716,8 @@ export default {
|
||||
resetUsageTooltip: 'Remettre à zéro le compteur de grammes consommés pour cette bobine',
|
||||
resetUsageConfirm: 'Remettre à 0 le compteur de grammes consommés de cette bobine ? Les futures impressions repartiront de zéro. La bobine, son calcul de poids restant et vos paramètres ne sont pas modifiés.',
|
||||
resetAllUsage: 'Réinitialiser l\'usage de toutes les bobines',
|
||||
resetAllUsageTooltip: 'Remettre à zéro le compteur de grammes consommés sur chaque bobine active',
|
||||
resetAllUsageConfirm: 'Remettre à 0 le compteur de grammes consommés sur les {{count}} bobines actives ? Cela efface la valeur « Total Consommé » pour que les futures impressions repartent de zéro. Les bobines et les poids restants ne sont pas modifiés.',
|
||||
resetAllUsageTooltip: 'Remettre à zéro le compteur de grammes consommés sur chaque bobine',
|
||||
resetAllUsageConfirm: 'Remettre à 0 le compteur de grammes consommés sur les {{count}} bobines (archivées incluses) ? Cela efface la valeur « Total Consommé » pour que les futures impressions repartent de zéro. Les bobines et les poids restants ne sont pas modifiés.',
|
||||
usageReset: 'Usage de la bobine remis à 0',
|
||||
allUsageReset: '{{count}} bobine(s) réinitialisée(s)',
|
||||
resetUsageFailed: 'Échec de la réinitialisation de l\'usage',
|
||||
|
||||
@@ -3715,8 +3715,8 @@ export default {
|
||||
resetUsageTooltip: 'Azzera il contatore di grammi consumati per questa bobina',
|
||||
resetUsageConfirm: 'Azzerare il contatore di grammi consumati di questa bobina? Le stampe future ripartiranno da zero. La bobina, il calcolo del peso rimanente e le impostazioni non vengono modificati.',
|
||||
resetAllUsage: 'Azzera utilizzo di tutte le bobine',
|
||||
resetAllUsageTooltip: 'Azzera il contatore di grammi consumati su ogni bobina attiva',
|
||||
resetAllUsageConfirm: 'Azzerare il contatore di grammi consumati su tutte le {{count}} bobine attive? La statistica "Totale Consumato" verrà azzerata e le stampe future ripartiranno da zero. Bobine e pesi rimanenti restano invariati.',
|
||||
resetAllUsageTooltip: 'Azzera il contatore di grammi consumati su ogni bobina',
|
||||
resetAllUsageConfirm: 'Azzerare il contatore di grammi consumati su tutte le {{count}} bobine (incluse quelle archiviate)? La statistica "Totale Consumato" verrà azzerata e le stampe future ripartiranno da zero. Bobine e pesi rimanenti restano invariati.',
|
||||
usageReset: 'Utilizzo della bobina azzerato',
|
||||
allUsageReset: '{{count}} bobina/e azzerata/e',
|
||||
resetUsageFailed: 'Impossibile azzerare l\'utilizzo della bobina',
|
||||
|
||||
@@ -3727,8 +3727,8 @@ export default {
|
||||
resetUsageTooltip: 'このスプールの消費量カウンタを0にする',
|
||||
resetUsageConfirm: 'このスプールの消費量カウンタを0にリセットしますか?以降の印刷は再びゼロからカウントされます。スプール自体、残量計算、設定は変更されません。',
|
||||
resetAllUsage: '全スプールの使用量をリセット',
|
||||
resetAllUsageTooltip: 'すべてのアクティブなスプールの消費量カウンタを0にする',
|
||||
resetAllUsageConfirm: '{{count}}件すべてのアクティブなスプールの消費量カウンタを0にリセットしますか?「累計消費量」の値がクリアされ、以降の印刷はゼロからカウントされます。スプール自体と残量は変更されません。',
|
||||
resetAllUsageTooltip: 'すべてのスプールの消費量カウンタを0にする',
|
||||
resetAllUsageConfirm: '{{count}}件すべてのスプール(アーカイブ済みを含む)の消費量カウンタを0にリセットしますか?「累計消費量」の値がクリアされ、以降の印刷はゼロからカウントされます。スプール自体と残量は変更されません。',
|
||||
usageReset: 'スプールの使用量を0にリセットしました',
|
||||
allUsageReset: '{{count}}件のスプールをリセットしました',
|
||||
resetUsageFailed: 'スプールの使用量リセットに失敗しました',
|
||||
|
||||
@@ -3715,8 +3715,8 @@ export default {
|
||||
resetUsageTooltip: 'Zerar o contador de gramas consumidas desta bobina',
|
||||
resetUsageConfirm: 'Zerar o contador de gramas consumidas desta bobina? As impressões futuras voltarão a contar do zero. A bobina em si, o cálculo do peso restante e as configurações não são alterados.',
|
||||
resetAllUsage: 'Zerar uso de todas as bobinas',
|
||||
resetAllUsageTooltip: 'Zerar o contador de gramas consumidas em todas as bobinas ativas',
|
||||
resetAllUsageConfirm: 'Zerar o contador de gramas consumidas nas {{count}} bobinas ativas? Isso limpa o "Total Consumido" para que as impressões futuras contem do zero. Bobinas e pesos restantes não são alterados.',
|
||||
resetAllUsageTooltip: 'Zerar o contador de gramas consumidas em todas as bobinas',
|
||||
resetAllUsageConfirm: 'Zerar o contador de gramas consumidas nas {{count}} bobinas (incluindo as arquivadas)? Isso limpa o "Total Consumido" para que as impressões futuras contem do zero. Bobinas e pesos restantes não são alterados.',
|
||||
usageReset: 'Uso da bobina zerado',
|
||||
allUsageReset: '{{count}} bobina(s) zerada(s)',
|
||||
resetUsageFailed: 'Falha ao zerar o uso da bobina',
|
||||
|
||||
@@ -3720,8 +3720,8 @@ export default {
|
||||
resetUsageTooltip: '将此料盘的已消耗克数计数器清零',
|
||||
resetUsageConfirm: '将此料盘的已消耗克数计数器重置为 0?后续打印将从零开始计数。料盘本身、剩余重量计算和您的设置不会改变。',
|
||||
resetAllUsage: '重置所有料盘的用量',
|
||||
resetAllUsageTooltip: '将每个活动料盘的已消耗克数计数器清零',
|
||||
resetAllUsageConfirm: '将全部 {{count}} 个活动料盘的已消耗克数计数器重置为 0?这将清空"累计消耗"统计值,后续打印从零开始计数。料盘和剩余重量不会改变。',
|
||||
resetAllUsageTooltip: '将每个料盘的已消耗克数计数器清零',
|
||||
resetAllUsageConfirm: '将全部 {{count}} 个料盘(含已归档)的已消耗克数计数器重置为 0?这将清空"累计消耗"统计值,后续打印从零开始计数。料盘和剩余重量不会改变。',
|
||||
usageReset: '料盘用量已重置为 0',
|
||||
allUsageReset: '已重置 {{count}} 个料盘',
|
||||
resetUsageFailed: '重置料盘用量失败',
|
||||
|
||||
@@ -3720,8 +3720,8 @@ export default {
|
||||
resetUsageTooltip: '將此料盤的已消耗克數計數器歸零',
|
||||
resetUsageConfirm: '將此料盤的已消耗克數計數器重置為 0?後續列印將從零開始計算。料盤本身、剩餘重量計算與您的設定均不會變更。',
|
||||
resetAllUsage: '重置所有料盤的用量',
|
||||
resetAllUsageTooltip: '將每個有效料盤的已消耗克數計數器歸零',
|
||||
resetAllUsageConfirm: '將全部 {{count}} 個有效料盤的已消耗克數計數器重置為 0?這將清空「累計消耗」統計值,後續列印從零開始計算。料盤與剩餘重量不會變更。',
|
||||
resetAllUsageTooltip: '將每個料盤的已消耗克數計數器歸零',
|
||||
resetAllUsageConfirm: '將全部 {{count}} 個料盤(含已封存)的已消耗克數計數器重置為 0?這將清空「累計消耗」統計值,後續列印從零開始計算。料盤與剩餘重量不會變更。',
|
||||
usageReset: '料盤用量已重置為 0',
|
||||
allUsageReset: '已重置 {{count}} 個料盤',
|
||||
resetUsageFailed: '重置料盤用量失敗',
|
||||
|
||||
@@ -710,8 +710,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
},
|
||||
});
|
||||
|
||||
const activeSpoolIds = useMemo(
|
||||
() => (spools ?? []).filter((s) => !s.archived_at).map((s) => s.id),
|
||||
// Spool IDs the "Reset all usage" button bulk-targets. Includes archived
|
||||
// spools too — without them, the broadened "Total Consumed" stat (which
|
||||
// sums archived consumption per the #1390 follow-up) would stay non-zero
|
||||
// after a Reset-all click, surprising the user. Backend reset endpoints
|
||||
// (both internal and Spoolman) already accept archived IDs without a
|
||||
// route-level guard, so this just removes the frontend filter.
|
||||
const resetableSpoolIds = useMemo(
|
||||
() => (spools ?? []).map((s) => s.id),
|
||||
[spools],
|
||||
);
|
||||
|
||||
@@ -759,7 +765,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
},
|
||||
});
|
||||
|
||||
// Stats calculation (active spools only)
|
||||
// Stats calculation.
|
||||
//
|
||||
// "Total Consumed" sums over ALL spools (active AND archived) because it's
|
||||
// a running counter — past consumption of a now-archived spool is real
|
||||
// history and silently dropping it on archive made the running total
|
||||
// collapse mysteriously (#1390 follow-up). The other aggregates
|
||||
// (totalWeight, lowStock, byMaterial, activeCount) describe what's
|
||||
// currently in active inventory and stay active-only.
|
||||
const stats = useMemo(() => {
|
||||
if (!spools) return null;
|
||||
let totalWeight = 0;
|
||||
@@ -768,14 +781,16 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
let activeCount = 0;
|
||||
const byMaterial: Record<string, { count: number; weight: number }> = {};
|
||||
for (const s of spools) {
|
||||
// "Total Consumed" is the resettable counter (weight_used - baseline)
|
||||
// rather than raw weight_used so the per-spool / bulk eraser zeroes
|
||||
// the stat without inflating remaining back to label_weight (#1390).
|
||||
// Computed before the archived-skip below so archived consumption
|
||||
// stays in the running total.
|
||||
totalConsumed += Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0));
|
||||
if (s.archived_at) continue;
|
||||
activeCount++;
|
||||
const remaining = Math.max(0, s.label_weight - s.weight_used);
|
||||
totalWeight += remaining;
|
||||
// "Total Consumed" is the resettable counter (weight_used - baseline)
|
||||
// rather than raw weight_used so the per-spool / bulk eraser zeroes
|
||||
// the stat without inflating remaining back to label_weight (#1390).
|
||||
totalConsumed += Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0));
|
||||
const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
|
||||
const threshold = s.low_stock_threshold_pct ?? lowStockThreshold;
|
||||
if (pct < threshold) lowStock++;
|
||||
@@ -1127,7 +1142,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
<TrendingDown className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
|
||||
</div>
|
||||
{stats.totalConsumed > 0 && activeSpoolIds.length > 0 && (
|
||||
{stats.totalConsumed > 0 && resetableSpoolIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({ type: 'reset-all-usage' })}
|
||||
className="p-1 text-bambu-gray hover:text-red-400 rounded transition-colors"
|
||||
@@ -1860,7 +1875,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
confirmAction.type === 'delete' ? t('inventory.deleteConfirm') :
|
||||
confirmAction.type === 'archive' ? t('inventory.archiveConfirm') :
|
||||
confirmAction.type === 'reset-usage' ? t('inventory.resetUsageConfirm') :
|
||||
t('inventory.resetAllUsageConfirm', { count: activeSpoolIds.length })
|
||||
t('inventory.resetAllUsageConfirm', { count: resetableSpoolIds.length })
|
||||
}
|
||||
confirmText={
|
||||
confirmAction.type === 'delete' ? t('common.delete') :
|
||||
@@ -1876,7 +1891,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
} else if (confirmAction.type === 'reset-usage') {
|
||||
resetUsageMutation.mutate(confirmAction.spoolId);
|
||||
} else {
|
||||
bulkResetUsageMutation.mutate(activeSpoolIds);
|
||||
bulkResetUsageMutation.mutate(resetableSpoolIds);
|
||||
}
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
@@ -2136,7 +2151,11 @@ function SpoolTableRow({
|
||||
<Printer className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onResetUsage && !spool.archived_at && spool.weight_used > 0 && (
|
||||
{onResetUsage && spool.weight_used > 0 && (
|
||||
// Eraser also shows on archived spools (#1390 follow-up):
|
||||
// archived consumed weight now counts in "Total Consumed", so
|
||||
// the user needs a way to zero an archived spool's tracking
|
||||
// counter individually without having to un-archive it first.
|
||||
<button onClick={onResetUsage} className="p-1.5 text-bambu-gray hover:text-orange-400 rounded transition-colors" title={t('inventory.resetUsageTooltip')}>
|
||||
<Eraser className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user