From 71d4b2f70d898d5e229d5eeb182787486bc89695 Mon Sep 17 00:00:00 2001
From: Thomansky <73141171+Thomansky@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:11:50 +0200
Subject: [PATCH] Material number as a first-class spool field (#2994)
---
backend/app/api/routes/_spoolman_helpers.py | 10 +
backend/app/api/routes/inventory.py | 91 +++
backend/app/core/database.py | 6 +
backend/app/models/spool.py | 9 +
backend/app/schemas/spool.py | 50 ++
backend/app/services/github_backup.py | 10 +
backend/app/services/github_restore.py | 11 +
backend/app/services/material_number.py | 75 +++
backend/app/services/spool_csv.py | 16 +-
backend/app/services/spool_tag_matcher.py | 13 +
.../integration/test_material_number_api.py | 528 ++++++++++++++++++
.../unit/services/test_spool_tag_matcher.py | 33 ++
.../unit/test_github_backup_spool_fields.py | 47 ++
backend/tests/unit/test_github_restore.py | 56 ++
.../unit/test_material_number_migration.py | 94 ++++
.../unit/test_spoolman_inventory_helpers.py | 16 +
.../components/AdditionalSection.test.tsx | 14 +
.../components/BulkEditSpoolsModal.test.tsx | 69 +++
.../components/MaterialNumberStats.test.tsx | 101 ++++
.../src/__tests__/hooks/useWebSocket.test.ts | 2 +
...InventoryPageMaterialNumberFilter.test.tsx | 141 +++++
.../pages/SpoolBuddyWriteTagPage.test.tsx | 56 ++
.../pages/StatsPageMaterialNumbers.test.tsx | 186 ++++++
frontend/src/api/client.ts | 21 +
.../src/components/BulkEditSpoolsModal.tsx | 36 +-
frontend/src/components/ForecastPanel.tsx | 1 +
.../src/components/MaterialNumberStats.tsx | 79 +++
frontend/src/components/SpoolFormModal.tsx | 14 +
.../spool-form/AdditionalSection.tsx | 29 +
frontend/src/components/spool-form/types.ts | 11 +
frontend/src/hooks/useWebSocket.ts | 2 +
frontend/src/i18n/locales/de.ts | 15 +
frontend/src/i18n/locales/en.ts | 15 +
frontend/src/i18n/locales/es.ts | 15 +
frontend/src/i18n/locales/fr.ts | 15 +
frontend/src/i18n/locales/it.ts | 15 +
frontend/src/i18n/locales/ja.ts | 15 +
frontend/src/i18n/locales/ko.ts | 15 +
frontend/src/i18n/locales/nl.ts | 15 +
frontend/src/i18n/locales/pt-BR.ts | 15 +
frontend/src/i18n/locales/ru.ts | 15 +
frontend/src/i18n/locales/sv.ts | 15 +
frontend/src/i18n/locales/tr.ts | 15 +
frontend/src/i18n/locales/uk.ts | 15 +
frontend/src/i18n/locales/zh-CN.ts | 15 +
frontend/src/i18n/locales/zh-TW.ts | 15 +
frontend/src/pages/InventoryPage.tsx | 67 ++-
frontend/src/pages/StatsPage.tsx | 10 +
.../pages/spoolbuddy/SpoolBuddyDashboard.tsx | 1 +
.../spoolbuddy/SpoolBuddyWriteTagPage.tsx | 5 +
frontend/src/utils/inventorySearch.ts | 1 +
51 files changed, 2123 insertions(+), 13 deletions(-)
create mode 100644 backend/app/services/material_number.py
create mode 100644 backend/tests/integration/test_material_number_api.py
create mode 100644 backend/tests/unit/test_github_backup_spool_fields.py
create mode 100644 backend/tests/unit/test_material_number_migration.py
create mode 100644 frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx
create mode 100644 frontend/src/__tests__/components/MaterialNumberStats.test.tsx
create mode 100644 frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx
create mode 100644 frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx
create mode 100644 frontend/src/components/MaterialNumberStats.tsx
diff --git a/backend/app/api/routes/_spoolman_helpers.py b/backend/app/api/routes/_spoolman_helpers.py
index 9847d3ace..1fab71f17 100644
--- a/backend/app/api/routes/_spoolman_helpers.py
+++ b/backend/app/api/routes/_spoolman_helpers.py
@@ -55,6 +55,10 @@ class MappedSpoolFields(TypedDict):
created_at: str | None # None when Spoolman spool has no registered timestamp
updated_at: str | None
cost_per_kg: float | None
+ # Spoolman's native filament.article_number, surfaced as the internal
+ # material number (#2870). Read-only in Spoolman mode — the number is
+ # filament-level there and maintained in Spoolman itself.
+ material_number: str | None
storage_location: str | None
location_id: int | None
k_profiles: list[Any]
@@ -414,6 +418,12 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
# Spoolman has no updated_at field; use registered timestamp as best available proxy
"updated_at": created_at,
"cost_per_kg": _safe_optional_float(spool.get("price")),
+ # Spoolman's filament.article_number maps 1:1 onto the internal
+ # material number (#2870): both identify the purchasable product.
+ # Trimmed for the same reason the schema validator trims the internal
+ # one — the filter chip builds its options from trimmed values and
+ # matches exactly, so a padded number would list and match nothing.
+ "material_number": ((filament.get("article_number") or "").strip() or None),
"storage_location": spool.get("location") or None,
"location_id": None,
"k_profiles": [],
diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py
index 92f2d25c1..ec7b38924 100644
--- a/backend/app/api/routes/inventory.py
+++ b/backend/app/api/routes/inventory.py
@@ -33,6 +33,7 @@ from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Su
from backend.app.models.user import User
from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
from backend.app.schemas.spool import (
+ MaterialNumberStats,
SpoolAssignmentCreate,
SpoolAssignmentResponse,
SpoolBulkCreate,
@@ -67,6 +68,7 @@ from backend.app.services.location_service import (
prepare_internal_spool_payload,
rename_location as rename_location_record,
)
+from backend.app.services.material_number import apply_material_number_inheritance
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
from backend.app.services.slot_nozzle import resolve_slot_nozzle
from backend.app.services.spool_csv import (
@@ -1467,6 +1469,10 @@ async def import_spools_csv(
created = 0
for row in preview.rows:
if row.status == "valid" and row.spool is not None:
+ # Deliberately no material-number inheritance here (#2870), unlike
+ # the other create paths: the file is authoritative. A CSV that
+ # leaves the column blank is stating "no number", not asking for
+ # one to be guessed from whatever else is in the inventory.
spool = Spool(**row.spool)
db.add(spool)
# Supplier assignments resolved by name during parsing (#2988).
@@ -1563,6 +1569,8 @@ async def create_spool(
payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+ # A new spool of an already-numbered product inherits its material number (#2870).
+ payload = await apply_material_number_inheritance(db, payload)
spool = Spool(**payload)
db.add(spool)
await db.flush()
@@ -1589,6 +1597,8 @@ async def bulk_create_spools(
payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+ # A new spool of an already-numbered product inherits its material number (#2870).
+ payload = await apply_material_number_inheritance(db, payload)
for _ in range(data.quantity):
spool = Spool(**payload)
db.add(spool)
@@ -2597,6 +2607,87 @@ async def get_supplier_stats(
return sorted(stats.values(), key=lambda s: (-s.consumed_g, supplier_name_key(s.supplier_name)))
+@router.get("/stats/material-numbers", response_model=list[MaterialNumberStats])
+async def get_material_number_stats(
+ date_from: date | None = Query(None),
+ date_to: date | None = Query(None),
+ db: AsyncSession = Depends(get_db),
+ _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+ """Aggregate the inventory by material number (#2870).
+
+ The material number is the internal purchasing identifier shared by all
+ spools of a product, so this is the grouping the business actually costs
+ by — unlike brand+material+colour. Two queries: active-spool counts and
+ remaining weight from the spool table, consumption and cost from the
+ recorded usage history (archived spools included — their consumption
+ happened).
+
+ ``date_from``/``date_to`` narrow the usage half only, so the widget can
+ follow the dashboard timeframe the rest of the stats page uses. Stock is
+ point-in-time by nature and stays unfiltered — "how much do I hold" has
+ no date range. Sorted by consumption, heaviest first, then by number so
+ a range where nothing was consumed still lists in a stable order.
+ """
+ from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+ # material_number is normalised to NULL-or-non-empty by the schema
+ # validator, so NULL is the only "unset" state to exclude here.
+ has_number = Spool.material_number.is_not(None)
+
+ usage_filters = [has_number]
+ if date_from:
+ usage_filters.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+ if date_to:
+ usage_filters.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+
+ # Clamped PER SPOOL, like every other remaining-weight computation in the
+ # codebase: a spool whose weight_used overshot its label_weight holds 0 g,
+ # it does not subtract from the other spools sharing the number.
+ per_spool_remaining = func.coalesce(Spool.label_weight, 0) - func.coalesce(Spool.weight_used, 0)
+ inventory_rows = await db.execute(
+ select(
+ Spool.material_number,
+ func.count(Spool.id),
+ func.sum(case((per_spool_remaining > 0, per_spool_remaining), else_=0.0)),
+ )
+ .where(has_number, Spool.archived_at.is_(None))
+ .group_by(Spool.material_number)
+ )
+
+ usage_rows = await db.execute(
+ select(
+ Spool.material_number,
+ func.sum(SpoolUsageHistory.weight_used),
+ func.sum(SpoolUsageHistory.cost),
+ )
+ .join(Spool, SpoolUsageHistory.spool_id == Spool.id)
+ .where(*usage_filters)
+ .group_by(Spool.material_number)
+ )
+
+ stats: dict[str, MaterialNumberStats] = {}
+ for number, count, remaining in inventory_rows.all():
+ stats[number] = MaterialNumberStats(
+ material_number=number,
+ spool_count=count,
+ remaining_g=float(remaining or 0),
+ consumed_g=0.0,
+ cost=0.0,
+ )
+ for number, consumed, cost in usage_rows.all():
+ entry = stats.get(number)
+ if entry is None:
+ entry = MaterialNumberStats(
+ material_number=number, spool_count=0, remaining_g=0.0, consumed_g=0.0, cost=0.0
+ )
+ stats[number] = entry
+ entry.consumed_g = float(consumed or 0)
+ entry.cost = float(cost or 0)
+
+ return sorted(stats.values(), key=lambda s: (-s.consumed_g, s.material_number))
+
+
@router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
async def get_all_usage_history(
limit: int = 100,
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 4bf02b442..e728357fa 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -5080,6 +5080,12 @@ async def run_migrations(conn):
# create_all() covers fresh installs; this covers upgrades.
await _migrate_create_supplier_tables(conn)
+ # Migration: Add material_number to spool (#2870). Nullable free text —
+ # the internal purchasing/article number a business costs by, shared by
+ # all spools of the same product. VARCHAR(64) is spelled identically on
+ # SQLite and Postgres.
+ await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN material_number VARCHAR(64)")
+
# Migration: repair the tare of spools the RFID auto-add gave the wrong
# Bambu spool row (#2909). Runs last so the spool catalogue it reads is
# whatever this database actually holds.
diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py
index 46abdcfae..de94db3bc 100644
--- a/backend/app/models/spool.py
+++ b/backend/app/models/spool.py
@@ -57,6 +57,15 @@ class Spool(Base):
# spools with a lower one without changing the global default.
low_stock_threshold_pct: Mapped[int | None] = mapped_column(Integer)
+ # Internal material / article number (#2870): the identifier a business
+ # purchases and costs by (e.g. "15" = Bambu Lab PLA Basic), distinct from
+ # `category` (production grouping) and `note` (free text). Free text, no
+ # uniqueness — several spools of the same product share the number, which
+ # is exactly what makes it a sort/filter/statistics key. New spools of a
+ # matching product inherit it on creation (services/material_number.py,
+ # applied by the spool create routes and the RFID auto-add).
+ material_number: Mapped[str | None] = mapped_column(String(64))
+
# Cost tracking
cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram
diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py
index 1def1a589..ebef0bbc0 100644
--- a/backend/app/schemas/spool.py
+++ b/backend/app/schemas/spool.py
@@ -65,6 +65,19 @@ def normalize_extra_colors(value: str | None) -> str | None:
return ",".join(tokens)
+def normalize_material_number(value: str | None) -> str | None:
+ """Trim the material number and treat a blank one as unset (#2870).
+
+ Every write path lands here (form, bulk edit, CSV import, direct API), so
+ "15" and "15 " can never become two groups in the statistics aggregate or
+ two entries in the inventory filter. Blank collapses to NULL rather than
+ "", which keeps "has no number" a single state to query for.
+ """
+ if value is None:
+ return None
+ return value.strip() or None
+
+
def normalize_effect_type(value: str | None) -> str | None:
if value is None:
return None
@@ -123,6 +136,17 @@ class SpoolBase(BaseModel):
# User-defined category + per-spool low-stock threshold override (#729).
category: str | None = Field(default=None, max_length=50)
low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
+ # Internal material / article number (#2870) — the purchasing identifier,
+ # shared by all spools of the same product. Free text, no uniqueness.
+ material_number: str | None = Field(default=None, max_length=64)
+
+ # mode="before": trim first, so a padded value is held to the 64
+ # characters it will store, not to the length it arrived with.
+ @field_validator("material_number", mode="before")
+ @classmethod
+ def _validate_material_number(cls, v):
+ return normalize_material_number(v) if isinstance(v, str) else v
+
# Free-text storage location, distinct from `location` (AMS slot
# assignment). Column has lived on the ORM since the inventory rework
# but was missing from this schema, so writes were silently dropped (#1291).
@@ -176,6 +200,16 @@ class SpoolUpdate(BaseModel):
# User-defined category + per-spool low-stock threshold override (#729).
category: str | None = Field(default=None, max_length=50)
low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
+ # Internal material / article number (#2870).
+ material_number: str | None = Field(default=None, max_length=64)
+
+ # mode="before": trim first, so a padded value is held to the 64
+ # characters it will store, not to the length it arrived with.
+ @field_validator("material_number", mode="before")
+ @classmethod
+ def _validate_material_number(cls, v):
+ return normalize_material_number(v) if isinstance(v, str) else v
+
storage_location: str | None = Field(default=None, max_length=255)
location_id: int | None = Field(default=None, gt=0)
@@ -254,6 +288,22 @@ class SpoolResponse(SpoolBase):
populate_by_name = True
+class MaterialNumberStats(BaseModel):
+ """Per-material-number inventory aggregate (#2870).
+
+ ``spool_count`` and ``remaining_g`` cover active (non-archived) spools;
+ ``consumed_g`` and ``cost`` sum the recorded usage history of every spool
+ carrying the number, archived included — consumption doesn't disappear
+ when a spool is archived.
+ """
+
+ material_number: str
+ spool_count: int
+ remaining_g: float
+ consumed_g: float
+ cost: float
+
+
class SpoolAssignmentCreate(BaseModel):
spool_id: int
printer_id: int
diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py
index 32e8bac7e..debc1bc10 100644
--- a/backend/app/services/github_backup.py
+++ b/backend/app/services/github_backup.py
@@ -778,6 +778,16 @@ class GitHubBackupService:
"nozzle_temp_max": s.nozzle_temp_max,
"note": s.note,
"cost_per_kg": s.cost_per_kg,
+ # The user's own bookkeeping on the spool: purchasing number
+ # (#2870), category and low-stock override (#729), free-text
+ # storage. All four were missing from this whitelist, so a
+ # restore silently dropped them. `location_id` stays out —
+ # the locations table itself is not in the backup, so the ID
+ # would point at whatever happens to own it on the target.
+ "material_number": s.material_number,
+ "category": s.category,
+ "low_stock_threshold_pct": s.low_stock_threshold_pct,
+ "storage_location": s.storage_location,
"tag_uid": s.tag_uid,
"tray_uuid": s.tray_uuid,
"data_origin": s.data_origin,
diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py
index de88a5fe5..38b8f7672 100644
--- a/backend/app/services/github_restore.py
+++ b/backend/app/services/github_restore.py
@@ -1313,6 +1313,17 @@ class GitHubRestoreService:
"archived_at": _parse_dt(entry.get("archived_at")),
}
+ # The user's own bookkeeping on the spool: purchasing number
+ # (#2870), category and low-stock override (#729), free-text
+ # storage. Added to the backup format after the fields above, so
+ # they are applied only when the file actually carries them —
+ # an older backup must not wipe what the live row holds.
+ # `location_id` is deliberately absent: the locations table is
+ # not backed up, so the ID would point at whatever owns it here.
+ for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
+ if late_field in entry:
+ fields[late_field] = entry[late_field]
+
if existing is not None:
if old_id is not None:
spool_id_map[old_id] = existing.id
diff --git a/backend/app/services/material_number.py b/backend/app/services/material_number.py
new file mode 100644
index 000000000..98641d9a4
--- /dev/null
+++ b/backend/app/services/material_number.py
@@ -0,0 +1,75 @@
+"""Material-number inheritance for newly created spools (#2870).
+
+The material number is the internal purchasing/article identifier a business
+costs by (e.g. "15" = Bambu Lab PLA Basic). All spools of the same product
+share it, so a new spool of an already-numbered product should arrive with
+the number filled instead of blank — whether it is created manually, via the
+API, or by the RFID auto-add.
+"""
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+async def find_material_number_for_product(
+ db: AsyncSession,
+ *,
+ material: str | None,
+ subtype: str | None,
+ brand: str | None,
+ color_name: str | None,
+) -> str | None:
+ """Return the material number an existing spool of this product carries.
+
+ Product identity is the (material, subtype, brand, color_name) string
+ tuple — the same key FilamentSkuSettings groups by. The most recently
+ updated match wins, newest row on a tie. `updated_at` moves on every
+ write, usage included, so this is "most recently touched", not "most
+ recently numbered": when a product's spools disagree, bulk-edit them to
+ one number rather than relying on which one wins. Archived spools count:
+ a product being out of stock doesn't change its number.
+ """
+ if not material:
+ return None
+
+ def _same(column, value):
+ return column.is_(None) if value is None else column == value
+
+ result = await db.execute(
+ select(Spool.material_number)
+ .where(
+ # Normalised to NULL-or-non-empty by the schema validator, so
+ # NULL is the only "unset" state left to exclude.
+ Spool.material_number.is_not(None),
+ Spool.material == material,
+ _same(Spool.subtype, subtype),
+ _same(Spool.brand, brand),
+ _same(Spool.color_name, color_name),
+ )
+ .order_by(Spool.updated_at.desc(), Spool.id.desc())
+ .limit(1)
+ )
+ return result.scalars().first()
+
+
+async def apply_material_number_inheritance(db: AsyncSession, payload: dict) -> dict:
+ """Fill ``payload["material_number"]`` from a matching existing spool.
+
+ No-op when the caller already supplied a non-empty number. Only used on
+ the create paths — editing a spool never overwrites what the user set.
+ """
+ if payload.get("material_number"):
+ return payload
+ number = await find_material_number_for_product(
+ db,
+ material=payload.get("material"),
+ subtype=payload.get("subtype"),
+ brand=payload.get("brand"),
+ color_name=payload.get("color_name"),
+ )
+ if number:
+ payload = dict(payload)
+ payload["material_number"] = number
+ return payload
diff --git a/backend/app/services/spool_csv.py b/backend/app/services/spool_csv.py
index 3696668e1..07183cb93 100644
--- a/backend/app/services/spool_csv.py
+++ b/backend/app/services/spool_csv.py
@@ -34,8 +34,9 @@ from backend.app.schemas.spool import SpoolCreate
# import — `weight_used` is the source of truth, and accepting both would let
# them contradict. `last_used` is a timestamp the model carries but SpoolCreate
# does not, so import applies it to the ORM object directly (see persist path).
-# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate
-# fields included so a round-trip preserves them (they'd otherwise be lost).
+# `storage_location`, `category`, `low_stock_threshold_pct` and
+# `material_number` (#2870) are SpoolCreate fields included so a round-trip
+# preserves them (they'd otherwise be lost).
CSV_COLUMNS = [
"material",
"brand",
@@ -55,6 +56,7 @@ CSV_COLUMNS = [
"storage_location",
"category",
"low_stock_threshold_pct",
+ "material_number",
# Supplier assignments (#2988): `suppliers` is the "; "-joined names of
# all assigned suppliers, `purchase_supplier` the one this spool was
# actually bought from (or empty). Import matches names against the
@@ -382,7 +384,15 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie
row_error: str | None = None
# Plain text passthrough columns.
- for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"):
+ for field in (
+ "subtype",
+ "effect_type",
+ "extra_colors",
+ "note",
+ "storage_location",
+ "category",
+ "material_number",
+ ):
value = cell(raw_row, field)
if value:
data[field] = value
diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py
index 9ce35cf8b..0392f12b5 100644
--- a/backend/app/services/spool_tag_matcher.py
+++ b/backend/app/services/spool_tag_matcher.py
@@ -227,6 +227,18 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
remain_pct = 100 # Unknown → assume full
weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)
+ # A new spool of an already-numbered product inherits its material number
+ # (#2870) — an RFID-scanned refill arrives costed, not blank.
+ from backend.app.services.material_number import find_material_number_for_product
+
+ material_number = await find_material_number_for_product(
+ db,
+ material=material,
+ subtype=subtype,
+ brand="Bambu Lab",
+ color_name=color_name,
+ )
+
spool = Spool(
material=material,
subtype=subtype,
@@ -235,6 +247,7 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
extra_colors=extra_colors,
effect_type=effect_type,
brand="Bambu Lab",
+ material_number=material_number,
label_weight=label_weight,
core_weight=core_weight,
core_weight_catalog_id=core_weight_catalog_id,
diff --git a/backend/tests/integration/test_material_number_api.py b/backend/tests/integration/test_material_number_api.py
new file mode 100644
index 000000000..7130c501a
--- /dev/null
+++ b/backend/tests/integration/test_material_number_api.py
@@ -0,0 +1,528 @@
+"""API coverage for the spool material number (#2870).
+
+The material number is the internal purchasing identifier shared by all
+spools of a product. Pinned here: CRUD round-trip, server-side normalisation,
+inheritance on the create paths, the per-number statistics aggregate and its
+dashboard timeframe, and the CSV round-trip.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+ async def _create(**kwargs):
+ defaults = {
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Jade White",
+ "rgba": "FFFFFFFF",
+ "label_weight": 1000,
+ "core_weight": 250,
+ "weight_used": 0,
+ "weight_used_baseline": 0,
+ "weight_locked": False,
+ }
+ defaults.update(kwargs)
+ spool = Spool(**defaults)
+ db_session.add(spool)
+ await db_session.commit()
+ await db_session.refresh(spool)
+ return spool
+
+ return _create
+
+
+class TestMaterialNumberCrud:
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_create_persists_and_lists_material_number(self, async_client: AsyncClient):
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={"material": "PLA", "material_number": "15"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "15"
+
+ listing = await async_client.get("/api/v1/inventory/spools")
+ assert listing.status_code == 200
+ assert [s["material_number"] for s in listing.json()] == ["15"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_patch_updates_material_number(self, async_client: AsyncClient, spool_factory):
+ spool = await spool_factory(material_number="15")
+
+ resp = await async_client.patch(
+ f"/api/v1/inventory/spools/{spool.id}",
+ json={"material_number": "16"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "16"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_material_number_longer_than_64_chars_is_rejected(self, async_client: AsyncClient):
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={"material": "PLA", "material_number": "x" * 65},
+ )
+ assert resp.status_code == 422
+
+
+class TestMaterialNumberNormalisation:
+ """One validator on the schema, so every write path normalises (#2870).
+
+ Without it "15" and "15 " are two groups in the statistics aggregate and
+ two entries in the inventory filter chip, and the chip's exact match
+ never finds the padded one.
+ """
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_create_strips_surrounding_whitespace(self, async_client: AsyncClient):
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={"material": "PLA", "material_number": " 15 "},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "15"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_create_maps_blank_to_none(self, async_client: AsyncClient):
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={"material": "PLA", "material_number": " "},
+ )
+ assert resp.status_code == 200
+ # NULL, not "" — "has no number" stays a single state to query for.
+ assert resp.json()["material_number"] is None
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_patch_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory):
+ spool = await spool_factory(material_number="15")
+
+ resp = await async_client.patch(
+ f"/api/v1/inventory/spools/{spool.id}",
+ json={"material_number": " 16 "},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "16"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_bulk_edit_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory):
+ spool = await spool_factory()
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools/bulk-update",
+ json={"ids": [spool.id], "update": {"material_number": " 15 "}},
+ )
+ assert resp.status_code == 200
+
+ listing = await async_client.get("/api/v1/inventory/spools")
+ assert [s["material_number"] for s in listing.json()] == ["15"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_csv_import_strips_surrounding_whitespace(self, async_client: AsyncClient):
+ csv = "material,brand,material_number\nPLA,Bambu Lab, 15 \n"
+ resp = await async_client.post(
+ "/api/v1/inventory/spools/import",
+ files={"file": ("spools.csv", csv.encode("utf-8"), "text/csv")},
+ )
+ assert resp.status_code == 200, resp.text
+
+ listing = await async_client.get("/api/v1/inventory/spools")
+ assert [s["material_number"] for s in listing.json()] == ["15"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_padding_does_not_count_against_the_length_cap(self, async_client: AsyncClient, spool_factory):
+ """The cap applies to what is stored: 63 characters with padding
+ around them fit, on every path that goes through the schema."""
+ padded = " " + "x" * 63
+ created = await async_client.post(
+ "/api/v1/inventory/spools", json={"material": "PLA", "material_number": padded}
+ )
+ assert created.status_code == 200, created.text
+ assert created.json()["material_number"] == "x" * 63
+
+ spool = await spool_factory()
+ patched = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": padded})
+ assert patched.status_code == 200, patched.text
+ assert patched.json()["material_number"] == "x" * 63
+
+ bulk = await async_client.post(
+ "/api/v1/inventory/spools/bulk-update",
+ json={"ids": [spool.id], "update": {"material_number": " " + "y" * 64 + " "}},
+ )
+ assert bulk.status_code == 200, bulk.text
+
+ too_long = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": "x" * 65})
+ assert too_long.status_code == 422
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_a_padded_duplicate_does_not_become_a_second_group(self, async_client: AsyncClient, spool_factory):
+ await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15"})
+ await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15 "})
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ assert [r["material_number"] for r in resp.json()] == ["15"]
+ assert resp.json()[0]["spool_count"] == 2
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_a_blank_number_is_not_offered_as_a_group(self, async_client: AsyncClient):
+ await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": " "})
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ assert resp.json() == []
+
+
+class TestMaterialNumberInheritance:
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_new_spool_of_same_product_inherits_number(self, async_client: AsyncClient, spool_factory):
+ await spool_factory(material_number="15")
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Jade White",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "15"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_different_product_does_not_inherit(self, async_client: AsyncClient, spool_factory):
+ await spool_factory(material_number="15")
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Black",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] is None
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_explicit_number_wins_over_inheritance(self, async_client: AsyncClient, spool_factory):
+ await spool_factory(material_number="15")
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Jade White",
+ "material_number": "99",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "99"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_bulk_create_inherits_number(self, async_client: AsyncClient, spool_factory):
+ await spool_factory(material_number="15")
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools/bulk",
+ json={
+ "spool": {
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Jade White",
+ },
+ "quantity": 3,
+ },
+ )
+ assert resp.status_code == 200
+ assert [s["material_number"] for s in resp.json()] == ["15", "15", "15"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_archived_spool_still_provides_the_number(self, async_client: AsyncClient, spool_factory):
+ from datetime import datetime, timezone
+
+ await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Jade White",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "15"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_a_timestamp_tie_goes_to_the_newest_spool(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ """Two donors touched in the same second must not pick one at random."""
+ from datetime import datetime
+
+ same_moment = datetime(2026, 1, 1, 12, 0, 0)
+ older = await spool_factory(material_number="15")
+ newer = await spool_factory(material_number="16")
+ for spool in (older, newer):
+ spool.updated_at = same_moment
+ await db_session.commit()
+
+ resp = await async_client.post(
+ "/api/v1/inventory/spools",
+ json={
+ "material": "PLA",
+ "subtype": "Basic",
+ "brand": "Bambu Lab",
+ "color_name": "Jade White",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["material_number"] == "16"
+
+
+class TestMaterialNumberStats:
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_stats_group_by_number(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession):
+ a = await spool_factory(material_number="15", label_weight=1000, weight_used=200)
+ b = await spool_factory(material_number="15", label_weight=1000, weight_used=0)
+ c = await spool_factory(material_number="16", color_name="Black", label_weight=1000, weight_used=500)
+ await spool_factory(material_number=None, color_name="Gray")
+
+ db_session.add_all(
+ [
+ SpoolUsageHistory(spool_id=a.id, weight_used=120, percent_used=12, status="completed", cost=2.4),
+ SpoolUsageHistory(spool_id=b.id, weight_used=80, percent_used=8, status="completed", cost=1.6),
+ SpoolUsageHistory(spool_id=c.id, weight_used=500, percent_used=50, status="failed", cost=15.0),
+ ]
+ )
+ await db_session.commit()
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ assert resp.status_code == 200
+ rows = {r["material_number"]: r for r in resp.json()}
+
+ assert set(rows) == {"15", "16"}
+ assert rows["15"]["spool_count"] == 2
+ assert rows["15"]["remaining_g"] == pytest.approx(1800)
+ assert rows["15"]["consumed_g"] == pytest.approx(200)
+ assert rows["15"]["cost"] == pytest.approx(4.0)
+ assert rows["16"]["consumed_g"] == pytest.approx(500)
+ assert rows["16"]["cost"] == pytest.approx(15.0)
+ # Heaviest consumption first.
+ assert [r["material_number"] for r in resp.json()] == ["16", "15"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_an_over_consumed_spool_does_not_eat_its_siblings_stock(
+ self, async_client: AsyncClient, spool_factory
+ ):
+ """Remaining stock is clamped per spool, not once over the group.
+
+ weight_used above label_weight is reachable (a scale reading, an AMS
+ sync, or a plain PATCH), and every other remaining-weight computation
+ in the codebase clamps each spool at 0. Summing the raw difference
+ first would subtract the overshoot from the other spools of the same
+ number and report less stock than the inventory list does.
+ """
+ await spool_factory(material_number="15", label_weight=1000, weight_used=0)
+ await spool_factory(material_number="15", color_name="Black", label_weight=1000, weight_used=1200)
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ assert resp.status_code == 200
+ row = resp.json()[0]
+ assert row["spool_count"] == 2
+ assert row["remaining_g"] == pytest.approx(1000)
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_archived_spools_keep_their_recorded_consumption(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ from datetime import datetime, timezone
+
+ archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
+ db_session.add(
+ SpoolUsageHistory(spool_id=archived.id, weight_used=300, percent_used=30, status="completed", cost=6.0)
+ )
+ await db_session.commit()
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ assert resp.status_code == 200
+ rows = {r["material_number"]: r for r in resp.json()}
+ # No active spools carry the number, but the consumption is still there.
+ assert rows["15"]["spool_count"] == 0
+ assert rows["15"]["remaining_g"] == 0
+ assert rows["15"]["consumed_g"] == pytest.approx(300)
+
+
+class TestMaterialNumberStatsTimeframe:
+ """The widget sits in the stats dashboard, so it follows its timeframe.
+
+ Usage history is the per-period half; stock is point-in-time and stays
+ whole — "how much do I hold" has no date range.
+ """
+
+ @staticmethod
+ async def _usage(db_session, spool_id, *, days_ago, grams, cost):
+ from datetime import datetime, timedelta, timezone
+
+ row = SpoolUsageHistory(
+ spool_id=spool_id, weight_used=grams, percent_used=grams / 10, status="completed", cost=cost
+ )
+ # created_at is a server default, so set it explicitly to age the row.
+ row.created_at = (datetime.now(timezone.utc) - timedelta(days=days_ago)).replace(tzinfo=None)
+ db_session.add(row)
+ await db_session.commit()
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_date_from_excludes_older_consumption(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ from datetime import datetime, timedelta, timezone
+
+ spool = await spool_factory(material_number="15", label_weight=1000, weight_used=400)
+ await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
+ await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
+
+ since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
+ resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}")
+ assert resp.status_code == 200
+ row = resp.json()[0]
+ assert row["consumed_g"] == pytest.approx(10)
+ assert row["cost"] == pytest.approx(0.2)
+ # Stock is point-in-time: unaffected by the range.
+ assert row["spool_count"] == 1
+ assert row["remaining_g"] == pytest.approx(600)
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_date_to_excludes_newer_consumption(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ from datetime import datetime, timedelta, timezone
+
+ spool = await spool_factory(material_number="15")
+ await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
+ await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
+
+ until = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
+ resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_to={until}")
+ assert resp.json()[0]["consumed_g"] == pytest.approx(1000)
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_no_range_still_reports_lifetime_totals(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ spool = await spool_factory(material_number="15")
+ await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
+ await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ assert resp.json()[0]["consumed_g"] == pytest.approx(1010)
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_a_number_with_no_usage_in_range_still_lists_its_stock(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ from datetime import datetime, timedelta, timezone
+
+ spool = await spool_factory(material_number="15", label_weight=1000, weight_used=250)
+ await self._usage(db_session, spool.id, days_ago=200, grams=250, cost=5.0)
+
+ since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
+ resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}")
+ row = resp.json()[0]
+ assert row["consumed_g"] == 0
+ assert row["remaining_g"] == pytest.approx(750)
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_ties_sort_by_number_so_the_order_is_stable(
+ self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+ ):
+ """Equal consumption has to fall back to the number, not to row order.
+
+ The response is assembled in two passes — active spools first, then
+ the numbers that only appear in usage history — so "16" (which has a
+ live spool) is seeded before "15" (archived, usage only). Without the
+ number tie-break the endpoint hands that seeding order straight back.
+ """
+ from datetime import datetime, timezone
+
+ live = await spool_factory(material_number="16", color_name="Black")
+ archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
+ db_session.add_all(
+ [
+ SpoolUsageHistory(spool_id=live.id, weight_used=100, percent_used=10, status="completed", cost=2.0),
+ SpoolUsageHistory(spool_id=archived.id, weight_used=100, percent_used=10, status="completed", cost=2.0),
+ ]
+ )
+ await db_session.commit()
+
+ resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+ rows = resp.json()
+ assert [r["consumed_g"] for r in rows] == [pytest.approx(100), pytest.approx(100)]
+ assert [r["material_number"] for r in rows] == ["15", "16"]
+
+
+class TestMaterialNumberCsv:
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_export_import_round_trip(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession):
+ await spool_factory(material_number="15")
+
+ export = await async_client.get("/api/v1/inventory/spools/export")
+ assert export.status_code == 200
+ text = export.text
+ header = text.splitlines()[0]
+ assert "material_number" in header.split(",")
+ assert ",15" in text.splitlines()[1] or text.splitlines()[1].endswith("15")
+
+ # Wipe and re-import: the number must survive the round trip.
+ from sqlalchemy import delete
+
+ await db_session.execute(delete(Spool))
+ await db_session.commit()
+
+ imported = await async_client.post(
+ "/api/v1/inventory/spools/import",
+ files={"file": ("spools.csv", text.encode("utf-8"), "text/csv")},
+ )
+ assert imported.status_code == 200, imported.text
+ assert imported.json()["created"] == 1
+
+ listing = await async_client.get("/api/v1/inventory/spools")
+ assert [s["material_number"] for s in listing.json()] == ["15"]
diff --git a/backend/tests/unit/services/test_spool_tag_matcher.py b/backend/tests/unit/services/test_spool_tag_matcher.py
index edfaf80df..429fda2ef 100644
--- a/backend/tests/unit/services/test_spool_tag_matcher.py
+++ b/backend/tests/unit/services/test_spool_tag_matcher.py
@@ -96,6 +96,39 @@ async def test_create_spool_from_tray_weight_from_remain(db_session):
assert spool.weight_used == 200.0
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_inherits_material_number(db_session):
+ """A scanned refill of an already-numbered product arrives costed (#2870).
+
+ This is the one create path nobody watches: it fires from the AMS, not
+ from a request, and it hardcodes brand="Bambu Lab" in the lookup.
+ """
+ first = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+ await db_session.commit()
+ assert first.material_number is None
+
+ first.material_number = "15"
+ await db_session.commit()
+
+ second = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+ await db_session.commit()
+
+ assert second.material_number == "15"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_does_not_inherit_across_products(db_session):
+ """The number follows the product, not the brand (#2870)."""
+ donor = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+ donor.material_number = "15"
+ await db_session.commit()
+
+ other = await create_spool_from_tray(db_session, {**SAMPLE_TRAY, "tray_type": "PETG"})
+ await db_session.commit()
+
+ assert other.material_number is None
+
+
@pytest.mark.asyncio
async def test_create_spool_from_tray_relationships_loaded(db_session):
"""Both k_profiles and assignments must be eagerly initialized.
diff --git a/backend/tests/unit/test_github_backup_spool_fields.py b/backend/tests/unit/test_github_backup_spool_fields.py
new file mode 100644
index 000000000..13297cd4c
--- /dev/null
+++ b/backend/tests/unit/test_github_backup_spool_fields.py
@@ -0,0 +1,47 @@
+"""The spool collector's field whitelist for Git backup (#2870).
+
+``_collect_spools`` builds an explicit dict per spool, so anything missing
+from it is silently absent from the backup and therefore lost on restore.
+The owner's own bookkeeping — purchasing number (#2870), category and
+low-stock override (#729), free-text storage — belongs in the file.
+"""
+
+import pytest
+
+from backend.app.models.spool import Spool
+from backend.app.services.github_backup import GitHubBackupService
+
+
+@pytest.mark.asyncio
+async def test_collects_the_owners_own_bookkeeping_fields(db_session):
+ db_session.add(
+ Spool(
+ material="PLA",
+ material_number="15",
+ category="Production",
+ low_stock_threshold_pct=40,
+ storage_location="Shelf B",
+ )
+ )
+ await db_session.commit()
+
+ files: dict = {}
+ await GitHubBackupService()._collect_spools(db_session, files)
+
+ entry = files["spools/inventory.json"]["spools"][0]
+ assert entry["material_number"] == "15"
+ assert entry["category"] == "Production"
+ assert entry["low_stock_threshold_pct"] == 40
+ assert entry["storage_location"] == "Shelf B"
+
+
+@pytest.mark.asyncio
+async def test_location_id_is_left_out(db_session):
+ """The locations table is not backed up, so the ID has nothing to mean."""
+ db_session.add(Spool(material="PLA"))
+ await db_session.commit()
+
+ files: dict = {}
+ await GitHubBackupService()._collect_spools(db_session, files)
+
+ assert "location_id" not in files["spools/inventory.json"]["spools"][0]
diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py
index c2d8236ce..c6e7771a0 100644
--- a/backend/tests/unit/test_github_restore.py
+++ b/backend/tests/unit/test_github_restore.py
@@ -931,6 +931,62 @@ class TestRestoreSpools:
assert len(spools) == 1
assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
+ @pytest.mark.asyncio
+ async def test_restores_the_owners_own_bookkeeping_fields(self, db_session):
+ """#2870 + #729: purchasing number, category, threshold, storage."""
+ tally = _CategoryTally()
+ entry = self._spool_entry(
+ material_number="15",
+ category="Production",
+ low_stock_threshold_pct=40,
+ storage_location="Shelf B",
+ )
+
+ await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+ await db_session.commit()
+
+ row = (await db_session.execute(select(Spool))).scalar_one()
+ assert row.material_number == "15"
+ assert row.category == "Production"
+ assert row.low_stock_threshold_pct == 40
+ assert row.storage_location == "Shelf B"
+
+ @pytest.mark.asyncio
+ async def test_a_backup_predating_those_fields_does_not_wipe_them(self, db_session):
+ """An old file has no such keys — overwrite must leave the live values."""
+ db_session.add(
+ Spool(
+ material="PLA",
+ tag_uid="AABBCCDD",
+ material_number="15",
+ category="Production",
+ low_stock_threshold_pct=40,
+ storage_location="Shelf B",
+ )
+ )
+ await db_session.commit()
+ tally = _CategoryTally()
+
+ await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+ await db_session.commit()
+
+ row = (await db_session.execute(select(Spool))).scalar_one()
+ assert row.material_number == "15"
+ assert row.category == "Production"
+ assert row.low_stock_threshold_pct == 40
+ assert row.storage_location == "Shelf B"
+
+ @pytest.mark.asyncio
+ async def test_location_id_is_never_restored(self, db_session):
+ """The locations table is not in the backup, so the ID is meaningless."""
+ tally = _CategoryTally()
+ entry = self._spool_entry(location_id=99)
+
+ await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+ await db_session.commit()
+
+ assert (await db_session.execute(select(Spool))).scalar_one().location_id is None
+
@pytest.mark.asyncio
async def test_usage_history_spool_id_is_remapped(self, db_session):
"""Usage rows must point at the new local spool id, not the backup's."""
diff --git a/backend/tests/unit/test_material_number_migration.py b/backend/tests/unit/test_material_number_migration.py
new file mode 100644
index 000000000..1b64f228f
--- /dev/null
+++ b/backend/tests/unit/test_material_number_migration.py
@@ -0,0 +1,94 @@
+"""Migration tests for the spool material_number column (#2870).
+
+A legacy database whose spool table predates the column must gain it on
+upgrade, existing rows must read back as NULL, and re-running the migration
+must be a no-op (idempotent _safe_execute).
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+ from backend.app.core import db_dialect
+
+ monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+ monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+ from backend.app.core import database as database_module
+
+ monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+ import backend.app.models # noqa: F401
+ from backend.app.models import ( # noqa: F401
+ external_link,
+ location,
+ print_log,
+ print_queue,
+ project_bom,
+ slot_preset,
+ spoolman_k_profile,
+ spoolman_slot_assignment,
+ virtual_printer,
+ )
+
+
+@pytest.fixture
+async def engine_with_legacy_spool_table():
+ """create_all builds the current schema; dropping the column afterwards
+ reproduces a database from a Bambuddy version that predates #2870."""
+ from backend.app.core.database import Base
+
+ _register_all_models()
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.execute(text("ALTER TABLE spool DROP COLUMN material_number"))
+ await conn.execute(
+ text(
+ """
+ INSERT INTO spool (
+ material, label_weight, core_weight,
+ weight_used, weight_used_baseline, weight_locked
+ )
+ VALUES ('PLA', 1000, 250, 0, 0, 0)
+ """
+ )
+ )
+ yield engine
+ await engine.dispose()
+
+
+async def test_migration_adds_material_number_column(engine_with_legacy_spool_table):
+ async with engine_with_legacy_spool_table.begin() as conn:
+ await run_migrations(conn)
+
+ async with engine_with_legacy_spool_table.connect() as conn:
+ rows = (await conn.execute(text("SELECT id, material, material_number FROM spool"))).all()
+
+ assert len(rows) == 1
+ # Pre-existing rows read back with NULL, not an error or a default.
+ assert rows[0].material_number is None
+
+
+async def test_migration_is_idempotent(engine_with_legacy_spool_table):
+ async with engine_with_legacy_spool_table.begin() as conn:
+ await run_migrations(conn)
+ # A value written after the first run must survive the second run — the
+ # duplicate ALTER TABLE is swallowed, not applied destructively.
+ async with engine_with_legacy_spool_table.begin() as conn:
+ await conn.execute(text("UPDATE spool SET material_number = '15'"))
+ async with engine_with_legacy_spool_table.begin() as conn:
+ await run_migrations(conn)
+
+ async with engine_with_legacy_spool_table.connect() as conn:
+ value = (await conn.execute(text("SELECT material_number FROM spool"))).scalar_one()
+
+ assert value == "15"
diff --git a/backend/tests/unit/test_spoolman_inventory_helpers.py b/backend/tests/unit/test_spoolman_inventory_helpers.py
index f81bb4408..715912371 100644
--- a/backend/tests/unit/test_spoolman_inventory_helpers.py
+++ b/backend/tests/unit/test_spoolman_inventory_helpers.py
@@ -107,6 +107,22 @@ class TestMapSpoolmanSpool:
assert result["weight_used_baseline"] == pytest.approx(0.0)
assert result["data_origin"] == "spoolman"
+ def test_article_number_maps_to_material_number(self):
+ """Spoolman's filament.article_number is the material number (#2870)."""
+ spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": "15"}}
+ assert _map_spoolman_spool(spool)["material_number"] == "15"
+
+ def test_missing_or_blank_article_number_maps_to_none(self):
+ assert _map_spoolman_spool(MINIMAL_SPOOL)["material_number"] is None
+ for value in ("", " "):
+ blank = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": value}}
+ assert _map_spoolman_spool(blank)["material_number"] is None
+
+ def test_padded_article_number_is_trimmed(self):
+ """The filter chip matches exactly against trimmed options (#2870)."""
+ spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": " 15 "}}
+ assert _map_spoolman_spool(spool)["material_number"] == "15"
+
def test_remaining_weight_drives_synthetic_used_for_parity(self):
"""When remaining_weight is set, weight_used = label - remaining and
the baseline absorbs the used_weight delta. This mirrors the internal
diff --git a/frontend/src/__tests__/components/AdditionalSection.test.tsx b/frontend/src/__tests__/components/AdditionalSection.test.tsx
index 383cb472d..b0f86ef74 100644
--- a/frontend/src/__tests__/components/AdditionalSection.test.tsx
+++ b/frontend/src/__tests__/components/AdditionalSection.test.tsx
@@ -17,6 +17,7 @@ const baseProps = {
spoolCatalog: [],
currencySymbol: '$',
availableCategories: [],
+ availableMaterialNumbers: [],
globalLowStockThreshold: 20,
};
@@ -26,4 +27,17 @@ describe('AdditionalSection', () => {
// SpoolWeightPicker renders the 'inventory.coreWeight' label
expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
});
+
+ it('renders the material number field in internal mode (#2870)', () => {
+ render( );
+ expect(screen.getByText('inventory.materialNumber')).toBeTruthy();
+ });
+
+ it('hides the material number field in Spoolman mode (#2870)', () => {
+ // In Spoolman mode the number is the filament-level article_number,
+ // maintained in Spoolman itself — the form must not offer an input
+ // whose value would be silently dropped.
+ render( );
+ expect(screen.queryByText('inventory.materialNumber')).toBeNull();
+ });
});
diff --git a/frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx b/frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx
new file mode 100644
index 000000000..1f7b418fd
--- /dev/null
+++ b/frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx
@@ -0,0 +1,69 @@
+/**
+ * Bulk edit: the field list must only offer fields the active inventory
+ * backend can actually store (#2870).
+ *
+ * In Spoolman mode a spool has no material number, category or low-stock
+ * override of its own — SpoolmanInventoryUpdate has no such fields, so the
+ * payload dumps to {} and the route answers 400 "update must include at
+ * least one field". The user ticks a box, types a value, clicks Apply and
+ * gets an error. Filtering the list is the fix.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { BulkEditSpoolsModal } from '../../components/BulkEditSpoolsModal';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+const baseProps = {
+ isOpen: true,
+ selectedCount: 3,
+ isPending: false,
+ availableLocations: [],
+ availableMaterials: [],
+ availableSubtypes: [],
+ availableBrands: [],
+ availableCategories: [],
+ availableMaterialNumbers: [],
+ availableSlicerFilaments: [],
+ availableSlicerFilamentNames: [],
+ onClose: vi.fn(),
+ onApply: vi.fn(),
+};
+
+const INTERNAL_ONLY = [
+ 'inventory.materialNumber',
+ 'inventory.category',
+ 'inventory.lowStockThresholdOverride',
+];
+
+describe('BulkEditSpoolsModal field list', () => {
+ it('offers the internal-only fields in internal mode', () => {
+ render( );
+ for (const key of INTERNAL_ONLY) {
+ expect(screen.getByText(key)).toBeTruthy();
+ }
+ });
+
+ it('hides every field Spoolman cannot store in Spoolman mode', () => {
+ render( );
+ for (const key of INTERNAL_ONLY) {
+ expect(screen.queryByText(key)).toBeNull();
+ }
+ // The fields Spoolman does accept stay.
+ expect(screen.getByText('inventory.material')).toBeTruthy();
+ expect(screen.getByText('inventory.note')).toBeTruthy();
+ expect(screen.getByText('inventory.costPerKg')).toBeTruthy();
+ });
+
+ it('defaults to internal mode when the prop is omitted', () => {
+ render( );
+ expect(screen.getByText('inventory.materialNumber')).toBeTruthy();
+ });
+});
diff --git a/frontend/src/__tests__/components/MaterialNumberStats.test.tsx b/frontend/src/__tests__/components/MaterialNumberStats.test.tsx
new file mode 100644
index 000000000..823b0853c
--- /dev/null
+++ b/frontend/src/__tests__/components/MaterialNumberStats.test.tsx
@@ -0,0 +1,101 @@
+/**
+ * Tests for the MaterialNumberStats widget (#2870).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MaterialNumberStats } from '../../components/MaterialNumberStats';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+ api: {
+ getMaterialNumberStats: vi.fn(),
+ },
+}));
+
+function renderWidget(props: { dateFrom?: string; dateTo?: string } = {}) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+describe('MaterialNumberStats', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders one row per material number with weights and cost', async () => {
+ (api.getMaterialNumberStats as ReturnType).mockResolvedValue([
+ { material_number: '16', spool_count: 1, remaining_g: 500, consumed_g: 1500, cost: 45 },
+ { material_number: '15', spool_count: 12, remaining_g: 9500, consumed_g: 250, cost: 5 },
+ ]);
+ renderWidget();
+
+ expect(await screen.findByText('16')).toBeInTheDocument();
+ expect(screen.getByText('15')).toBeInTheDocument();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ // >= 1 kg renders as kilograms, below stays in grams.
+ expect(screen.getByText('9.50 kg')).toBeInTheDocument();
+ expect(screen.getByText('500 g')).toBeInTheDocument();
+ expect(screen.getByText('EUR 45.00')).toBeInTheDocument();
+ });
+
+ it('shows the empty hint when no numbers are assigned', async () => {
+ (api.getMaterialNumberStats as ReturnType).mockResolvedValue([]);
+ renderWidget();
+
+ expect(await screen.findByText(/No material numbers assigned yet/)).toBeInTheDocument();
+ });
+
+ // A 403 from a missing INVENTORY_READ, a 500 or a dropped connection are
+ // not "you have not numbered your spools" — the two states must read
+ // differently or the user goes looking for a problem that isn't there.
+ it('reports an API failure as a failure, not as an empty inventory', async () => {
+ (api.getMaterialNumberStats as ReturnType).mockRejectedValue(new Error('403'));
+ renderWidget();
+
+ expect(await screen.findByText(/Could not load the material number statistics/)).toBeInTheDocument();
+ expect(screen.queryByText(/No material numbers assigned yet/)).not.toBeInTheDocument();
+ });
+
+ it('passes the dashboard timeframe to the endpoint', async () => {
+ (api.getMaterialNumberStats as ReturnType).mockResolvedValue([]);
+ renderWidget({ dateFrom: '2026-08-01', dateTo: '2026-08-31' });
+
+ await screen.findByText(/No material numbers assigned yet/);
+ expect(api.getMaterialNumberStats).toHaveBeenCalledWith({
+ dateFrom: '2026-08-01',
+ dateTo: '2026-08-31',
+ });
+ });
+
+ // The range is part of the query key, so the cache cannot serve a
+ // 30-day answer when the dashboard has moved to 90.
+ it('refetches on the same client when the timeframe changes', async () => {
+ (api.getMaterialNumberStats as ReturnType).mockResolvedValue([]);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const { rerender } = render(
+
+
+ ,
+ );
+ await screen.findByText(/No material numbers assigned yet/);
+ expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+
+ ,
+ );
+
+ await vi.waitFor(() => expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(2));
+ expect(api.getMaterialNumberStats).toHaveBeenLastCalledWith({
+ dateFrom: '2026-09-01',
+ dateTo: undefined,
+ });
+ });
+});
diff --git a/frontend/src/__tests__/hooks/useWebSocket.test.ts b/frontend/src/__tests__/hooks/useWebSocket.test.ts
index d14be149f..dbce7ecd0 100644
--- a/frontend/src/__tests__/hooks/useWebSocket.test.ts
+++ b/frontend/src/__tests__/hooks/useWebSocket.test.ts
@@ -468,6 +468,8 @@ describe('useWebSocket hook', () => {
// #2988: without this key the supplier broadcast reached nothing, so a
// supplier created in one tab never showed up in another.
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] });
+ // The per-material-number aggregate is derived from the same rows (#2870).
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] });
vi.useRealTimers();
vi.unstubAllGlobals();
diff --git a/frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx b/frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx
new file mode 100644
index 000000000..2d962e96a
--- /dev/null
+++ b/frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx
@@ -0,0 +1,141 @@
+/**
+ * The material-number filter chip (#2870).
+ *
+ * The chip needs one slot for "no number assigned". The other chips spell
+ * that '__none__', but a material number is free text, so '__none__' can be
+ * a real value — and then picking it filtered for the spools that have no
+ * number at all. The sentinel is now longer than the column's 64-character
+ * cap, so no spool can collide with it.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, 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 BASE = {
+ material: 'PLA',
+ subtype: 'Basic',
+ color_name: 'Red',
+ rgba: 'FF0000FF',
+ label_weight: 1000,
+ core_weight: 250,
+ weight_used: 100,
+ slicer_filament: null,
+ slicer_filament_name: null,
+ nozzle_temp_min: 220,
+ nozzle_temp_max: 240,
+ 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,
+ storage_location: null,
+ category: null,
+ low_stock_threshold_pct: null,
+ weight_locked: false,
+};
+
+// One spool whose material number is literally the old sentinel, one with no
+// number at all. The two must never be confused for each other.
+const SPOOLS = [
+ { ...BASE, id: 1, brand: 'AlphaBrand', material_number: '__none__' },
+ { ...BASE, id: 2, brand: 'BetaBrand', material_number: null },
+];
+
+function setupHandlers() {
+ server.use(
+ http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD', low_stock_threshold: 20.0, language: 'en' })),
+ http.get('/api/v1/settings/spoolman', () =>
+ HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
+ ),
+ http.get('/api/v1/inventory/spools', () => HttpResponse.json(SPOOLS)),
+ http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+ http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+ http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+ http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
+ http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+ http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
+ http.get('/api/v1/printers/', () => HttpResponse.json([])),
+ );
+}
+
+async function materialNumberSelect(): Promise {
+ return waitFor(() => {
+ const found = screen
+ .getAllByRole('combobox')
+ .find((el) => el.querySelector('option[value=""]')?.textContent === 'Material No.');
+ if (!found) throw new Error('material number chip not rendered');
+ return found as HTMLSelectElement;
+ });
+}
+
+/** Brand names shown in the table body — the chips list brands too. */
+function rowBrands(): string[] {
+ return Array.from(document.querySelectorAll('tbody tr'))
+ .map((row) => row.textContent ?? '')
+ .flatMap((text) => ['AlphaBrand', 'BetaBrand'].filter((b) => text.includes(b)));
+}
+
+describe('InventoryPage material-number filter', () => {
+ beforeEach(() => {
+ setupHandlers();
+ vi.mocked(localStorage.getItem).mockReturnValue(null);
+ });
+
+ it('filters for the literal value "__none__" rather than for unnumbered spools', async () => {
+ render( );
+ const select = await materialNumberSelect();
+ await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand', 'BetaBrand']));
+
+ fireEvent.change(select, { target: { value: '__none__' } });
+
+ // The spool whose number IS '__none__', not the one without a number.
+ await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand']));
+ });
+
+ it('still offers a slot that finds the spools with no number', async () => {
+ render( );
+ const select = await materialNumberSelect();
+ await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand', 'BetaBrand']));
+
+ const noneOption = Array.from(select.options).find((o) => o.textContent === 'No material number');
+ expect(noneOption).toBeTruthy();
+ // The sentinel outruns SpoolBase's 64-character cap, so it is a value no
+ // spool can carry.
+ expect(noneOption!.value.length).toBeGreaterThan(64);
+
+ fireEvent.change(select, { target: { value: noneOption!.value } });
+
+ await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand']));
+ });
+
+ it('sorts the Material No. column numerically, "2" before "15"', async () => {
+ server.use(
+ http.get('/api/v1/inventory/spools', () =>
+ HttpResponse.json([
+ { ...BASE, id: 1, brand: 'AlphaBrand', material_number: '15' },
+ { ...BASE, id: 2, brand: 'BetaBrand', material_number: '2' },
+ ])
+ ),
+ );
+ vi.mocked(localStorage.getItem).mockImplementation((key) =>
+ key === 'bambuddy-inventory-sort' ? '{"column":"material_number","direction":"asc"}' : null,
+ );
+ render( );
+
+ await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand', 'AlphaBrand']));
+ });
+});
diff --git a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
index 987b656e6..4d2e74b63 100644
--- a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
+++ b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
@@ -357,4 +357,60 @@ describe('SpoolBuddyWriteTagPage', () => {
expect(payload).not.toHaveProperty('core_weight_catalog_id');
});
});
+
+ // #2870: the form rendered a Material No. input whose value never made it
+ // into the create payload, so the spool was saved with an inherited number
+ // or none at all — silently, with no hint that the typed one was dropped.
+ async function openFullNewSpoolForm() {
+ const rendered = renderPage();
+ fireEvent.click(screen.getByText('New Spool'));
+ await waitFor(() => {
+ expect(screen.getByText('Material')).toBeDefined();
+ });
+ // The simple view's material select is the cheapest way to satisfy the
+ // only field validateForm insists on.
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA' } });
+ fireEvent.click(screen.getByText('Full'));
+ await waitFor(() => {
+ expect(screen.getByText('Quick Add')).toBeDefined();
+ });
+ return rendered;
+ }
+
+ it('sends the material number the operator typed (#2870)', async () => {
+ const { container } = await openFullNewSpoolForm();
+
+ // Quick Add so material is the only required field on this form.
+ const quickAddToggle = screen.getByText('Quick Add').parentElement?.querySelector('button');
+ expect(quickAddToggle).toBeTruthy();
+ fireEvent.click(quickAddToggle as HTMLElement);
+
+ const numberInput = container.querySelector('#spool-material-number') as HTMLInputElement;
+ expect(numberInput).toBeTruthy();
+ fireEvent.change(numberInput, { target: { value: '77' } });
+
+ fireEvent.click(screen.getByText('Create Spool'));
+
+ await waitFor(() => {
+ expect(vi.mocked(mockedApi.createSpool)).toHaveBeenCalled();
+ });
+ expect(vi.mocked(mockedApi.createSpool).mock.calls[0][0]).toEqual(
+ expect.objectContaining({ material_number: '77' }),
+ );
+ });
+
+ it('does not offer the material number in Spoolman mode (#2870)', async () => {
+ // There the number is Spoolman's filament-level article_number; an input
+ // the internal create path cannot store must not be shown.
+ vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({
+ spoolman_enabled: 'true',
+ spoolman_url: 'http://spoolman.test',
+ spoolman_sync_mode: '',
+ spoolman_disable_weight_sync: '',
+ spoolman_report_partial_usage: '',
+ });
+ const { container } = await openFullNewSpoolForm();
+
+ expect(container.querySelector('#spool-material-number')).toBeNull();
+ });
});
diff --git a/frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx b/frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx
new file mode 100644
index 000000000..f6087b3cb
--- /dev/null
+++ b/frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx
@@ -0,0 +1,186 @@
+/**
+ * The "By Material Number" widget on the stats dashboard (#2870).
+ *
+ * It sits in the same grid as the widgets that follow the dashboard
+ * timeframe, so its usage half has to follow it too — otherwise it shows
+ * lifetime totals next to cards headed "Last 30 days".
+ *
+ * In Spoolman mode it aggregates the internal spool table, which is empty
+ * there, while the inventory list does show numbers mapped from Spoolman's
+ * filament.article_number. A permanently empty card saying "no material
+ * numbers assigned yet" next to an inventory full of them is worse than no
+ * card, so the widget is dropped in that mode.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { StatsPage } from '../../pages/StatsPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const EMPTY_STATS = {
+ total_prints: 0,
+ successful_prints: 0,
+ failed_prints: 0,
+ cancelled_prints: 0,
+ total_print_time_hours: 0,
+ total_filament_grams: 0,
+ total_cost: 0,
+ prints_by_filament_type: {},
+ prints_by_printer: {},
+ average_time_accuracy: 0,
+ time_accuracy_by_printer: {},
+ total_energy_kwh: 0,
+ total_energy_cost: 0,
+};
+
+let materialNumberRequests: URL[] = [];
+
+function setupHandlers(spoolmanEnabled: boolean, spoolmanGate?: Promise) {
+ server.use(
+ http.get('/api/v1/archives/stats', () => HttpResponse.json(EMPTY_STATS)),
+ http.get('/api/v1/printers/', () => HttpResponse.json([])),
+ http.get('/api/v1/archives/slim', () => HttpResponse.json([])),
+ http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD' })),
+ http.get('/api/v1/settings/spoolman', async () => {
+ // A gate lets a test hold the settings response back while the rest of
+ // the dashboard renders, which is the ordering the widget has to survive.
+ if (spoolmanGate) await spoolmanGate;
+ return HttpResponse.json({
+ spoolman_enabled: spoolmanEnabled ? 'true' : 'false',
+ spoolman_url: spoolmanEnabled ? 'http://spoolman.local' : '',
+ });
+ }),
+ http.get('/api/v1/archives/analysis/failures', () =>
+ HttpResponse.json({
+ period_days: 30,
+ total_prints: 0,
+ failed_prints: 0,
+ failure_rate: 0,
+ failures_by_reason: {},
+ failures_by_filament: {},
+ failures_by_printer: {},
+ failures_by_hour: {},
+ recent_failures: [],
+ trend: [],
+ })
+ ),
+ http.get('/api/v1/inventory/stats/material-numbers', ({ request }) => {
+ materialNumberRequests.push(new URL(request.url));
+ return HttpResponse.json([]);
+ })
+ );
+}
+
+/** The suite stubs localStorage with bare mocks, so feed the timeframe in. */
+function withTimeframe(preset: string) {
+ (localStorage.getItem as ReturnType).mockImplementation((key: string) =>
+ key === 'bambusy-stats-timeframe' ? JSON.stringify({ preset }) : null
+ );
+}
+
+describe('StatsPage material-number widget', () => {
+ beforeEach(() => {
+ materialNumberRequests = [];
+ (localStorage.getItem as ReturnType).mockReset();
+ });
+
+ afterEach(() => {
+ (localStorage.getItem as ReturnType).mockReset();
+ });
+
+ it('asks the endpoint for the dashboard timeframe', async () => {
+ withTimeframe('last-30');
+ setupHandlers(false);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('By Material Number')).toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(materialNumberRequests.length).toBeGreaterThan(0);
+ });
+
+ const today = new Date();
+ const from = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 29));
+ expect(materialNumberRequests[0].searchParams.get('date_from')).toBe(from.toISOString().split('T')[0]);
+ expect(materialNumberRequests[0].searchParams.get('date_to')).toBe(today.toISOString().split('T')[0]);
+ });
+
+ it('asks for lifetime totals when the timeframe is all time', async () => {
+ withTimeframe('all-time');
+ setupHandlers(false);
+ render( );
+
+ await waitFor(() => {
+ expect(materialNumberRequests.length).toBeGreaterThan(0);
+ });
+ expect(materialNumberRequests[0].searchParams.get('date_from')).toBeNull();
+ expect(materialNumberRequests[0].searchParams.get('date_to')).toBeNull();
+ });
+
+ it('drops the widget entirely in Spoolman mode', async () => {
+ setupHandlers(true);
+ render( );
+
+ // Wait for the dashboard to be up before asserting on an absence.
+ await waitFor(() => {
+ expect(screen.getByText('Statistics')).toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('By Material Number')).toBeNull();
+ expect(materialNumberRequests).toHaveLength(0);
+ });
+
+ // #2870: the mode decision has to wait for the settings response. Deriving
+ // it from `undefined` treats "not loaded yet" as "internal mode", so a
+ // Spoolman install flashed the card and fired the aggregate request before
+ // the setting arrived.
+ it('holds the widget back until the Spoolman setting has resolved', async () => {
+ let openGate = () => {};
+ const gate = new Promise((resolve) => {
+ openGate = resolve;
+ });
+ setupHandlers(true, gate);
+ render( );
+
+ // The dashboard is fully up on the archive response alone.
+ await waitFor(() => {
+ expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('By Material Number')).toBeNull();
+ expect(materialNumberRequests).toHaveLength(0);
+
+ openGate();
+ await waitFor(() => {
+ expect(screen.queryByText('By Material Number')).toBeNull();
+ });
+ expect(materialNumberRequests).toHaveLength(0);
+ });
+
+ it('shows the widget once the setting says this is not Spoolman mode', async () => {
+ let openGate = () => {};
+ const gate = new Promise((resolve) => {
+ openGate = resolve;
+ });
+ setupHandlers(false, gate);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('By Material Number')).toBeNull();
+
+ openGate();
+ await waitFor(() => {
+ expect(screen.getByText('By Material Number')).toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(materialNumberRequests.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 4358ceb87..e67656474 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -3658,6 +3658,9 @@ export interface InventorySpool {
// User-defined category + per-spool low-stock threshold override (#729).
category: string | null;
low_stock_threshold_pct: number | null;
+ // Internal material / article number (#2870) — the purchasing identifier
+ // shared by all spools of the same product.
+ material_number: string | null;
k_profiles?: SpoolKProfile[];
storage_location?: string | null;
location_id?: number | null;
@@ -3718,6 +3721,15 @@ export interface SupplierStats {
cost: number;
}
+/** Per-material-number inventory aggregate (#2870). */
+export interface MaterialNumberStats {
+ material_number: string;
+ spool_count: number;
+ remaining_g: number;
+ consumed_g: number;
+ cost: number;
+}
+
export interface SpoolmanBulkCreateResult {
created: InventorySpool[];
requested_count: number;
@@ -6798,6 +6810,15 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(data),
}),
+ // Per-material-number inventory aggregate (#2870). The date range narrows
+ // the usage half only — stock is point-in-time.
+ getMaterialNumberStats: (options?: { dateFrom?: string; dateTo?: string }) => {
+ const params = new URLSearchParams();
+ if (options?.dateFrom) params.set('date_from', options.dateFrom);
+ if (options?.dateTo) params.set('date_to', options.dateTo);
+ const qs = params.toString();
+ return request(`/inventory/stats/material-numbers${qs ? `?${qs}` : ''}`);
+ },
getSpoolUsageHistory: (spoolId: number, limit = 50) =>
request(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
getAllUsageHistory: (limit = 100, printerId?: number) =>
diff --git a/frontend/src/components/BulkEditSpoolsModal.tsx b/frontend/src/components/BulkEditSpoolsModal.tsx
index b0bb0ac54..264975ec6 100644
--- a/frontend/src/components/BulkEditSpoolsModal.tsx
+++ b/frontend/src/components/BulkEditSpoolsModal.tsx
@@ -26,7 +26,8 @@ type EditableField =
| 'label_weight'
| 'core_weight'
| 'category'
- | 'low_stock_threshold_pct';
+ | 'low_stock_threshold_pct'
+ | 'material_number';
type FieldSpec = {
id: EditableField;
@@ -43,6 +44,11 @@ type FieldSpec = {
step?: number;
/** Hex pattern for the rgba field. */
pattern?: string;
+ /** Lives on the internal spool row only. A Spoolman spool has no such
+ * field, so SpoolmanInventoryUpdate drops it, the payload dumps to {} and
+ * the route answers 400 "update must include at least one field". Offering
+ * it in Spoolman mode is offering a button that cannot work. */
+ internalOnly?: boolean;
};
const FIELDS: FieldSpec[] = [
@@ -58,8 +64,11 @@ const FIELDS: FieldSpec[] = [
{ id: 'note', type: 'textarea', labelKey: 'inventory.note' },
{ id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 },
{ id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 },
- { id: 'category', type: 'searchable', labelKey: 'inventory.category' },
- { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 },
+ { id: 'category', type: 'searchable', labelKey: 'inventory.category', internalOnly: true },
+ { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1, internalOnly: true },
+ // Internal material / article number (#2870) — bulk-assigning it is the
+ // main way existing inventories get numbered.
+ { id: 'material_number', type: 'searchable', labelKey: 'inventory.materialNumber', internalOnly: true },
];
export interface BulkEditSpoolsModalProps {
@@ -72,8 +81,11 @@ export interface BulkEditSpoolsModalProps {
availableSubtypes: string[];
availableBrands: string[];
availableCategories: string[];
+ availableMaterialNumbers: string[];
availableSlicerFilaments: string[];
availableSlicerFilamentNames: string[];
+ /** Spoolman-backed inventory: hides the fields Spoolman cannot store. */
+ spoolmanMode?: boolean;
onClose: () => void;
onApply: (patch: Partial>) => void;
}
@@ -210,10 +222,15 @@ function combineUnique(...lists: string[][]): string[] {
export function BulkEditSpoolsModal({
isOpen, selectedCount, isPending,
availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories,
- availableSlicerFilaments, availableSlicerFilamentNames,
+ availableMaterialNumbers, availableSlicerFilaments, availableSlicerFilamentNames,
+ spoolmanMode = false,
onClose, onApply,
}: BulkEditSpoolsModalProps) {
const { t } = useTranslation();
+ const fields = useMemo(
+ () => FIELDS.filter((f) => !(spoolmanMode && f.internalOnly)),
+ [spoolmanMode],
+ );
// Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca
// + local + built-in). Gated on `isOpen` so closed modal doesn't fetch.
@@ -280,6 +297,10 @@ export function BulkEditSpoolsModal({
() => combineUnique(availableCategories).map((m) => ({ value: m, label: m })),
[availableCategories],
);
+ const materialNumberOptions: Option[] = useMemo(
+ () => combineUnique(availableMaterialNumbers).map((m) => ({ value: m, label: m })),
+ [availableMaterialNumbers],
+ );
const slicerFilamentOptions: Option[] = useMemo(() => {
// value = preset code (what goes into spool.slicer_filament),
// label = display name so the user can find it by name.
@@ -317,7 +338,7 @@ export function BulkEditSpoolsModal({
const buildPatch = (): Record => {
const patch: Record = {};
- for (const f of FIELDS) {
+ for (const f of fields) {
const raw = values[f.id];
if (raw === undefined) continue;
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
@@ -345,7 +366,7 @@ export function BulkEditSpoolsModal({
// would be silently dropped from the patch — e.g. a malformed rgba hex.
// Without this guard the user clicks Apply, the field is dropped, and the
// success toast still fires for the OTHER fields.
- const hasDroppedTickedField = FIELDS.some((f) => {
+ const hasDroppedTickedField = fields.some((f) => {
const raw = values[f.id];
if (raw === undefined) return false;
if (raw.trim() === '') return false;
@@ -357,6 +378,7 @@ export function BulkEditSpoolsModal({
if (id === 'subtype') return subtypeOptions;
if (id === 'brand') return brandOptions;
if (id === 'category') return categoryOptions;
+ if (id === 'material_number') return materialNumberOptions;
if (id === 'slicer_filament') return slicerFilamentOptions;
if (id === 'slicer_filament_name') return slicerFilamentNameOptions;
if (id === 'location_id') return locationOptions;
@@ -469,7 +491,7 @@ export function BulkEditSpoolsModal({
{t('inventory.bulk.editHint')}
- {FIELDS.map((f) => {
+ {fields.map((f) => {
const enabled = values[f.id] !== undefined;
return (
diff --git a/frontend/src/components/ForecastPanel.tsx b/frontend/src/components/ForecastPanel.tsx
index 3bb3a0f7e..dbc4d5c57 100644
--- a/frontend/src/components/ForecastPanel.tsx
+++ b/frontend/src/components/ForecastPanel.tsx
@@ -1281,6 +1281,7 @@ function ShoppingListPanel({
added_full: null, last_used: null, encode_time: null,
category: 'Stock',
low_stock_threshold_pct: null,
+ material_number: null,
};
await api.bulkCreateSpools(spoolBase, item.quantity_spools);
await api.removeFromShoppingList(id);
diff --git a/frontend/src/components/MaterialNumberStats.tsx b/frontend/src/components/MaterialNumberStats.tsx
new file mode 100644
index 000000000..24bd05f51
--- /dev/null
+++ b/frontend/src/components/MaterialNumberStats.tsx
@@ -0,0 +1,79 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+
+// Consumption, cost and stock grouped by the internal material number
+// (#2870) — the identifier the business actually purchases and costs by,
+// unlike brand+material+colour. Data comes from the dedicated aggregate
+// endpoint so archived spools' recorded usage still counts.
+
+interface MaterialNumberStatsProps {
+ currency: string;
+ // Dashboard timeframe. Narrows the consumption/cost columns only — the
+ // spool count and remaining weight are point-in-time stock.
+ dateFrom?: string;
+ dateTo?: string;
+}
+
+function formatGrams(g: number): string {
+ if (Math.abs(g) >= 1000) return `${(g / 1000).toFixed(2)} kg`;
+ return `${Math.round(g)} g`;
+}
+
+export function MaterialNumberStats({ currency, dateFrom, dateTo }: MaterialNumberStatsProps) {
+ const { t } = useTranslation();
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['material-number-stats', dateFrom ?? null, dateTo ?? null],
+ queryFn: () => api.getMaterialNumberStats({ dateFrom, dateTo }),
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // A failed request is not an empty inventory: telling someone who has
+ // numbered their spools to go and number them (because of a 403 from a
+ // missing INVENTORY_READ, or a dropped connection) sends them looking for
+ // a problem that isn't there.
+ if (isError) {
+ return
{t('stats.materialNumbers.loadFailed')}
;
+ }
+
+ if (!data || data.length === 0) {
+ return
{t('stats.materialNumbers.empty')}
;
+ }
+
+ return (
+
+
+
+
+ {t('inventory.materialNumber')}
+ {t('stats.materialNumbers.spools')}
+ {t('stats.materialNumbers.remaining')}
+ {t('stats.materialNumbers.consumed')}
+ {t('stats.materialNumbers.cost')}
+
+
+
+ {data.map((row) => (
+
+ {row.material_number}
+ {row.spool_count}
+ {formatGrams(row.remaining_g)}
+ {formatGrams(row.consumed_g)}
+
+ {currency} {row.cost.toFixed(2)}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx
index f401f4ee6..266ba7e90 100644
--- a/frontend/src/components/SpoolFormModal.tsx
+++ b/frontend/src/components/SpoolFormModal.tsx
@@ -428,6 +428,7 @@ export function SpoolFormModal({
cost_per_kg: spool.cost_per_kg ?? null,
category: spool.category || '',
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
+ material_number: spool.material_number || '',
location_id: spool.location_id ?? null,
spoolman_filament_id: null,
});
@@ -786,6 +787,16 @@ export function SpoolFormModal({
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
})();
+ // Autocomplete for the internal material number (#2870), mirroring the
+ // category datalist above.
+ const availableMaterialNumbers = (() => {
+ const set = new Set
();
+ for (const s of allSpools ?? []) {
+ const n = s.material_number?.trim();
+ if (n) set.add(n);
+ }
+ return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+ })();
const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
const unassignMutation = useMutation({
@@ -968,6 +979,7 @@ export function SpoolFormModal({
cost_per_kg: formData.cost_per_kg,
category: formData.category.trim() || null,
low_stock_threshold_pct: formData.low_stock_threshold_pct,
+ material_number: formData.material_number.trim() || null,
...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
};
@@ -1174,6 +1186,7 @@ export function SpoolFormModal({
spoolCatalog={spoolCatalog}
currencySymbol={currencySymbol}
availableCategories={availableCategories}
+ availableMaterialNumbers={availableMaterialNumbers}
availableLocations={storageLocations}
onCreateLocation={async (name) => {
try {
@@ -1192,6 +1205,7 @@ export function SpoolFormModal({
}
}}
globalLowStockThreshold={globalLowStockThreshold}
+ spoolmanMode={spoolmanMode}
/>
diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx
index c171ea343..490d612b9 100644
--- a/frontend/src/components/spool-form/AdditionalSection.tsx
+++ b/frontend/src/components/spool-form/AdditionalSection.tsx
@@ -174,9 +174,11 @@ export function AdditionalSection({
spoolCatalog,
currencySymbol,
availableCategories,
+ availableMaterialNumbers,
availableLocations = [],
onCreateLocation,
globalLowStockThreshold,
+ spoolmanMode = false,
}: AdditionalSectionProps) {
const { t } = useTranslation();
const { showToast } = useToast();
@@ -318,6 +320,33 @@ export function AdditionalSection({
+ {/* Material number (#2870). Hidden in Spoolman mode: there the number
+ is Spoolman's filament-level article_number, maintained in Spoolman
+ itself and surfaced read-only in the list. */}
+ {!spoolmanMode && (
+
+
+ {t('inventory.materialNumber')}
+
+
updateField('material_number', e.target.value)}
+ />
+ {availableMaterialNumbers.length > 0 && (
+
+ {availableMaterialNumbers.map((n) => )}
+
+ )}
+
{t('inventory.materialNumberHelp')}
+
+ )}
+
{/* Category (#729) */}
diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts
index d30388a14..6b1bf10bf 100644
--- a/frontend/src/components/spool-form/types.ts
+++ b/frontend/src/components/spool-form/types.ts
@@ -41,6 +41,9 @@ export interface SpoolFormData {
// User-defined category + per-spool low-stock threshold override (#729).
category: string;
low_stock_threshold_pct: number | null;
+ // Internal material / article number (#2870) — the purchasing identifier
+ // shared by all spools of the same product. Free text.
+ material_number: string;
location_id: number | null;
// When set the spool is linked to a specific Spoolman filament catalog entry;
// the backend skips find_or_create_filament() and uses this ID directly.
@@ -64,6 +67,7 @@ export const defaultFormData: SpoolFormData = {
cost_per_kg: null,
category: '',
low_stock_threshold_pct: null,
+ material_number: '',
location_id: null,
spoolman_filament_id: null,
};
@@ -207,11 +211,18 @@ export interface AdditionalSectionProps extends SectionProps {
// datalist so users naturally re-use existing names instead of creating
// near-duplicates ("Production" vs "production" vs "prod"). #729
availableCategories: string[];
+ // Material numbers already used on other spools — same autocomplete idea
+ // as availableCategories, for the internal article number (#2870).
+ availableMaterialNumbers: string[];
// Global low-stock threshold (%); shown as placeholder on the per-spool
// override input so users see what they're overriding. #729
globalLowStockThreshold: number;
availableLocations?: { id: number; name: string }[];
onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
+ // When true the material number input is hidden: in Spoolman mode the
+ // number is Spoolman's filament-level article_number, maintained in
+ // Spoolman itself and shown read-only in the list (#2870).
+ spoolmanMode?: boolean;
}
// PA Profile section props
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts
index d435502d9..570aa81a6 100644
--- a/frontend/src/hooks/useWebSocket.ts
+++ b/frontend/src/hooks/useWebSocket.ts
@@ -409,6 +409,8 @@ export function useWebSocket() {
debouncedInvalidate('spoolman-inventory-spools');
debouncedInvalidate(inventoryLocationsQueryKey[0]);
debouncedInvalidate(inventorySuppliersQueryKey[0]);
+ // The per-material-number aggregate is derived from the same rows (#2870).
+ debouncedInvalidate('material-number-stats');
break;
case 'spool_assignment_changed':
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index e2ea5e14b..9d4ef4862 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: 'Druckaktivität',
filamentTypes: 'Filamenttypen',
filamentTrends: 'Filamenttrends',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Nach Materialnummer',
+ empty: 'Noch keine Materialnummern vergeben. Weise sie Spulen im Bestand zu, um Verbrauch und Kosten hier zu gruppieren.',
+ loadFailed: 'Die Materialnummern-Statistik konnte nicht geladen werden.',
+ spools: 'Spulen',
+ remaining: 'Verbleibend',
+ consumed: 'Verbraucht',
+ cost: 'Kosten',
+ },
// Consumption/cost grouped by purchase-source supplier (#2988)
suppliers: {
title: 'Nach Lieferant',
@@ -4829,6 +4839,11 @@ export default {
storageLocationNone: 'Kein Lagerort',
lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
+ // Internal material / article number (#2870)
+ materialNumber: 'Material-Nr.',
+ materialNumberPlaceholder: 'z. B. 15',
+ materialNumberHelp: 'Interne Einkaufsnummer - wird von allen Spulen dieses Produkts geteilt. Neue Spulen desselben Produkts übernehmen sie.',
+ materialNumberNone: 'Keine Materialnummer',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Lieferanten',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index e2f0cdfb8..fe7f96ce5 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1688,6 +1688,16 @@ export default {
printActivity: 'Print Activity',
filamentTypes: 'Filament Types',
filamentTrends: 'Filament Trends',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'By Material Number',
+ empty: 'No material numbers assigned yet. Add them to spools in the inventory to group consumption and costs here.',
+ loadFailed: 'Could not load the material number statistics.',
+ spools: 'Spools',
+ remaining: 'Remaining',
+ consumed: 'Consumed',
+ cost: 'Cost',
+ },
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'By Supplier',
@@ -4869,6 +4879,11 @@ export default {
storageLocationNone: 'No location set',
lowStockThresholdOverride: 'Low-stock threshold (this spool)',
lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
+ // Internal material / article number (#2870)
+ materialNumber: 'Material No.',
+ materialNumberPlaceholder: 'e.g. 15',
+ materialNumberHelp: 'Internal purchasing number — shared by all spools of this product. New spools of the same product inherit it.',
+ materialNumberNone: 'No material number',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Suppliers',
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 5c655ccef..a5b30690d 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: 'Actividad de impresión',
filamentTypes: 'Tipos de filamento',
filamentTrends: 'Tendencias del filamento',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Por número de material',
+ empty: 'Aún no hay números de material asignados. Añádelos a las bobinas en el inventario para agrupar aquí el consumo y los costes.',
+ loadFailed: 'No se han podido cargar las estadísticas por número de material.',
+ spools: 'Bobinas',
+ remaining: 'Restante',
+ consumed: 'Consumido',
+ cost: 'Coste',
+ },
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'Por proveedor',
@@ -4832,6 +4842,11 @@ export default {
storageLocationNone: 'Sin ubicación establecida',
lowStockThresholdOverride: 'Umbral de existencias bajas (esta bobina)',
lowStockThresholdOverrideHelp: 'Déjelo en blanco para usar el umbral global ({{global}}%).',
+ // Internal material / article number (#2870)
+ materialNumber: 'N.º de material',
+ materialNumberPlaceholder: 'p. ej. 15',
+ materialNumberHelp: 'Número interno de compras: compartido por todas las bobinas de este producto. Las bobinas nuevas del mismo producto lo heredan.',
+ materialNumberNone: 'Sin número de material',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Proveedores',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index 828e4b9dc..6d4b68e99 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: 'Activité d\'impression',
filamentTypes: 'Types de filament',
filamentTrends: 'Tendances filament',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Par numéro de matière',
+ empty: 'Aucun numéro de matière attribué pour l\'instant. Ajoutez-en aux bobines dans l\'inventaire pour regrouper ici la consommation et les coûts.',
+ loadFailed: 'Impossible de charger les statistiques par numéro de matière.',
+ spools: 'Bobines',
+ remaining: 'Restant',
+ consumed: 'Consommé',
+ cost: 'Coût',
+ },
suppliers: {
title: 'Par fournisseur',
empty: 'Aucun achat enregistré pour le moment. Indiquez sur une bobine auprès de quel fournisseur elle a été achetée pour regrouper ici la consommation et les coûts.',
@@ -4817,6 +4827,11 @@ export default {
storageLocationNone: 'Aucun emplacement défini',
lowStockThresholdOverride: 'Seuil bas (cette bobine)',
lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
+ // Internal material / article number (#2870)
+ materialNumber: 'N° matière',
+ materialNumberPlaceholder: 'ex. 15',
+ materialNumberHelp: 'Numéro d\'achat interne - partagé par toutes les bobines de ce produit. Les nouvelles bobines du même produit en héritent.',
+ materialNumberNone: 'Aucun numéro de matière',
suppliers: {
label: 'Fournisseurs',
none: 'Aucun fournisseur',
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index e943ff299..0b6ae9d84 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: 'Attivita di stampa',
filamentTypes: 'Tipi di filamento',
filamentTrends: 'Trend filamento',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Per numero materiale',
+ empty: 'Nessun numero materiale assegnato. Aggiungili alle bobine nell\'inventario per raggruppare qui consumi e costi.',
+ loadFailed: 'Impossibile caricare le statistiche per numero materiale.',
+ spools: 'Bobine',
+ remaining: 'Rimanente',
+ consumed: 'Consumato',
+ cost: 'Costo',
+ },
suppliers: {
title: 'Per fornitore',
empty: 'Nessun acquisto registrato. Indica su una bobina da quale fornitore è stata acquistata per raggruppare qui consumi e costi.',
@@ -4816,6 +4826,11 @@ export default {
storageLocationNone: 'Nessuna posizione impostata',
lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
+ // Internal material / article number (#2870)
+ materialNumber: 'N. materiale',
+ materialNumberPlaceholder: 'es. 15',
+ materialNumberHelp: 'Numero interno di acquisto - condiviso da tutte le bobine di questo prodotto. Le nuove bobine dello stesso prodotto lo ereditano.',
+ materialNumberNone: 'Nessun numero materiale',
suppliers: {
label: 'Fornitori',
none: 'Nessun fornitore',
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index 13f44cfbb..656ede2a2 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -1670,6 +1670,16 @@ export default {
printActivity: '印刷アクティビティ',
filamentTypes: 'フィラメントタイプ',
filamentTrends: 'フィラメントトレンド',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: '資材番号別',
+ empty: 'まだ資材番号が割り当てられていません。在庫のスプールに資材番号を追加すると、ここで消費量とコストをまとめて確認できます。',
+ loadFailed: '資材番号の統計を読み込めませんでした。',
+ spools: 'スプール',
+ remaining: '残量',
+ consumed: '消費量',
+ cost: 'コスト',
+ },
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'サプライヤー別',
@@ -4829,6 +4839,11 @@ export default {
storageLocationNone: '保管場所未設定',
lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
+ // Internal material / article number (#2870)
+ materialNumber: '資材番号',
+ materialNumberPlaceholder: '例:15',
+ materialNumberHelp: '社内の購買番号です。同じ製品のすべてのスプールで共有され、同じ製品の新しいスプールに自動的に引き継がれます。',
+ materialNumberNone: '資材番号なし',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'サプライヤー',
diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts
index 0b4c8ffee..12c2615dd 100644
--- a/frontend/src/i18n/locales/ko.ts
+++ b/frontend/src/i18n/locales/ko.ts
@@ -1604,6 +1604,16 @@ export default {
printActivity: '인쇄 활동',
filamentTypes: '필라멘트 종류',
filamentTrends: '필라멘트 추세',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: '자재 번호별',
+ empty: '아직 지정된 자재 번호가 없습니다. 인벤토리에서 스풀에 자재 번호를 추가하면 여기에서 소비량과 비용을 그룹화할 수 있습니다.',
+ loadFailed: '자재 번호 통계를 불러오지 못했습니다.',
+ spools: '스풀',
+ remaining: '남은 양',
+ consumed: '소비량',
+ cost: '비용',
+ },
suppliers: {
title: '공급업체별',
empty: '아직 기록된 구매가 없습니다. 스풀에 구매한 공급업체를 표시하면 여기에서 소비량과 비용이 그룹화됩니다.',
@@ -4613,6 +4623,11 @@ export default {
categoryNone: '미분류',
lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)',
lowStockThresholdOverrideHelp: '전역 임계값({{global}}%)을 사용하려면 비워두세요.',
+ // Internal material / article number (#2870)
+ materialNumber: '자재 번호',
+ materialNumberPlaceholder: '예: 15',
+ materialNumberHelp: '내부 구매 번호 - 이 제품의 모든 스풀이 공유합니다. 같은 제품의 새 스풀은 이 번호를 이어받습니다.',
+ materialNumberNone: '자재 번호 없음',
suppliers: {
label: '공급업체',
none: '공급업체 없음',
diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts
index 03e4d468e..402ccefca 100644
--- a/frontend/src/i18n/locales/nl.ts
+++ b/frontend/src/i18n/locales/nl.ts
@@ -1688,6 +1688,16 @@ export default {
printActivity: 'Afdrukactiviteit',
filamentTypes: 'Filamenttypen',
filamentTrends: 'Filamenttrends',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Per materiaalnummer',
+ empty: 'Nog geen materiaalnummers toegewezen. Voeg ze toe aan spoelen in de voorraad om verbruik en kosten hier te groeperen.',
+ loadFailed: 'De statistieken per materiaalnummer konden niet worden geladen.',
+ spools: 'Spoelen',
+ remaining: 'Resterend',
+ consumed: 'Verbruikt',
+ cost: 'Kosten',
+ },
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'Per leverancier',
@@ -4869,6 +4879,11 @@ export default {
storageLocationNone: 'Geen locatie ingesteld',
lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)',
lowStockThresholdOverrideHelp: 'Laat leeg om de globale drempel ({{global}}%) te gebruiken.',
+ // Internal material / article number (#2870)
+ materialNumber: 'Materiaalnr.',
+ materialNumberPlaceholder: 'bijv. 15',
+ materialNumberHelp: 'Intern inkoopnummer - gedeeld door alle spoelen van dit product. Nieuwe spoelen van hetzelfde product nemen het over.',
+ materialNumberNone: 'Geen materiaalnummer',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Leveranciers',
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index 9fec9b3f2..e52c86093 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: 'Atividade de Impressão',
filamentTypes: 'Tipos de Filamento',
filamentTrends: 'Tendências de Filamento',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Por Número de Material',
+ empty: 'Nenhum número de material atribuído ainda. Adicione-os aos carretéis no inventário para agrupar consumo e custos aqui.',
+ loadFailed: 'Não foi possível carregar as estatísticas por número de material.',
+ spools: 'Carretéis',
+ remaining: 'Restante',
+ consumed: 'Consumido',
+ cost: 'Custo',
+ },
suppliers: {
title: 'Por Fornecedor',
empty: 'Nenhuma compra registrada ainda. Marque em um carretel de qual fornecedor ele foi comprado para agrupar consumo e custos aqui.',
@@ -4816,6 +4826,11 @@ export default {
storageLocationNone: 'Sem local definido',
lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
+ // Internal material / article number (#2870)
+ materialNumber: 'Nº do Material',
+ materialNumberPlaceholder: 'ex. 15',
+ materialNumberHelp: 'Número interno de compra - compartilhado por todos os carretéis deste produto. Novos carretéis do mesmo produto o herdam.',
+ materialNumberNone: 'Sem número de material',
suppliers: {
label: 'Fornecedores',
none: 'Sem fornecedor',
diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts
index 1c0d1b8c8..2f19d5b9d 100644
--- a/frontend/src/i18n/locales/ru.ts
+++ b/frontend/src/i18n/locales/ru.ts
@@ -1601,6 +1601,16 @@ export default {
printActivity: "Активность печати",
filamentTypes: "Типы филамента",
filamentTrends: "Расход филамента",
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'По артикулам',
+ empty: 'Артикулы пока не назначены. Добавьте их катушкам в инвентаре, чтобы группировать здесь расход и затраты.',
+ loadFailed: 'Не удалось загрузить статистику по артикулам.',
+ spools: 'Катушки',
+ remaining: 'Остаток',
+ consumed: 'Израсходовано',
+ cost: 'Стоимость',
+ },
suppliers: {
title: "По поставщикам",
empty: "Покупки пока не зарегистрированы. Отметьте на катушке, у какого поставщика она была куплена, чтобы сгруппировать здесь расход и затраты.",
@@ -4604,6 +4614,11 @@ export default {
storageLocationNone: "Место не указано",
lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).",
+ // Internal material / article number (#2870)
+ materialNumber: 'Артикул',
+ materialNumberPlaceholder: 'напр. 15',
+ materialNumberHelp: 'Внутренний закупочный номер — общий для всех катушек этого товара. Новые катушки того же товара наследуют его.',
+ materialNumberNone: 'Без артикула',
suppliers: {
label: "Поставщики",
none: "Без поставщика",
diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts
index 1e116a59a..cb5a7c20f 100644
--- a/frontend/src/i18n/locales/sv.ts
+++ b/frontend/src/i18n/locales/sv.ts
@@ -1688,6 +1688,16 @@ export default {
printActivity: 'Utskriftsaktivitet',
filamentTypes: 'Filamenttyper',
filamentTrends: 'Filamenttrender',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Efter materialnummer',
+ empty: 'Inga materialnummer tilldelade än. Lägg till dem på rullar i inventariet för att gruppera förbrukning och kostnader här.',
+ loadFailed: 'Kunde inte ladda materialnummerstatistiken.',
+ spools: 'Rullar',
+ remaining: 'Återstående',
+ consumed: 'Förbrukat',
+ cost: 'Kostnad',
+ },
suppliers: {
title: 'Per leverantör',
empty: 'Inga inköp registrerade ännu. Ange på en rulle vilken leverantör den köptes från för att gruppera förbrukning och kostnader här.',
@@ -4868,6 +4878,11 @@ errors: {
storageLocationNone: 'Ingen lagringsplats inställd',
lowStockThresholdOverride: 'Lågt lagertröskelvärde (denna rulle)',
lowStockThresholdOverrideHelp: 'Lämna tomt för att använda det globala tröskelvärdet ({{global}}%).',
+ // Internal material / article number (#2870)
+ materialNumber: 'Materialnr',
+ materialNumberPlaceholder: 't.ex. 15',
+ materialNumberHelp: 'Internt inköpsnummer — delas av alla rullar av denna produkt. Nya rullar av samma produkt ärver det.',
+ materialNumberNone: 'Inget materialnummer',
suppliers: {
label: 'Leverantörer',
none: 'Ingen leverantör',
diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts
index 2816f34e1..6d612b76d 100644
--- a/frontend/src/i18n/locales/tr.ts
+++ b/frontend/src/i18n/locales/tr.ts
@@ -1672,6 +1672,16 @@ export default {
printActivity: 'Baskı Etkinliği',
filamentTypes: 'Filament Türleri',
filamentTrends: 'Filament Trendleri',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'Malzeme Numarasına Göre',
+ empty: 'Henüz malzeme numarası atanmadı. Tüketim ve maliyetleri burada gruplamak için envanterdeki makaralara malzeme numarası ekleyin.',
+ loadFailed: 'Malzeme numarası istatistikleri yüklenemedi.',
+ spools: 'Makaralar',
+ remaining: 'Kalan',
+ consumed: 'Tüketilen',
+ cost: 'Maliyet',
+ },
suppliers: {
title: 'Tedarikçiye Göre',
empty: 'Henüz satın alma kaydı yok. Tüketim ve maliyetleri burada gruplamak için bir makarada hangi tedarikçiden alındığını işaretleyin.',
@@ -4816,6 +4826,11 @@ export default {
storageLocationNone: 'Konum ayarlanmamış',
lowStockThresholdOverride: 'Düşük stok eşiği (bu makara)',
lowStockThresholdOverrideHelp: 'Global eşiği kullanmak için boş bırakın (%{{global}}).',
+ // Internal material / article number (#2870)
+ materialNumber: 'Malzeme No.',
+ materialNumberPlaceholder: 'örn. 15',
+ materialNumberHelp: 'Dahili satın alma numarası - bu ürünün tüm makaraları tarafından paylaşılır. Aynı ürünün yeni makaraları bu numarayı devralır.',
+ materialNumberNone: 'Malzeme numarası yok',
suppliers: {
label: 'Tedarikçiler',
none: 'Tedarikçi yok',
diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts
index f5f4cd335..3dc84ef07 100644
--- a/frontend/src/i18n/locales/uk.ts
+++ b/frontend/src/i18n/locales/uk.ts
@@ -1687,6 +1687,16 @@ export default {
printActivity: "Активність друку",
filamentTypes: "Типи філаментів",
filamentTrends: "Тенденції філаменту",
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: 'За номером матеріалу',
+ empty: 'Номери матеріалів ще не призначено. Додайте їх до котушок в інвентарі, щоб групувати тут споживання та витрати.',
+ loadFailed: 'Не вдалося завантажити статистику за номерами матеріалів.',
+ spools: 'Котушки',
+ remaining: 'Залишок',
+ consumed: 'Спожито',
+ cost: 'Вартість',
+ },
suppliers: {
title: "За постачальником",
empty: "Покупок ще не зафіксовано. Позначте на котушці, у якого постачальника її придбано, щоб згрупувати тут споживання та витрати.",
@@ -4865,6 +4875,11 @@ export default {
storageLocationNone: "Місцезнаходження не встановлено",
lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).",
+ // Internal material / article number (#2870)
+ materialNumber: 'Мат. №',
+ materialNumberPlaceholder: 'напр. 15',
+ materialNumberHelp: 'Внутрішній закупівельний номер — спільний для всіх котушок цього продукту. Нові котушки того самого продукту успадковують його.',
+ materialNumberNone: 'Без номера матеріалу',
suppliers: {
label: "Постачальники",
none: "Без постачальника",
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 84298a8ca..51255ffb3 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: '打印活动',
filamentTypes: '耗材类型',
filamentTrends: '耗材趋势',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: '按物料号',
+ empty: '尚未分配物料号。在库存中为料盘添加物料号,即可在此按其汇总消耗量和成本。',
+ loadFailed: '无法加载物料号统计数据。',
+ spools: '料盘',
+ remaining: '剩余',
+ consumed: '已消耗',
+ cost: '成本',
+ },
suppliers: {
title: '按供应商',
empty: '暂无购买记录。在料盘上标记其购买来源供应商后,即可在此按供应商统计消耗和成本。',
@@ -4816,6 +4826,11 @@ export default {
storageLocationNone: '未设置位置',
lowStockThresholdOverride: '低库存阈值(此料盘)',
lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
+ // Internal material / article number (#2870)
+ materialNumber: '物料号',
+ materialNumberPlaceholder: '例如 15',
+ materialNumberHelp: '内部采购编号 — 同一产品的所有料盘共用。同一产品的新料盘会自动继承。',
+ materialNumberNone: '无物料号',
suppliers: {
label: '供应商',
none: '无供应商',
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 39a775a14..389671ba6 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -1671,6 +1671,16 @@ export default {
printActivity: '列印活動',
filamentTypes: '耗材類型',
filamentTrends: '耗材趨勢',
+ // Consumption/cost grouped by the internal material number (#2870).
+ materialNumbers: {
+ title: '依料號統計',
+ empty: '尚未指定任何料號。請在庫存中為料盤新增料號,即可在此依料號統整消耗量與成本。',
+ loadFailed: '無法載入料號統計資料。',
+ spools: '料盤',
+ remaining: '剩餘',
+ consumed: '已消耗',
+ cost: '成本',
+ },
suppliers: {
title: '依供應商',
empty: '尚未記錄任何購買。在料盤上標記其購買來源的供應商,即可在此依供應商彙整消耗量與成本。',
@@ -4816,6 +4826,11 @@ export default {
storageLocationNone: '未設定位置',
lowStockThresholdOverride: '低庫存閾值(此料盤)',
lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
+ // Internal material / article number (#2870)
+ materialNumber: '料號',
+ materialNumberPlaceholder: '例如:15',
+ materialNumberHelp: '內部採購編號 — 同一產品的所有料盤共用。同一產品的新料盤會自動繼承。',
+ materialNumberNone: '無料號',
suppliers: {
label: '供應商',
none: '無供應商',
diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx
index 83fb3635b..db75d0346 100644
--- a/frontend/src/pages/InventoryPage.tsx
+++ b/frontend/src/pages/InventoryPage.tsx
@@ -75,6 +75,20 @@ function spoolGroupKey(s: InventorySpool): string {
// Column definitions for the inventory table
const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
+// Sentinel for the "no material number" slot in the filter dropdown (#2870).
+// The other chips use a readable '__none__', but a material number is free
+// text and could literally be '__none__'. SpoolBase caps the column at 64
+// characters, so a 65-character sentinel is one no spool can ever carry.
+const MATERIAL_NUMBER_NONE = 'none'.padStart(65, '_');
+
+// Sort key for the material-number column (#2870). The table compares sort
+// values with plain < / >, which puts "15" before "2"; padding every digit run
+// to the column's 64-character cap makes that comparison numeric-aware, so the
+// column orders the same way as the filter chip and the dialog suggestions.
+function materialNumberSortKey(value: string | null): string {
+ return (value || '').toLowerCase().replace(/\d+/g, (digits) => digits.padStart(64, '0'));
+}
+
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'id', label: '#', visible: true },
{ id: 'added_time', label: 'Added', visible: true },
@@ -99,6 +113,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'printed_total', label: 'Printed Total', visible: false },
{ id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
{ id: 'note', label: 'Note', visible: false },
+ { id: 'material_number', label: 'Material No.', visible: false },
{ id: 'suppliers', label: 'Suppliers', visible: false },
{ id: 'pa_k', label: 'PA(K)', visible: true },
{ id: 'tag_id', label: 'Tag ID', visible: false },
@@ -229,6 +244,7 @@ const columnHeaders: Record string> = {
printed_total: () => 'Printed Total',
printed_since_weight: () => 'Printed Since Weight',
note: (t) => t('inventory.note'),
+ material_number: (t) => t('inventory.materialNumber'),
suppliers: (t) => t('inventory.suppliers.label'),
pa_k: () => 'PA(K)',
tag_id: () => 'Tag ID',
@@ -370,6 +386,9 @@ const columnCells: Record ReactNode> = {
note: ({ spool }) => (
{spool.note || '-'}
),
+ material_number: ({ spool }) => (
+ {spool.material_number || '-'}
+ ),
// Supplier chips (#2988): purchase source first and highlighted; the
// others read as alternative sources. Tooltip carries the supplier's
// article number when set.
@@ -541,6 +560,7 @@ const columnSortValues: Record<
used: (s) => s.weight_used,
remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
note: (s) => (s.note || '').toLowerCase(),
+ material_number: (s) => materialNumberSortKey(s.material_number),
// Sorts on the purchase-source supplier, falling back to the first
// assignment — a spool has to sit in exactly one place in the list.
suppliers: (s) => {
@@ -638,6 +658,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const [materialFilter, setMaterialFilter] = useState('');
const [brandFilter, setBrandFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
+ // Filter on the internal material number (#2870), same shape as category.
+ const [materialNumberFilter, setMaterialNumberFilter] = useState('');
// Filter on an assigned supplier (#2988), same shape as category.
const [supplierFilter, setSupplierFilter] = useState('');
const [spoolFilter, setSpoolFilter] = useState('');
@@ -674,7 +696,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
// honest vs. what the user is actually looking at.
useEffect(() => {
setSelectedIds(new Set());
- }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, search]);
+ }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, materialNumberFilter, supplierFilter, spoolFilter, stockFilter, search]);
// Pagination state (pageSize persisted to localStorage)
const [pageIndex, setPageIndex] = useState(0);
@@ -1312,6 +1334,16 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}
}
+ // Material number dropdown (#2870). The sentinel finds spools that have
+ // no number assigned yet.
+ if (materialNumberFilter) {
+ if (materialNumberFilter === MATERIAL_NUMBER_NONE) {
+ filtered = filtered.filter((s) => !s.material_number?.trim());
+ } else {
+ filtered = filtered.filter((s) => s.material_number === materialNumberFilter);
+ }
+ }
+
// Supplier dropdown (#2988): "everything from supplier X" matches ANY
// assignment, purchase source or alternative; `__none__` finds spools
// without supplier assignments.
@@ -1359,7 +1391,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}
return filtered;
- }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
+ }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, materialNumberFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
// Reset page on filter changes
const resetPage = () => setPageIndex(0);
@@ -1380,6 +1412,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort();
const hasUncategorized = (spools ?? []).some((s) => !s.category);
+ // #2870: distinct material numbers, numeric-aware sort ("2" before "15").
+ const uniqueMaterialNumbers = [...new Set(spools?.map((s) => s.material_number?.trim()).filter(Boolean) as string[] || [])]
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+ const hasUnnumbered = (spools ?? []).some((s) => !s.material_number?.trim());
// #2988: suppliers seen across the inventory, for the filter dropdown.
const uniqueSuppliers = useMemo(() => {
const byId = new Map();
@@ -1401,7 +1437,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.location_id && !s.storage_location?.trim());
// Check if any filters are non-default
- const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
+ const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!materialNumberFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
const handleColumnConfigSave = (config: ColumnConfig[]) => {
setColumnConfig(config);
@@ -1523,6 +1559,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
setMaterialFilter('');
setBrandFilter('');
setCategoryFilter('');
+ setMaterialNumberFilter('');
setSupplierFilter('');
setSpoolFilter('');
setStockFilter('all');
@@ -1988,6 +2025,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
)}
+ {/* Material number dropdown chip (#2870) — same render rule as the
+ category chip: hidden until at least one spool carries a number. */}
+ {(uniqueMaterialNumbers.length > 0 || materialNumberFilter) && (
+ { setMaterialNumberFilter(e.target.value); resetPage(); }}
+ className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+ materialNumberFilter
+ ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
+ : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
+ }`}
+ >
+ {t('inventory.materialNumber')}
+ {uniqueMaterialNumbers.map((n) => (
+ {n}
+ ))}
+ {hasUnnumbered && (
+ {t('inventory.materialNumberNone')}
+ )}
+
+ )}
+
{/* Supplier dropdown chip (#2988) — same render rule as the category
chip: hidden until at least one spool carries an assignment. */}
{(uniqueSuppliers.length > 0 || supplierFilter) && (
@@ -2550,8 +2609,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
availableSubtypes={dedupeAndSort((spools ?? []).map((s) => s.subtype))}
availableBrands={dedupeAndSort((spools ?? []).map((s) => s.brand))}
availableCategories={dedupeAndSort((spools ?? []).map((s) => s.category))}
+ availableMaterialNumbers={dedupeAndSort((spools ?? []).map((s) => s.material_number))}
availableSlicerFilaments={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament))}
availableSlicerFilamentNames={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament_name))}
+ spoolmanMode={spoolmanMode}
onClose={() => setBulkEditOpen(false)}
onApply={(patch) => bulkUpdateMutation.mutate({ ids: [...selectedIds], update: patch })}
/>
diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx
index 5de629205..8b4e709d1 100644
--- a/frontend/src/pages/StatsPage.tsx
+++ b/frontend/src/pages/StatsPage.tsx
@@ -39,6 +39,7 @@ import { api, type ArchiveSlim } from '../api/client';
import { PrintCalendar } from '../components/PrintCalendar';
import { FilamentTrends } from '../components/FilamentTrends';
import { SupplierStats } from '../components/SupplierStats';
+import { MaterialNumberStats } from '../components/MaterialNumberStats';
import { Dashboard, type DashboardWidget } from '../components/Dashboard';
import { getCurrencySymbol } from '../utils/currency';
import { formatWeight } from '../utils/weight';
@@ -1071,6 +1072,9 @@ export function StatsPage() {
// in Spoolman mode — there the assignments live in the Spoolman twin table.
// Rather than show a permanently empty card next to an inventory that does
// display supplier chips, drop it (#2988).
+ // The material-number widget shares this gate for the same reason: in
+ // Spoolman mode the number is Spoolman's filament-level article_number and
+ // lives in Spoolman itself, not in the internal spool table (#2870).
const { data: spoolmanSettings, isPending: spoolmanSettingsPending } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: api.getSpoolmanSettings,
@@ -1213,6 +1217,12 @@ export function StatsPage() {
component: ,
defaultSize: 2,
}] as DashboardWidget[])),
+ ...(!spoolmanModeReady || spoolmanMode ? [] : ([{
+ id: 'material-numbers',
+ title: t('stats.materialNumbers.title'),
+ component: ,
+ defaultSize: 2,
+ }] as DashboardWidget[])),
];
return (
diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
index 37bf1fe5a..b6bf608fd 100644
--- a/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
+++ b/frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
@@ -480,6 +480,7 @@ export function SpoolBuddyDashboard() {
last_weighed_at: weight !== null ? new Date().toISOString() : null,
category: null,
low_stock_threshold_pct: null,
+ material_number: null,
});
}
} catch (e) {
diff --git a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
index 3baa59319..e6d0eb604 100644
--- a/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
+++ b/frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
@@ -780,6 +780,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
last_weighed_at: null,
category: formData.category.trim() || null,
low_stock_threshold_pct: formData.low_stock_threshold_pct,
+ material_number: formData.material_number.trim() || null,
};
setCreating(true);
@@ -1001,7 +1002,11 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
availableCategories={Array.from(new Set(
allSpoolsForForm.map((s) => s.category?.trim()).filter((c): c is string => !!c),
)).sort((a, b) => a.localeCompare(b))}
+ availableMaterialNumbers={Array.from(new Set(
+ allSpoolsForForm.map((s) => s.material_number?.trim()).filter((n): n is string => !!n),
+ )).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))}
globalLowStockThreshold={settingsForForm?.low_stock_threshold ?? 20}
+ spoolmanMode={spoolmanMode}
/>
) : (
diff --git a/frontend/src/utils/inventorySearch.ts b/frontend/src/utils/inventorySearch.ts
index a797bac1c..310ad39a2 100644
--- a/frontend/src/utils/inventorySearch.ts
+++ b/frontend/src/utils/inventorySearch.ts
@@ -35,6 +35,7 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean
(spool.note?.toLowerCase().includes(q) ?? false) ||
(spool.slicer_filament_name?.toLowerCase().includes(q) ?? false) ||
(spool.storage_location?.toLowerCase().includes(q) ?? false) ||
+ (spool.material_number?.toLowerCase().includes(q) ?? false) ||
(spool.suppliers?.some(
(link) =>
link.supplier_name.toLowerCase().includes(q) ||