Ver código fonte

Material number as a first-class spool field (#2994)

Thomansky 2 dias atrás
pai
commit
71d4b2f70d
51 arquivos alterados com 2123 adições e 13 exclusões
  1. 10 0
      backend/app/api/routes/_spoolman_helpers.py
  2. 91 0
      backend/app/api/routes/inventory.py
  3. 6 0
      backend/app/core/database.py
  4. 9 0
      backend/app/models/spool.py
  5. 50 0
      backend/app/schemas/spool.py
  6. 10 0
      backend/app/services/github_backup.py
  7. 11 0
      backend/app/services/github_restore.py
  8. 75 0
      backend/app/services/material_number.py
  9. 13 3
      backend/app/services/spool_csv.py
  10. 13 0
      backend/app/services/spool_tag_matcher.py
  11. 528 0
      backend/tests/integration/test_material_number_api.py
  12. 33 0
      backend/tests/unit/services/test_spool_tag_matcher.py
  13. 47 0
      backend/tests/unit/test_github_backup_spool_fields.py
  14. 56 0
      backend/tests/unit/test_github_restore.py
  15. 94 0
      backend/tests/unit/test_material_number_migration.py
  16. 16 0
      backend/tests/unit/test_spoolman_inventory_helpers.py
  17. 14 0
      frontend/src/__tests__/components/AdditionalSection.test.tsx
  18. 69 0
      frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx
  19. 101 0
      frontend/src/__tests__/components/MaterialNumberStats.test.tsx
  20. 2 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  21. 141 0
      frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx
  22. 56 0
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  23. 186 0
      frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx
  24. 21 0
      frontend/src/api/client.ts
  25. 29 7
      frontend/src/components/BulkEditSpoolsModal.tsx
  26. 1 0
      frontend/src/components/ForecastPanel.tsx
  27. 79 0
      frontend/src/components/MaterialNumberStats.tsx
  28. 14 0
      frontend/src/components/SpoolFormModal.tsx
  29. 29 0
      frontend/src/components/spool-form/AdditionalSection.tsx
  30. 11 0
      frontend/src/components/spool-form/types.ts
  31. 2 0
      frontend/src/hooks/useWebSocket.ts
  32. 15 0
      frontend/src/i18n/locales/de.ts
  33. 15 0
      frontend/src/i18n/locales/en.ts
  34. 15 0
      frontend/src/i18n/locales/es.ts
  35. 15 0
      frontend/src/i18n/locales/fr.ts
  36. 15 0
      frontend/src/i18n/locales/it.ts
  37. 15 0
      frontend/src/i18n/locales/ja.ts
  38. 15 0
      frontend/src/i18n/locales/ko.ts
  39. 15 0
      frontend/src/i18n/locales/nl.ts
  40. 15 0
      frontend/src/i18n/locales/pt-BR.ts
  41. 15 0
      frontend/src/i18n/locales/ru.ts
  42. 15 0
      frontend/src/i18n/locales/sv.ts
  43. 15 0
      frontend/src/i18n/locales/tr.ts
  44. 15 0
      frontend/src/i18n/locales/uk.ts
  45. 15 0
      frontend/src/i18n/locales/zh-CN.ts
  46. 15 0
      frontend/src/i18n/locales/zh-TW.ts
  47. 64 3
      frontend/src/pages/InventoryPage.tsx
  48. 10 0
      frontend/src/pages/StatsPage.tsx
  49. 1 0
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  50. 5 0
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  51. 1 0
      frontend/src/utils/inventorySearch.ts

+ 10 - 0
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": [],

+ 91 - 0
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,

+ 6 - 0
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.

+ 9 - 0
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
 

+ 50 - 0
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

+ 10 - 0
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,

+ 11 - 0
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

+ 75 - 0
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

+ 13 - 3
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

+ 13 - 0
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,

+ 528 - 0
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"]

+ 33 - 0
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.

+ 47 - 0
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]

+ 56 - 0
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."""

+ 94 - 0
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"

+ 16 - 0
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

+ 14 - 0
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(<AdditionalSection {...baseProps} spoolmanMode={false} />);
+    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(<AdditionalSection {...baseProps} spoolmanMode={true} />);
+    expect(screen.queryByText('inventory.materialNumber')).toBeNull();
+  });
 });

+ 69 - 0
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(<BulkEditSpoolsModal {...baseProps} spoolmanMode={false} />);
+    for (const key of INTERNAL_ONLY) {
+      expect(screen.getByText(key)).toBeTruthy();
+    }
+  });
+
+  it('hides every field Spoolman cannot store in Spoolman mode', () => {
+    render(<BulkEditSpoolsModal {...baseProps} spoolmanMode={true} />);
+    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(<BulkEditSpoolsModal {...baseProps} />);
+    expect(screen.getByText('inventory.materialNumber')).toBeTruthy();
+  });
+});

+ 101 - 0
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(
+    <QueryClientProvider client={client}>
+      <MaterialNumberStats currency="EUR" {...props} />
+    </QueryClientProvider>,
+  );
+}
+
+describe('MaterialNumberStats', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders one row per material number with weights and cost', async () => {
+    (api.getMaterialNumberStats as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    const { rerender } = render(
+      <QueryClientProvider client={client}>
+        <MaterialNumberStats currency="EUR" dateFrom="2026-08-01" />
+      </QueryClientProvider>,
+    );
+    await screen.findByText(/No material numbers assigned yet/);
+    expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(1);
+
+    rerender(
+      <QueryClientProvider client={client}>
+        <MaterialNumberStats currency="EUR" dateFrom="2026-09-01" />
+      </QueryClientProvider>,
+    );
+
+    await vi.waitFor(() => expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(2));
+    expect(api.getMaterialNumberStats).toHaveBeenLastCalledWith({
+      dateFrom: '2026-09-01',
+      dateTo: undefined,
+    });
+  });
+});

+ 2 - 0
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();

+ 141 - 0
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<HTMLSelectElement> {
+  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(<InventoryPageRouter />);
+    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(<InventoryPageRouter />);
+    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(<InventoryPageRouter />);
+
+    await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand', 'AlphaBrand']));
+  });
+});

+ 56 - 0
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();
+  });
 });

+ 186 - 0
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<void>) {
+  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<typeof vi.fn>).mockImplementation((key: string) =>
+    key === 'bambusy-stats-timeframe' ? JSON.stringify({ preset }) : null
+  );
+}
+
+describe('StatsPage material-number widget', () => {
+  beforeEach(() => {
+    materialNumberRequests = [];
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
+  });
+
+  afterEach(() => {
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
+  });
+
+  it('asks the endpoint for the dashboard timeframe', async () => {
+    withTimeframe('last-30');
+    setupHandlers(false);
+    render(<StatsPage />);
+
+    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(<StatsPage />);
+
+    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(<StatsPage />);
+
+    // 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<void>((resolve) => {
+      openGate = resolve;
+    });
+    setupHandlers(true, gate);
+    render(<StatsPage />);
+
+    // 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<void>((resolve) => {
+      openGate = resolve;
+    });
+    setupHandlers(false, gate);
+    render(<StatsPage />);
+
+    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);
+    });
+  });
+});

+ 21 - 0
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<MaterialNumberStats[]>(`/inventory/stats/material-numbers${qs ? `?${qs}` : ''}`);
+  },
   getSpoolUsageHistory: (spoolId: number, limit = 50) =>
     request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
   getAllUsageHistory: (limit = 100, printerId?: number) =>

+ 29 - 7
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<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) => 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<string, string | number> => {
     const patch: Record<string, string | number> = {};
-    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')}
         </p>
         <div className="flex-1 overflow-y-auto p-5 space-y-3">
-          {FIELDS.map((f) => {
+          {fields.map((f) => {
             const enabled = values[f.id] !== undefined;
             return (
               <div key={f.id} className={`flex items-start gap-3 rounded-md p-2 transition-colors ${enabled ? 'bg-bambu-green/5 border border-bambu-green/30' : 'border border-transparent'}`}>

+ 1 - 0
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);

+ 79 - 0
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 (
+      <div className="flex items-center justify-center py-8">
+        <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  // 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 <p className="text-sm text-red-700 dark:text-red-400 py-4">{t('stats.materialNumbers.loadFailed')}</p>;
+  }
+
+  if (!data || data.length === 0) {
+    return <p className="text-sm text-bambu-gray py-4">{t('stats.materialNumbers.empty')}</p>;
+  }
+
+  return (
+    <div className="overflow-x-auto">
+      <table className="w-full text-sm">
+        <thead>
+          <tr className="text-left text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
+            <th className="py-2 pr-4 font-medium">{t('inventory.materialNumber')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.spools')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.remaining')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.consumed')}</th>
+            <th className="py-2 font-medium text-right">{t('stats.materialNumbers.cost')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {data.map((row) => (
+            <tr key={row.material_number} className="border-b border-bambu-dark-tertiary/50 last:border-b-0">
+              <td className="py-2 pr-4 text-white font-medium">{row.material_number}</td>
+              <td className="py-2 pr-4 text-bambu-gray text-right">{row.spool_count}</td>
+              <td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.remaining_g)}</td>
+              <td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.consumed_g)}</td>
+              <td className="py-2 text-bambu-gray text-right">
+                {currency} {row.cost.toFixed(2)}
+              </td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+}

+ 14 - 0
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<string>();
+    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}
                 />
               </div>
 

+ 29 - 0
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({
         </div>
       </div>
 
+      {/* 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 && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-material-number">
+            {t('inventory.materialNumber')}
+          </label>
+          <input
+            id="spool-material-number"
+            type="text"
+            list="spool-material-number-options"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.materialNumberPlaceholder')}
+            value={formData.material_number}
+            maxLength={64}
+            onChange={(e) => updateField('material_number', e.target.value)}
+          />
+          {availableMaterialNumbers.length > 0 && (
+            <datalist id="spool-material-number-options">
+              {availableMaterialNumbers.map((n) => <option key={n} value={n} />)}
+            </datalist>
+          )}
+          <p className="text-xs text-bambu-gray mt-1">{t('inventory.materialNumberHelp')}</p>
+        </div>
+      )}
+
       {/* Category (#729) */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-category">

+ 11 - 0
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

+ 2 - 0
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':

+ 15 - 0
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',

+ 15 - 0
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',

+ 15 - 0
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',

+ 15 - 0
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',

+ 15 - 0
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',

+ 15 - 0
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: 'サプライヤー',

+ 15 - 0
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: '공급업체 없음',

+ 15 - 0
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',

+ 15 - 0
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',

+ 15 - 0
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: "Без поставщика",

+ 15 - 0
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',

+ 15 - 0
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',

+ 15 - 0
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: "Без постачальника",

+ 15 - 0
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: '无供应商',

+ 15 - 0
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: '無供應商',

+ 64 - 3
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, (t: TFn) => 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<string, (ctx: CellCtx) => ReactNode> = {
   note: ({ spool }) => (
     <span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
   ),
+  material_number: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.material_number || '-'}</span>
+  ),
   // 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<number, string>();
@@ -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
           </select>
         )}
 
+        {/* 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) && (
+          <select
+            value={materialNumberFilter}
+            onChange={(e) => { 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'
+            }`}
+          >
+            <option value="">{t('inventory.materialNumber')}</option>
+            {uniqueMaterialNumbers.map((n) => (
+              <option key={n} value={n}>{n}</option>
+            ))}
+            {hasUnnumbered && (
+              <option value={MATERIAL_NUMBER_NONE}>{t('inventory.materialNumberNone')}</option>
+            )}
+          </select>
+        )}
+
         {/* 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 })}
       />

+ 10 - 0
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: <SupplierStats currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
       defaultSize: 2,
     }] as DashboardWidget[])),
+    ...(!spoolmanModeReady || spoolmanMode ? [] : ([{
+      id: 'material-numbers',
+      title: t('stats.materialNumbers.title'),
+      component: <MaterialNumberStats currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
+      defaultSize: 2,
+    }] as DashboardWidget[])),
   ];
 
   return (

+ 1 - 0
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) {

+ 5 - 0
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}
             />
           </div>
         ) : (

+ 1 - 0
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) ||