From 71d4b2f70d898d5e229d5eeb182787486bc89695 Mon Sep 17 00:00:00 2001 From: Thomansky <73141171+Thomansky@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:11:50 +0200 Subject: [PATCH] Material number as a first-class spool field (#2994) --- backend/app/api/routes/_spoolman_helpers.py | 10 + backend/app/api/routes/inventory.py | 91 +++ backend/app/core/database.py | 6 + backend/app/models/spool.py | 9 + backend/app/schemas/spool.py | 50 ++ backend/app/services/github_backup.py | 10 + backend/app/services/github_restore.py | 11 + backend/app/services/material_number.py | 75 +++ backend/app/services/spool_csv.py | 16 +- backend/app/services/spool_tag_matcher.py | 13 + .../integration/test_material_number_api.py | 528 ++++++++++++++++++ .../unit/services/test_spool_tag_matcher.py | 33 ++ .../unit/test_github_backup_spool_fields.py | 47 ++ backend/tests/unit/test_github_restore.py | 56 ++ .../unit/test_material_number_migration.py | 94 ++++ .../unit/test_spoolman_inventory_helpers.py | 16 + .../components/AdditionalSection.test.tsx | 14 + .../components/BulkEditSpoolsModal.test.tsx | 69 +++ .../components/MaterialNumberStats.test.tsx | 101 ++++ .../src/__tests__/hooks/useWebSocket.test.ts | 2 + ...InventoryPageMaterialNumberFilter.test.tsx | 141 +++++ .../pages/SpoolBuddyWriteTagPage.test.tsx | 56 ++ .../pages/StatsPageMaterialNumbers.test.tsx | 186 ++++++ frontend/src/api/client.ts | 21 + .../src/components/BulkEditSpoolsModal.tsx | 36 +- frontend/src/components/ForecastPanel.tsx | 1 + .../src/components/MaterialNumberStats.tsx | 79 +++ frontend/src/components/SpoolFormModal.tsx | 14 + .../spool-form/AdditionalSection.tsx | 29 + frontend/src/components/spool-form/types.ts | 11 + frontend/src/hooks/useWebSocket.ts | 2 + frontend/src/i18n/locales/de.ts | 15 + frontend/src/i18n/locales/en.ts | 15 + frontend/src/i18n/locales/es.ts | 15 + frontend/src/i18n/locales/fr.ts | 15 + frontend/src/i18n/locales/it.ts | 15 + frontend/src/i18n/locales/ja.ts | 15 + frontend/src/i18n/locales/ko.ts | 15 + frontend/src/i18n/locales/nl.ts | 15 + frontend/src/i18n/locales/pt-BR.ts | 15 + frontend/src/i18n/locales/ru.ts | 15 + frontend/src/i18n/locales/sv.ts | 15 + frontend/src/i18n/locales/tr.ts | 15 + frontend/src/i18n/locales/uk.ts | 15 + frontend/src/i18n/locales/zh-CN.ts | 15 + frontend/src/i18n/locales/zh-TW.ts | 15 + frontend/src/pages/InventoryPage.tsx | 67 ++- frontend/src/pages/StatsPage.tsx | 10 + .../pages/spoolbuddy/SpoolBuddyDashboard.tsx | 1 + .../spoolbuddy/SpoolBuddyWriteTagPage.tsx | 5 + frontend/src/utils/inventorySearch.ts | 1 + 51 files changed, 2123 insertions(+), 13 deletions(-) create mode 100644 backend/app/services/material_number.py create mode 100644 backend/tests/integration/test_material_number_api.py create mode 100644 backend/tests/unit/test_github_backup_spool_fields.py create mode 100644 backend/tests/unit/test_material_number_migration.py create mode 100644 frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx create mode 100644 frontend/src/__tests__/components/MaterialNumberStats.test.tsx create mode 100644 frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx create mode 100644 frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx create mode 100644 frontend/src/components/MaterialNumberStats.tsx diff --git a/backend/app/api/routes/_spoolman_helpers.py b/backend/app/api/routes/_spoolman_helpers.py index 9847d3ace..1fab71f17 100644 --- a/backend/app/api/routes/_spoolman_helpers.py +++ b/backend/app/api/routes/_spoolman_helpers.py @@ -55,6 +55,10 @@ class MappedSpoolFields(TypedDict): created_at: str | None # None when Spoolman spool has no registered timestamp updated_at: str | None cost_per_kg: float | None + # Spoolman's native filament.article_number, surfaced as the internal + # material number (#2870). Read-only in Spoolman mode — the number is + # filament-level there and maintained in Spoolman itself. + material_number: str | None storage_location: str | None location_id: int | None k_profiles: list[Any] @@ -414,6 +418,12 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields: # Spoolman has no updated_at field; use registered timestamp as best available proxy "updated_at": created_at, "cost_per_kg": _safe_optional_float(spool.get("price")), + # Spoolman's filament.article_number maps 1:1 onto the internal + # material number (#2870): both identify the purchasable product. + # Trimmed for the same reason the schema validator trims the internal + # one — the filter chip builds its options from trimmed values and + # matches exactly, so a padded number would list and match nothing. + "material_number": ((filament.get("article_number") or "").strip() or None), "storage_location": spool.get("location") or None, "location_id": None, "k_profiles": [], diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 92f2d25c1..ec7b38924 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -33,6 +33,7 @@ from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Su from backend.app.models.user import User from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate from backend.app.schemas.spool import ( + MaterialNumberStats, SpoolAssignmentCreate, SpoolAssignmentResponse, SpoolBulkCreate, @@ -67,6 +68,7 @@ from backend.app.services.location_service import ( prepare_internal_spool_payload, rename_location as rename_location_record, ) +from backend.app.services.material_number import apply_material_number_inheritance from backend.app.services.slicer_filament_resolver import resolve_slicer_filament from backend.app.services.slot_nozzle import resolve_slot_nozzle from backend.app.services.spool_csv import ( @@ -1467,6 +1469,10 @@ async def import_spools_csv( created = 0 for row in preview.rows: if row.status == "valid" and row.spool is not None: + # Deliberately no material-number inheritance here (#2870), unlike + # the other create paths: the file is authoritative. A CSV that + # leaves the column blank is stating "no number", not asking for + # one to be guessed from whatever else is in the inventory. spool = Spool(**row.spool) db.add(spool) # Supplier assignments resolved by name during parsing (#2988). @@ -1563,6 +1569,8 @@ async def create_spool( payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + # A new spool of an already-numbered product inherits its material number (#2870). + payload = await apply_material_number_inheritance(db, payload) spool = Spool(**payload) db.add(spool) await db.flush() @@ -1589,6 +1597,8 @@ async def bulk_create_spools( payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + # A new spool of an already-numbered product inherits its material number (#2870). + payload = await apply_material_number_inheritance(db, payload) for _ in range(data.quantity): spool = Spool(**payload) db.add(spool) @@ -2597,6 +2607,87 @@ async def get_supplier_stats( return sorted(stats.values(), key=lambda s: (-s.consumed_g, supplier_name_key(s.supplier_name))) +@router.get("/stats/material-numbers", response_model=list[MaterialNumberStats]) +async def get_material_number_stats( + date_from: date | None = Query(None), + date_to: date | None = Query(None), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Aggregate the inventory by material number (#2870). + + The material number is the internal purchasing identifier shared by all + spools of a product, so this is the grouping the business actually costs + by — unlike brand+material+colour. Two queries: active-spool counts and + remaining weight from the spool table, consumption and cost from the + recorded usage history (archived spools included — their consumption + happened). + + ``date_from``/``date_to`` narrow the usage half only, so the widget can + follow the dashboard timeframe the rest of the stats page uses. Stock is + point-in-time by nature and stays unfiltered — "how much do I hold" has + no date range. Sorted by consumption, heaviest first, then by number so + a range where nothing was consumed still lists in a stable order. + """ + from backend.app.models.spool_usage_history import SpoolUsageHistory + + # material_number is normalised to NULL-or-non-empty by the schema + # validator, so NULL is the only "unset" state to exclude here. + has_number = Spool.material_number.is_not(None) + + usage_filters = [has_number] + if date_from: + usage_filters.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc)) + if date_to: + usage_filters.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc)) + + # Clamped PER SPOOL, like every other remaining-weight computation in the + # codebase: a spool whose weight_used overshot its label_weight holds 0 g, + # it does not subtract from the other spools sharing the number. + per_spool_remaining = func.coalesce(Spool.label_weight, 0) - func.coalesce(Spool.weight_used, 0) + inventory_rows = await db.execute( + select( + Spool.material_number, + func.count(Spool.id), + func.sum(case((per_spool_remaining > 0, per_spool_remaining), else_=0.0)), + ) + .where(has_number, Spool.archived_at.is_(None)) + .group_by(Spool.material_number) + ) + + usage_rows = await db.execute( + select( + Spool.material_number, + func.sum(SpoolUsageHistory.weight_used), + func.sum(SpoolUsageHistory.cost), + ) + .join(Spool, SpoolUsageHistory.spool_id == Spool.id) + .where(*usage_filters) + .group_by(Spool.material_number) + ) + + stats: dict[str, MaterialNumberStats] = {} + for number, count, remaining in inventory_rows.all(): + stats[number] = MaterialNumberStats( + material_number=number, + spool_count=count, + remaining_g=float(remaining or 0), + consumed_g=0.0, + cost=0.0, + ) + for number, consumed, cost in usage_rows.all(): + entry = stats.get(number) + if entry is None: + entry = MaterialNumberStats( + material_number=number, spool_count=0, remaining_g=0.0, consumed_g=0.0, cost=0.0 + ) + stats[number] = entry + entry.consumed_g = float(consumed or 0) + entry.cost = float(cost or 0) + + return sorted(stats.values(), key=lambda s: (-s.consumed_g, s.material_number)) + + @router.get("/usage", response_model=list[SpoolUsageHistoryResponse]) async def get_all_usage_history( limit: int = 100, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 4bf02b442..e728357fa 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -5080,6 +5080,12 @@ async def run_migrations(conn): # create_all() covers fresh installs; this covers upgrades. await _migrate_create_supplier_tables(conn) + # Migration: Add material_number to spool (#2870). Nullable free text — + # the internal purchasing/article number a business costs by, shared by + # all spools of the same product. VARCHAR(64) is spelled identically on + # SQLite and Postgres. + await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN material_number VARCHAR(64)") + # Migration: repair the tare of spools the RFID auto-add gave the wrong # Bambu spool row (#2909). Runs last so the spool catalogue it reads is # whatever this database actually holds. diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index 46abdcfae..de94db3bc 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -57,6 +57,15 @@ class Spool(Base): # spools with a lower one without changing the global default. low_stock_threshold_pct: Mapped[int | None] = mapped_column(Integer) + # Internal material / article number (#2870): the identifier a business + # purchases and costs by (e.g. "15" = Bambu Lab PLA Basic), distinct from + # `category` (production grouping) and `note` (free text). Free text, no + # uniqueness — several spools of the same product share the number, which + # is exactly what makes it a sort/filter/statistics key. New spools of a + # matching product inherit it on creation (services/material_number.py, + # applied by the spool create routes and the RFID auto-add). + material_number: Mapped[str | None] = mapped_column(String(64)) + # Cost tracking cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index 1def1a589..ebef0bbc0 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -65,6 +65,19 @@ def normalize_extra_colors(value: str | None) -> str | None: return ",".join(tokens) +def normalize_material_number(value: str | None) -> str | None: + """Trim the material number and treat a blank one as unset (#2870). + + Every write path lands here (form, bulk edit, CSV import, direct API), so + "15" and "15 " can never become two groups in the statistics aggregate or + two entries in the inventory filter. Blank collapses to NULL rather than + "", which keeps "has no number" a single state to query for. + """ + if value is None: + return None + return value.strip() or None + + def normalize_effect_type(value: str | None) -> str | None: if value is None: return None @@ -123,6 +136,17 @@ class SpoolBase(BaseModel): # User-defined category + per-spool low-stock threshold override (#729). category: str | None = Field(default=None, max_length=50) low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99) + # Internal material / article number (#2870) — the purchasing identifier, + # shared by all spools of the same product. Free text, no uniqueness. + material_number: str | None = Field(default=None, max_length=64) + + # mode="before": trim first, so a padded value is held to the 64 + # characters it will store, not to the length it arrived with. + @field_validator("material_number", mode="before") + @classmethod + def _validate_material_number(cls, v): + return normalize_material_number(v) if isinstance(v, str) else v + # Free-text storage location, distinct from `location` (AMS slot # assignment). Column has lived on the ORM since the inventory rework # but was missing from this schema, so writes were silently dropped (#1291). @@ -176,6 +200,16 @@ class SpoolUpdate(BaseModel): # User-defined category + per-spool low-stock threshold override (#729). category: str | None = Field(default=None, max_length=50) low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99) + # Internal material / article number (#2870). + material_number: str | None = Field(default=None, max_length=64) + + # mode="before": trim first, so a padded value is held to the 64 + # characters it will store, not to the length it arrived with. + @field_validator("material_number", mode="before") + @classmethod + def _validate_material_number(cls, v): + return normalize_material_number(v) if isinstance(v, str) else v + storage_location: str | None = Field(default=None, max_length=255) location_id: int | None = Field(default=None, gt=0) @@ -254,6 +288,22 @@ class SpoolResponse(SpoolBase): populate_by_name = True +class MaterialNumberStats(BaseModel): + """Per-material-number inventory aggregate (#2870). + + ``spool_count`` and ``remaining_g`` cover active (non-archived) spools; + ``consumed_g`` and ``cost`` sum the recorded usage history of every spool + carrying the number, archived included — consumption doesn't disappear + when a spool is archived. + """ + + material_number: str + spool_count: int + remaining_g: float + consumed_g: float + cost: float + + class SpoolAssignmentCreate(BaseModel): spool_id: int printer_id: int diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index 32e8bac7e..debc1bc10 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -778,6 +778,16 @@ class GitHubBackupService: "nozzle_temp_max": s.nozzle_temp_max, "note": s.note, "cost_per_kg": s.cost_per_kg, + # The user's own bookkeeping on the spool: purchasing number + # (#2870), category and low-stock override (#729), free-text + # storage. All four were missing from this whitelist, so a + # restore silently dropped them. `location_id` stays out — + # the locations table itself is not in the backup, so the ID + # would point at whatever happens to own it on the target. + "material_number": s.material_number, + "category": s.category, + "low_stock_threshold_pct": s.low_stock_threshold_pct, + "storage_location": s.storage_location, "tag_uid": s.tag_uid, "tray_uuid": s.tray_uuid, "data_origin": s.data_origin, diff --git a/backend/app/services/github_restore.py b/backend/app/services/github_restore.py index de88a5fe5..38b8f7672 100644 --- a/backend/app/services/github_restore.py +++ b/backend/app/services/github_restore.py @@ -1313,6 +1313,17 @@ class GitHubRestoreService: "archived_at": _parse_dt(entry.get("archived_at")), } + # The user's own bookkeeping on the spool: purchasing number + # (#2870), category and low-stock override (#729), free-text + # storage. Added to the backup format after the fields above, so + # they are applied only when the file actually carries them — + # an older backup must not wipe what the live row holds. + # `location_id` is deliberately absent: the locations table is + # not backed up, so the ID would point at whatever owns it here. + for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"): + if late_field in entry: + fields[late_field] = entry[late_field] + if existing is not None: if old_id is not None: spool_id_map[old_id] = existing.id diff --git a/backend/app/services/material_number.py b/backend/app/services/material_number.py new file mode 100644 index 000000000..98641d9a4 --- /dev/null +++ b/backend/app/services/material_number.py @@ -0,0 +1,75 @@ +"""Material-number inheritance for newly created spools (#2870). + +The material number is the internal purchasing/article identifier a business +costs by (e.g. "15" = Bambu Lab PLA Basic). All spools of the same product +share it, so a new spool of an already-numbered product should arrive with +the number filled instead of blank — whether it is created manually, via the +API, or by the RFID auto-add. +""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.spool import Spool + + +async def find_material_number_for_product( + db: AsyncSession, + *, + material: str | None, + subtype: str | None, + brand: str | None, + color_name: str | None, +) -> str | None: + """Return the material number an existing spool of this product carries. + + Product identity is the (material, subtype, brand, color_name) string + tuple — the same key FilamentSkuSettings groups by. The most recently + updated match wins, newest row on a tie. `updated_at` moves on every + write, usage included, so this is "most recently touched", not "most + recently numbered": when a product's spools disagree, bulk-edit them to + one number rather than relying on which one wins. Archived spools count: + a product being out of stock doesn't change its number. + """ + if not material: + return None + + def _same(column, value): + return column.is_(None) if value is None else column == value + + result = await db.execute( + select(Spool.material_number) + .where( + # Normalised to NULL-or-non-empty by the schema validator, so + # NULL is the only "unset" state left to exclude. + Spool.material_number.is_not(None), + Spool.material == material, + _same(Spool.subtype, subtype), + _same(Spool.brand, brand), + _same(Spool.color_name, color_name), + ) + .order_by(Spool.updated_at.desc(), Spool.id.desc()) + .limit(1) + ) + return result.scalars().first() + + +async def apply_material_number_inheritance(db: AsyncSession, payload: dict) -> dict: + """Fill ``payload["material_number"]`` from a matching existing spool. + + No-op when the caller already supplied a non-empty number. Only used on + the create paths — editing a spool never overwrites what the user set. + """ + if payload.get("material_number"): + return payload + number = await find_material_number_for_product( + db, + material=payload.get("material"), + subtype=payload.get("subtype"), + brand=payload.get("brand"), + color_name=payload.get("color_name"), + ) + if number: + payload = dict(payload) + payload["material_number"] = number + return payload diff --git a/backend/app/services/spool_csv.py b/backend/app/services/spool_csv.py index 3696668e1..07183cb93 100644 --- a/backend/app/services/spool_csv.py +++ b/backend/app/services/spool_csv.py @@ -34,8 +34,9 @@ from backend.app.schemas.spool import SpoolCreate # import — `weight_used` is the source of truth, and accepting both would let # them contradict. `last_used` is a timestamp the model carries but SpoolCreate # does not, so import applies it to the ORM object directly (see persist path). -# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate -# fields included so a round-trip preserves them (they'd otherwise be lost). +# `storage_location`, `category`, `low_stock_threshold_pct` and +# `material_number` (#2870) are SpoolCreate fields included so a round-trip +# preserves them (they'd otherwise be lost). CSV_COLUMNS = [ "material", "brand", @@ -55,6 +56,7 @@ CSV_COLUMNS = [ "storage_location", "category", "low_stock_threshold_pct", + "material_number", # Supplier assignments (#2988): `suppliers` is the "; "-joined names of # all assigned suppliers, `purchase_supplier` the one this spool was # actually bought from (or empty). Import matches names against the @@ -382,7 +384,15 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie row_error: str | None = None # Plain text passthrough columns. - for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"): + for field in ( + "subtype", + "effect_type", + "extra_colors", + "note", + "storage_location", + "category", + "material_number", + ): value = cell(raw_row, field) if value: data[field] = value diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py index 9ce35cf8b..0392f12b5 100644 --- a/backend/app/services/spool_tag_matcher.py +++ b/backend/app/services/spool_tag_matcher.py @@ -227,6 +227,18 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool: remain_pct = 100 # Unknown → assume full weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1) + # A new spool of an already-numbered product inherits its material number + # (#2870) — an RFID-scanned refill arrives costed, not blank. + from backend.app.services.material_number import find_material_number_for_product + + material_number = await find_material_number_for_product( + db, + material=material, + subtype=subtype, + brand="Bambu Lab", + color_name=color_name, + ) + spool = Spool( material=material, subtype=subtype, @@ -235,6 +247,7 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool: extra_colors=extra_colors, effect_type=effect_type, brand="Bambu Lab", + material_number=material_number, label_weight=label_weight, core_weight=core_weight, core_weight_catalog_id=core_weight_catalog_id, diff --git a/backend/tests/integration/test_material_number_api.py b/backend/tests/integration/test_material_number_api.py new file mode 100644 index 000000000..7130c501a --- /dev/null +++ b/backend/tests/integration/test_material_number_api.py @@ -0,0 +1,528 @@ +"""API coverage for the spool material number (#2870). + +The material number is the internal purchasing identifier shared by all +spools of a product. Pinned here: CRUD round-trip, server-side normalisation, +inheritance on the create paths, the per-number statistics aggregate and its +dashboard timeframe, and the CSV round-trip. +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.spool import Spool +from backend.app.models.spool_usage_history import SpoolUsageHistory + + +@pytest.fixture +async def spool_factory(db_session: AsyncSession): + async def _create(**kwargs): + defaults = { + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Jade White", + "rgba": "FFFFFFFF", + "label_weight": 1000, + "core_weight": 250, + "weight_used": 0, + "weight_used_baseline": 0, + "weight_locked": False, + } + defaults.update(kwargs) + spool = Spool(**defaults) + db_session.add(spool) + await db_session.commit() + await db_session.refresh(spool) + return spool + + return _create + + +class TestMaterialNumberCrud: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_persists_and_lists_material_number(self, async_client: AsyncClient): + resp = await async_client.post( + "/api/v1/inventory/spools", + json={"material": "PLA", "material_number": "15"}, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "15" + + listing = await async_client.get("/api/v1/inventory/spools") + assert listing.status_code == 200 + assert [s["material_number"] for s in listing.json()] == ["15"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_updates_material_number(self, async_client: AsyncClient, spool_factory): + spool = await spool_factory(material_number="15") + + resp = await async_client.patch( + f"/api/v1/inventory/spools/{spool.id}", + json={"material_number": "16"}, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "16" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_material_number_longer_than_64_chars_is_rejected(self, async_client: AsyncClient): + resp = await async_client.post( + "/api/v1/inventory/spools", + json={"material": "PLA", "material_number": "x" * 65}, + ) + assert resp.status_code == 422 + + +class TestMaterialNumberNormalisation: + """One validator on the schema, so every write path normalises (#2870). + + Without it "15" and "15 " are two groups in the statistics aggregate and + two entries in the inventory filter chip, and the chip's exact match + never finds the padded one. + """ + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_strips_surrounding_whitespace(self, async_client: AsyncClient): + resp = await async_client.post( + "/api/v1/inventory/spools", + json={"material": "PLA", "material_number": " 15 "}, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "15" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_maps_blank_to_none(self, async_client: AsyncClient): + resp = await async_client.post( + "/api/v1/inventory/spools", + json={"material": "PLA", "material_number": " "}, + ) + assert resp.status_code == 200 + # NULL, not "" — "has no number" stays a single state to query for. + assert resp.json()["material_number"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory): + spool = await spool_factory(material_number="15") + + resp = await async_client.patch( + f"/api/v1/inventory/spools/{spool.id}", + json={"material_number": " 16 "}, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "16" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_edit_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory): + spool = await spool_factory() + + resp = await async_client.post( + "/api/v1/inventory/spools/bulk-update", + json={"ids": [spool.id], "update": {"material_number": " 15 "}}, + ) + assert resp.status_code == 200 + + listing = await async_client.get("/api/v1/inventory/spools") + assert [s["material_number"] for s in listing.json()] == ["15"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_csv_import_strips_surrounding_whitespace(self, async_client: AsyncClient): + csv = "material,brand,material_number\nPLA,Bambu Lab, 15 \n" + resp = await async_client.post( + "/api/v1/inventory/spools/import", + files={"file": ("spools.csv", csv.encode("utf-8"), "text/csv")}, + ) + assert resp.status_code == 200, resp.text + + listing = await async_client.get("/api/v1/inventory/spools") + assert [s["material_number"] for s in listing.json()] == ["15"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_padding_does_not_count_against_the_length_cap(self, async_client: AsyncClient, spool_factory): + """The cap applies to what is stored: 63 characters with padding + around them fit, on every path that goes through the schema.""" + padded = " " + "x" * 63 + created = await async_client.post( + "/api/v1/inventory/spools", json={"material": "PLA", "material_number": padded} + ) + assert created.status_code == 200, created.text + assert created.json()["material_number"] == "x" * 63 + + spool = await spool_factory() + patched = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": padded}) + assert patched.status_code == 200, patched.text + assert patched.json()["material_number"] == "x" * 63 + + bulk = await async_client.post( + "/api/v1/inventory/spools/bulk-update", + json={"ids": [spool.id], "update": {"material_number": " " + "y" * 64 + " "}}, + ) + assert bulk.status_code == 200, bulk.text + + too_long = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": "x" * 65}) + assert too_long.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_padded_duplicate_does_not_become_a_second_group(self, async_client: AsyncClient, spool_factory): + await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15"}) + await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15 "}) + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + assert [r["material_number"] for r in resp.json()] == ["15"] + assert resp.json()[0]["spool_count"] == 2 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_blank_number_is_not_offered_as_a_group(self, async_client: AsyncClient): + await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": " "}) + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + assert resp.json() == [] + + +class TestMaterialNumberInheritance: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_new_spool_of_same_product_inherits_number(self, async_client: AsyncClient, spool_factory): + await spool_factory(material_number="15") + + resp = await async_client.post( + "/api/v1/inventory/spools", + json={ + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Jade White", + }, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "15" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_different_product_does_not_inherit(self, async_client: AsyncClient, spool_factory): + await spool_factory(material_number="15") + + resp = await async_client.post( + "/api/v1/inventory/spools", + json={ + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Black", + }, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_explicit_number_wins_over_inheritance(self, async_client: AsyncClient, spool_factory): + await spool_factory(material_number="15") + + resp = await async_client.post( + "/api/v1/inventory/spools", + json={ + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Jade White", + "material_number": "99", + }, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "99" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_create_inherits_number(self, async_client: AsyncClient, spool_factory): + await spool_factory(material_number="15") + + resp = await async_client.post( + "/api/v1/inventory/spools/bulk", + json={ + "spool": { + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Jade White", + }, + "quantity": 3, + }, + ) + assert resp.status_code == 200 + assert [s["material_number"] for s in resp.json()] == ["15", "15", "15"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archived_spool_still_provides_the_number(self, async_client: AsyncClient, spool_factory): + from datetime import datetime, timezone + + await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc)) + + resp = await async_client.post( + "/api/v1/inventory/spools", + json={ + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Jade White", + }, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "15" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_timestamp_tie_goes_to_the_newest_spool( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + """Two donors touched in the same second must not pick one at random.""" + from datetime import datetime + + same_moment = datetime(2026, 1, 1, 12, 0, 0) + older = await spool_factory(material_number="15") + newer = await spool_factory(material_number="16") + for spool in (older, newer): + spool.updated_at = same_moment + await db_session.commit() + + resp = await async_client.post( + "/api/v1/inventory/spools", + json={ + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": "Jade White", + }, + ) + assert resp.status_code == 200 + assert resp.json()["material_number"] == "16" + + +class TestMaterialNumberStats: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_stats_group_by_number(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession): + a = await spool_factory(material_number="15", label_weight=1000, weight_used=200) + b = await spool_factory(material_number="15", label_weight=1000, weight_used=0) + c = await spool_factory(material_number="16", color_name="Black", label_weight=1000, weight_used=500) + await spool_factory(material_number=None, color_name="Gray") + + db_session.add_all( + [ + SpoolUsageHistory(spool_id=a.id, weight_used=120, percent_used=12, status="completed", cost=2.4), + SpoolUsageHistory(spool_id=b.id, weight_used=80, percent_used=8, status="completed", cost=1.6), + SpoolUsageHistory(spool_id=c.id, weight_used=500, percent_used=50, status="failed", cost=15.0), + ] + ) + await db_session.commit() + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + assert resp.status_code == 200 + rows = {r["material_number"]: r for r in resp.json()} + + assert set(rows) == {"15", "16"} + assert rows["15"]["spool_count"] == 2 + assert rows["15"]["remaining_g"] == pytest.approx(1800) + assert rows["15"]["consumed_g"] == pytest.approx(200) + assert rows["15"]["cost"] == pytest.approx(4.0) + assert rows["16"]["consumed_g"] == pytest.approx(500) + assert rows["16"]["cost"] == pytest.approx(15.0) + # Heaviest consumption first. + assert [r["material_number"] for r in resp.json()] == ["16", "15"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_an_over_consumed_spool_does_not_eat_its_siblings_stock( + self, async_client: AsyncClient, spool_factory + ): + """Remaining stock is clamped per spool, not once over the group. + + weight_used above label_weight is reachable (a scale reading, an AMS + sync, or a plain PATCH), and every other remaining-weight computation + in the codebase clamps each spool at 0. Summing the raw difference + first would subtract the overshoot from the other spools of the same + number and report less stock than the inventory list does. + """ + await spool_factory(material_number="15", label_weight=1000, weight_used=0) + await spool_factory(material_number="15", color_name="Black", label_weight=1000, weight_used=1200) + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + assert resp.status_code == 200 + row = resp.json()[0] + assert row["spool_count"] == 2 + assert row["remaining_g"] == pytest.approx(1000) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archived_spools_keep_their_recorded_consumption( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + from datetime import datetime, timezone + + archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc)) + db_session.add( + SpoolUsageHistory(spool_id=archived.id, weight_used=300, percent_used=30, status="completed", cost=6.0) + ) + await db_session.commit() + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + assert resp.status_code == 200 + rows = {r["material_number"]: r for r in resp.json()} + # No active spools carry the number, but the consumption is still there. + assert rows["15"]["spool_count"] == 0 + assert rows["15"]["remaining_g"] == 0 + assert rows["15"]["consumed_g"] == pytest.approx(300) + + +class TestMaterialNumberStatsTimeframe: + """The widget sits in the stats dashboard, so it follows its timeframe. + + Usage history is the per-period half; stock is point-in-time and stays + whole — "how much do I hold" has no date range. + """ + + @staticmethod + async def _usage(db_session, spool_id, *, days_ago, grams, cost): + from datetime import datetime, timedelta, timezone + + row = SpoolUsageHistory( + spool_id=spool_id, weight_used=grams, percent_used=grams / 10, status="completed", cost=cost + ) + # created_at is a server default, so set it explicitly to age the row. + row.created_at = (datetime.now(timezone.utc) - timedelta(days=days_ago)).replace(tzinfo=None) + db_session.add(row) + await db_session.commit() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_date_from_excludes_older_consumption( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + from datetime import datetime, timedelta, timezone + + spool = await spool_factory(material_number="15", label_weight=1000, weight_used=400) + await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0) + await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2) + + since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat() + resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}") + assert resp.status_code == 200 + row = resp.json()[0] + assert row["consumed_g"] == pytest.approx(10) + assert row["cost"] == pytest.approx(0.2) + # Stock is point-in-time: unaffected by the range. + assert row["spool_count"] == 1 + assert row["remaining_g"] == pytest.approx(600) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_date_to_excludes_newer_consumption( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + from datetime import datetime, timedelta, timezone + + spool = await spool_factory(material_number="15") + await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0) + await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2) + + until = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat() + resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_to={until}") + assert resp.json()[0]["consumed_g"] == pytest.approx(1000) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_no_range_still_reports_lifetime_totals( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + spool = await spool_factory(material_number="15") + await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0) + await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2) + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + assert resp.json()[0]["consumed_g"] == pytest.approx(1010) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_a_number_with_no_usage_in_range_still_lists_its_stock( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + from datetime import datetime, timedelta, timezone + + spool = await spool_factory(material_number="15", label_weight=1000, weight_used=250) + await self._usage(db_session, spool.id, days_ago=200, grams=250, cost=5.0) + + since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat() + resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}") + row = resp.json()[0] + assert row["consumed_g"] == 0 + assert row["remaining_g"] == pytest.approx(750) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_ties_sort_by_number_so_the_order_is_stable( + self, async_client: AsyncClient, spool_factory, db_session: AsyncSession + ): + """Equal consumption has to fall back to the number, not to row order. + + The response is assembled in two passes — active spools first, then + the numbers that only appear in usage history — so "16" (which has a + live spool) is seeded before "15" (archived, usage only). Without the + number tie-break the endpoint hands that seeding order straight back. + """ + from datetime import datetime, timezone + + live = await spool_factory(material_number="16", color_name="Black") + archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc)) + db_session.add_all( + [ + SpoolUsageHistory(spool_id=live.id, weight_used=100, percent_used=10, status="completed", cost=2.0), + SpoolUsageHistory(spool_id=archived.id, weight_used=100, percent_used=10, status="completed", cost=2.0), + ] + ) + await db_session.commit() + + resp = await async_client.get("/api/v1/inventory/stats/material-numbers") + rows = resp.json() + assert [r["consumed_g"] for r in rows] == [pytest.approx(100), pytest.approx(100)] + assert [r["material_number"] for r in rows] == ["15", "16"] + + +class TestMaterialNumberCsv: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_export_import_round_trip(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession): + await spool_factory(material_number="15") + + export = await async_client.get("/api/v1/inventory/spools/export") + assert export.status_code == 200 + text = export.text + header = text.splitlines()[0] + assert "material_number" in header.split(",") + assert ",15" in text.splitlines()[1] or text.splitlines()[1].endswith("15") + + # Wipe and re-import: the number must survive the round trip. + from sqlalchemy import delete + + await db_session.execute(delete(Spool)) + await db_session.commit() + + imported = await async_client.post( + "/api/v1/inventory/spools/import", + files={"file": ("spools.csv", text.encode("utf-8"), "text/csv")}, + ) + assert imported.status_code == 200, imported.text + assert imported.json()["created"] == 1 + + listing = await async_client.get("/api/v1/inventory/spools") + assert [s["material_number"] for s in listing.json()] == ["15"] diff --git a/backend/tests/unit/services/test_spool_tag_matcher.py b/backend/tests/unit/services/test_spool_tag_matcher.py index edfaf80df..429fda2ef 100644 --- a/backend/tests/unit/services/test_spool_tag_matcher.py +++ b/backend/tests/unit/services/test_spool_tag_matcher.py @@ -96,6 +96,39 @@ async def test_create_spool_from_tray_weight_from_remain(db_session): assert spool.weight_used == 200.0 +@pytest.mark.asyncio +async def test_create_spool_from_tray_inherits_material_number(db_session): + """A scanned refill of an already-numbered product arrives costed (#2870). + + This is the one create path nobody watches: it fires from the AMS, not + from a request, and it hardcodes brand="Bambu Lab" in the lookup. + """ + first = await create_spool_from_tray(db_session, SAMPLE_TRAY) + await db_session.commit() + assert first.material_number is None + + first.material_number = "15" + await db_session.commit() + + second = await create_spool_from_tray(db_session, SAMPLE_TRAY) + await db_session.commit() + + assert second.material_number == "15" + + +@pytest.mark.asyncio +async def test_create_spool_from_tray_does_not_inherit_across_products(db_session): + """The number follows the product, not the brand (#2870).""" + donor = await create_spool_from_tray(db_session, SAMPLE_TRAY) + donor.material_number = "15" + await db_session.commit() + + other = await create_spool_from_tray(db_session, {**SAMPLE_TRAY, "tray_type": "PETG"}) + await db_session.commit() + + assert other.material_number is None + + @pytest.mark.asyncio async def test_create_spool_from_tray_relationships_loaded(db_session): """Both k_profiles and assignments must be eagerly initialized. diff --git a/backend/tests/unit/test_github_backup_spool_fields.py b/backend/tests/unit/test_github_backup_spool_fields.py new file mode 100644 index 000000000..13297cd4c --- /dev/null +++ b/backend/tests/unit/test_github_backup_spool_fields.py @@ -0,0 +1,47 @@ +"""The spool collector's field whitelist for Git backup (#2870). + +``_collect_spools`` builds an explicit dict per spool, so anything missing +from it is silently absent from the backup and therefore lost on restore. +The owner's own bookkeeping — purchasing number (#2870), category and +low-stock override (#729), free-text storage — belongs in the file. +""" + +import pytest + +from backend.app.models.spool import Spool +from backend.app.services.github_backup import GitHubBackupService + + +@pytest.mark.asyncio +async def test_collects_the_owners_own_bookkeeping_fields(db_session): + db_session.add( + Spool( + material="PLA", + material_number="15", + category="Production", + low_stock_threshold_pct=40, + storage_location="Shelf B", + ) + ) + await db_session.commit() + + files: dict = {} + await GitHubBackupService()._collect_spools(db_session, files) + + entry = files["spools/inventory.json"]["spools"][0] + assert entry["material_number"] == "15" + assert entry["category"] == "Production" + assert entry["low_stock_threshold_pct"] == 40 + assert entry["storage_location"] == "Shelf B" + + +@pytest.mark.asyncio +async def test_location_id_is_left_out(db_session): + """The locations table is not backed up, so the ID has nothing to mean.""" + db_session.add(Spool(material="PLA")) + await db_session.commit() + + files: dict = {} + await GitHubBackupService()._collect_spools(db_session, files) + + assert "location_id" not in files["spools/inventory.json"]["spools"][0] diff --git a/backend/tests/unit/test_github_restore.py b/backend/tests/unit/test_github_restore.py index c2d8236ce..c6e7771a0 100644 --- a/backend/tests/unit/test_github_restore.py +++ b/backend/tests/unit/test_github_restore.py @@ -931,6 +931,62 @@ class TestRestoreSpools: assert len(spools) == 1 assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0) + @pytest.mark.asyncio + async def test_restores_the_owners_own_bookkeeping_fields(self, db_session): + """#2870 + #729: purchasing number, category, threshold, storage.""" + tally = _CategoryTally() + entry = self._spool_entry( + material_number="15", + category="Production", + low_stock_threshold_pct=40, + storage_location="Shelf B", + ) + + await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(Spool))).scalar_one() + assert row.material_number == "15" + assert row.category == "Production" + assert row.low_stock_threshold_pct == 40 + assert row.storage_location == "Shelf B" + + @pytest.mark.asyncio + async def test_a_backup_predating_those_fields_does_not_wipe_them(self, db_session): + """An old file has no such keys — overwrite must leave the live values.""" + db_session.add( + Spool( + material="PLA", + tag_uid="AABBCCDD", + material_number="15", + category="Production", + low_stock_threshold_pct=40, + storage_location="Shelf B", + ) + ) + await db_session.commit() + tally = _CategoryTally() + + await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {}) + await db_session.commit() + + row = (await db_session.execute(select(Spool))).scalar_one() + assert row.material_number == "15" + assert row.category == "Production" + assert row.low_stock_threshold_pct == 40 + assert row.storage_location == "Shelf B" + + @pytest.mark.asyncio + async def test_location_id_is_never_restored(self, db_session): + """The locations table is not in the backup, so the ID is meaningless.""" + tally = _CategoryTally() + entry = self._spool_entry(location_id=99) + + await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {}) + await db_session.commit() + + assert (await db_session.execute(select(Spool))).scalar_one().location_id is None + @pytest.mark.asyncio async def test_usage_history_spool_id_is_remapped(self, db_session): """Usage rows must point at the new local spool id, not the backup's.""" diff --git a/backend/tests/unit/test_material_number_migration.py b/backend/tests/unit/test_material_number_migration.py new file mode 100644 index 000000000..1b64f228f --- /dev/null +++ b/backend/tests/unit/test_material_number_migration.py @@ -0,0 +1,94 @@ +"""Migration tests for the spool material_number column (#2870). + +A legacy database whose spool table predates the column must gain it on +upgrade, existing rows must read back as NULL, and re-running the migration +must be a no-op (idempotent _safe_execute). +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from backend.app.core.database import run_migrations + + +@pytest.fixture(autouse=True) +def force_sqlite_dialect(monkeypatch): + from backend.app.core import db_dialect + + monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True) + monkeypatch.setattr(db_dialect, "is_postgres", lambda: False) + from backend.app.core import database as database_module + + monkeypatch.setattr(database_module, "is_sqlite", lambda: True) + + +def _register_all_models(): + import backend.app.models # noqa: F401 + from backend.app.models import ( # noqa: F401 + external_link, + location, + print_log, + print_queue, + project_bom, + slot_preset, + spoolman_k_profile, + spoolman_slot_assignment, + virtual_printer, + ) + + +@pytest.fixture +async def engine_with_legacy_spool_table(): + """create_all builds the current schema; dropping the column afterwards + reproduces a database from a Bambuddy version that predates #2870.""" + from backend.app.core.database import Base + + _register_all_models() + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute(text("ALTER TABLE spool DROP COLUMN material_number")) + await conn.execute( + text( + """ + INSERT INTO spool ( + material, label_weight, core_weight, + weight_used, weight_used_baseline, weight_locked + ) + VALUES ('PLA', 1000, 250, 0, 0, 0) + """ + ) + ) + yield engine + await engine.dispose() + + +async def test_migration_adds_material_number_column(engine_with_legacy_spool_table): + async with engine_with_legacy_spool_table.begin() as conn: + await run_migrations(conn) + + async with engine_with_legacy_spool_table.connect() as conn: + rows = (await conn.execute(text("SELECT id, material, material_number FROM spool"))).all() + + assert len(rows) == 1 + # Pre-existing rows read back with NULL, not an error or a default. + assert rows[0].material_number is None + + +async def test_migration_is_idempotent(engine_with_legacy_spool_table): + async with engine_with_legacy_spool_table.begin() as conn: + await run_migrations(conn) + # A value written after the first run must survive the second run — the + # duplicate ALTER TABLE is swallowed, not applied destructively. + async with engine_with_legacy_spool_table.begin() as conn: + await conn.execute(text("UPDATE spool SET material_number = '15'")) + async with engine_with_legacy_spool_table.begin() as conn: + await run_migrations(conn) + + async with engine_with_legacy_spool_table.connect() as conn: + value = (await conn.execute(text("SELECT material_number FROM spool"))).scalar_one() + + assert value == "15" diff --git a/backend/tests/unit/test_spoolman_inventory_helpers.py b/backend/tests/unit/test_spoolman_inventory_helpers.py index f81bb4408..715912371 100644 --- a/backend/tests/unit/test_spoolman_inventory_helpers.py +++ b/backend/tests/unit/test_spoolman_inventory_helpers.py @@ -107,6 +107,22 @@ class TestMapSpoolmanSpool: assert result["weight_used_baseline"] == pytest.approx(0.0) assert result["data_origin"] == "spoolman" + def test_article_number_maps_to_material_number(self): + """Spoolman's filament.article_number is the material number (#2870).""" + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": "15"}} + assert _map_spoolman_spool(spool)["material_number"] == "15" + + def test_missing_or_blank_article_number_maps_to_none(self): + assert _map_spoolman_spool(MINIMAL_SPOOL)["material_number"] is None + for value in ("", " "): + blank = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": value}} + assert _map_spoolman_spool(blank)["material_number"] is None + + def test_padded_article_number_is_trimmed(self): + """The filter chip matches exactly against trimmed options (#2870).""" + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": " 15 "}} + assert _map_spoolman_spool(spool)["material_number"] == "15" + def test_remaining_weight_drives_synthetic_used_for_parity(self): """When remaining_weight is set, weight_used = label - remaining and the baseline absorbs the used_weight delta. This mirrors the internal diff --git a/frontend/src/__tests__/components/AdditionalSection.test.tsx b/frontend/src/__tests__/components/AdditionalSection.test.tsx index 383cb472d..b0f86ef74 100644 --- a/frontend/src/__tests__/components/AdditionalSection.test.tsx +++ b/frontend/src/__tests__/components/AdditionalSection.test.tsx @@ -17,6 +17,7 @@ const baseProps = { spoolCatalog: [], currencySymbol: '$', availableCategories: [], + availableMaterialNumbers: [], globalLowStockThreshold: 20, }; @@ -26,4 +27,17 @@ describe('AdditionalSection', () => { // SpoolWeightPicker renders the 'inventory.coreWeight' label expect(screen.getByText('inventory.coreWeight')).toBeTruthy(); }); + + it('renders the material number field in internal mode (#2870)', () => { + render(); + expect(screen.getByText('inventory.materialNumber')).toBeTruthy(); + }); + + it('hides the material number field in Spoolman mode (#2870)', () => { + // In Spoolman mode the number is the filament-level article_number, + // maintained in Spoolman itself — the form must not offer an input + // whose value would be silently dropped. + render(); + expect(screen.queryByText('inventory.materialNumber')).toBeNull(); + }); }); diff --git a/frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx b/frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx new file mode 100644 index 000000000..1f7b418fd --- /dev/null +++ b/frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx @@ -0,0 +1,69 @@ +/** + * Bulk edit: the field list must only offer fields the active inventory + * backend can actually store (#2870). + * + * In Spoolman mode a spool has no material number, category or low-stock + * override of its own — SpoolmanInventoryUpdate has no such fields, so the + * payload dumps to {} and the route answers 400 "update must include at + * least one field". The user ticks a box, types a value, clicks Apply and + * gets an error. Filtering the list is the fix. + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { render } from '../utils'; +import { BulkEditSpoolsModal } from '../../components/BulkEditSpoolsModal'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const baseProps = { + isOpen: true, + selectedCount: 3, + isPending: false, + availableLocations: [], + availableMaterials: [], + availableSubtypes: [], + availableBrands: [], + availableCategories: [], + availableMaterialNumbers: [], + availableSlicerFilaments: [], + availableSlicerFilamentNames: [], + onClose: vi.fn(), + onApply: vi.fn(), +}; + +const INTERNAL_ONLY = [ + 'inventory.materialNumber', + 'inventory.category', + 'inventory.lowStockThresholdOverride', +]; + +describe('BulkEditSpoolsModal field list', () => { + it('offers the internal-only fields in internal mode', () => { + render(); + for (const key of INTERNAL_ONLY) { + expect(screen.getByText(key)).toBeTruthy(); + } + }); + + it('hides every field Spoolman cannot store in Spoolman mode', () => { + render(); + for (const key of INTERNAL_ONLY) { + expect(screen.queryByText(key)).toBeNull(); + } + // The fields Spoolman does accept stay. + expect(screen.getByText('inventory.material')).toBeTruthy(); + expect(screen.getByText('inventory.note')).toBeTruthy(); + expect(screen.getByText('inventory.costPerKg')).toBeTruthy(); + }); + + it('defaults to internal mode when the prop is omitted', () => { + render(); + expect(screen.getByText('inventory.materialNumber')).toBeTruthy(); + }); +}); diff --git a/frontend/src/__tests__/components/MaterialNumberStats.test.tsx b/frontend/src/__tests__/components/MaterialNumberStats.test.tsx new file mode 100644 index 000000000..823b0853c --- /dev/null +++ b/frontend/src/__tests__/components/MaterialNumberStats.test.tsx @@ -0,0 +1,101 @@ +/** + * Tests for the MaterialNumberStats widget (#2870). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MaterialNumberStats } from '../../components/MaterialNumberStats'; +import { api } from '../../api/client'; + +vi.mock('../../api/client', () => ({ + api: { + getMaterialNumberStats: vi.fn(), + }, +})); + +function renderWidget(props: { dateFrom?: string; dateTo?: string } = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('MaterialNumberStats', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders one row per material number with weights and cost', async () => { + (api.getMaterialNumberStats as ReturnType).mockResolvedValue([ + { material_number: '16', spool_count: 1, remaining_g: 500, consumed_g: 1500, cost: 45 }, + { material_number: '15', spool_count: 12, remaining_g: 9500, consumed_g: 250, cost: 5 }, + ]); + renderWidget(); + + expect(await screen.findByText('16')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + // >= 1 kg renders as kilograms, below stays in grams. + expect(screen.getByText('9.50 kg')).toBeInTheDocument(); + expect(screen.getByText('500 g')).toBeInTheDocument(); + expect(screen.getByText('EUR 45.00')).toBeInTheDocument(); + }); + + it('shows the empty hint when no numbers are assigned', async () => { + (api.getMaterialNumberStats as ReturnType).mockResolvedValue([]); + renderWidget(); + + expect(await screen.findByText(/No material numbers assigned yet/)).toBeInTheDocument(); + }); + + // A 403 from a missing INVENTORY_READ, a 500 or a dropped connection are + // not "you have not numbered your spools" — the two states must read + // differently or the user goes looking for a problem that isn't there. + it('reports an API failure as a failure, not as an empty inventory', async () => { + (api.getMaterialNumberStats as ReturnType).mockRejectedValue(new Error('403')); + renderWidget(); + + expect(await screen.findByText(/Could not load the material number statistics/)).toBeInTheDocument(); + expect(screen.queryByText(/No material numbers assigned yet/)).not.toBeInTheDocument(); + }); + + it('passes the dashboard timeframe to the endpoint', async () => { + (api.getMaterialNumberStats as ReturnType).mockResolvedValue([]); + renderWidget({ dateFrom: '2026-08-01', dateTo: '2026-08-31' }); + + await screen.findByText(/No material numbers assigned yet/); + expect(api.getMaterialNumberStats).toHaveBeenCalledWith({ + dateFrom: '2026-08-01', + dateTo: '2026-08-31', + }); + }); + + // The range is part of the query key, so the cache cannot serve a + // 30-day answer when the dashboard has moved to 90. + it('refetches on the same client when the timeframe changes', async () => { + (api.getMaterialNumberStats as ReturnType).mockResolvedValue([]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender } = render( + + + , + ); + await screen.findByText(/No material numbers assigned yet/); + expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + + await vi.waitFor(() => expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(2)); + expect(api.getMaterialNumberStats).toHaveBeenLastCalledWith({ + dateFrom: '2026-09-01', + dateTo: undefined, + }); + }); +}); diff --git a/frontend/src/__tests__/hooks/useWebSocket.test.ts b/frontend/src/__tests__/hooks/useWebSocket.test.ts index d14be149f..dbce7ecd0 100644 --- a/frontend/src/__tests__/hooks/useWebSocket.test.ts +++ b/frontend/src/__tests__/hooks/useWebSocket.test.ts @@ -468,6 +468,8 @@ describe('useWebSocket hook', () => { // #2988: without this key the supplier broadcast reached nothing, so a // supplier created in one tab never showed up in another. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] }); + // The per-material-number aggregate is derived from the same rows (#2870). + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] }); vi.useRealTimers(); vi.unstubAllGlobals(); diff --git a/frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx b/frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx new file mode 100644 index 000000000..2d962e96a --- /dev/null +++ b/frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx @@ -0,0 +1,141 @@ +/** + * The material-number filter chip (#2870). + * + * The chip needs one slot for "no number assigned". The other chips spell + * that '__none__', but a material number is free text, so '__none__' can be + * a real value — and then picking it filtered for the spools that have no + * number at all. The sentinel is now longer than the column's 64-character + * cap, so no spool can collide with it. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import InventoryPageRouter from '../../pages/InventoryPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const BASE = { + material: 'PLA', + subtype: 'Basic', + color_name: 'Red', + rgba: 'FF0000FF', + label_weight: 1000, + core_weight: 250, + weight_used: 100, + slicer_filament: null, + slicer_filament_name: null, + nozzle_temp_min: 220, + nozzle_temp_max: 240, + note: null, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + archived_at: null, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + k_profiles: [], + cost_per_kg: null, + last_scale_weight: null, + last_weighed_at: null, + storage_location: null, + category: null, + low_stock_threshold_pct: null, + weight_locked: false, +}; + +// One spool whose material number is literally the old sentinel, one with no +// number at all. The two must never be confused for each other. +const SPOOLS = [ + { ...BASE, id: 1, brand: 'AlphaBrand', material_number: '__none__' }, + { ...BASE, id: 2, brand: 'BetaBrand', material_number: null }, +]; + +function setupHandlers() { + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD', low_stock_threshold: 20.0, language: 'en' })), + http.get('/api/v1/settings/spoolman', () => + HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' }) + ), + http.get('/api/v1/inventory/spools', () => HttpResponse.json(SPOOLS)), + http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), + http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), + http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])), + http.get('/api/v1/inventory/colors', () => HttpResponse.json([])), + http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])), + http.get('/api/v1/inventory/locations', () => HttpResponse.json([])), + http.get('/api/v1/printers/', () => HttpResponse.json([])), + ); +} + +async function materialNumberSelect(): Promise { + return waitFor(() => { + const found = screen + .getAllByRole('combobox') + .find((el) => el.querySelector('option[value=""]')?.textContent === 'Material No.'); + if (!found) throw new Error('material number chip not rendered'); + return found as HTMLSelectElement; + }); +} + +/** Brand names shown in the table body — the chips list brands too. */ +function rowBrands(): string[] { + return Array.from(document.querySelectorAll('tbody tr')) + .map((row) => row.textContent ?? '') + .flatMap((text) => ['AlphaBrand', 'BetaBrand'].filter((b) => text.includes(b))); +} + +describe('InventoryPage material-number filter', () => { + beforeEach(() => { + setupHandlers(); + vi.mocked(localStorage.getItem).mockReturnValue(null); + }); + + it('filters for the literal value "__none__" rather than for unnumbered spools', async () => { + render(); + const select = await materialNumberSelect(); + await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand', 'BetaBrand'])); + + fireEvent.change(select, { target: { value: '__none__' } }); + + // The spool whose number IS '__none__', not the one without a number. + await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand'])); + }); + + it('still offers a slot that finds the spools with no number', async () => { + render(); + const select = await materialNumberSelect(); + await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand', 'BetaBrand'])); + + const noneOption = Array.from(select.options).find((o) => o.textContent === 'No material number'); + expect(noneOption).toBeTruthy(); + // The sentinel outruns SpoolBase's 64-character cap, so it is a value no + // spool can carry. + expect(noneOption!.value.length).toBeGreaterThan(64); + + fireEvent.change(select, { target: { value: noneOption!.value } }); + + await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand'])); + }); + + it('sorts the Material No. column numerically, "2" before "15"', async () => { + server.use( + http.get('/api/v1/inventory/spools', () => + HttpResponse.json([ + { ...BASE, id: 1, brand: 'AlphaBrand', material_number: '15' }, + { ...BASE, id: 2, brand: 'BetaBrand', material_number: '2' }, + ]) + ), + ); + vi.mocked(localStorage.getItem).mockImplementation((key) => + key === 'bambuddy-inventory-sort' ? '{"column":"material_number","direction":"asc"}' : null, + ); + render(); + + await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand', 'AlphaBrand'])); + }); +}); diff --git a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx index 987b656e6..4d2e74b63 100644 --- a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx +++ b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx @@ -357,4 +357,60 @@ describe('SpoolBuddyWriteTagPage', () => { expect(payload).not.toHaveProperty('core_weight_catalog_id'); }); }); + + // #2870: the form rendered a Material No. input whose value never made it + // into the create payload, so the spool was saved with an inherited number + // or none at all — silently, with no hint that the typed one was dropped. + async function openFullNewSpoolForm() { + const rendered = renderPage(); + fireEvent.click(screen.getByText('New Spool')); + await waitFor(() => { + expect(screen.getByText('Material')).toBeDefined(); + }); + // The simple view's material select is the cheapest way to satisfy the + // only field validateForm insists on. + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA' } }); + fireEvent.click(screen.getByText('Full')); + await waitFor(() => { + expect(screen.getByText('Quick Add')).toBeDefined(); + }); + return rendered; + } + + it('sends the material number the operator typed (#2870)', async () => { + const { container } = await openFullNewSpoolForm(); + + // Quick Add so material is the only required field on this form. + const quickAddToggle = screen.getByText('Quick Add').parentElement?.querySelector('button'); + expect(quickAddToggle).toBeTruthy(); + fireEvent.click(quickAddToggle as HTMLElement); + + const numberInput = container.querySelector('#spool-material-number') as HTMLInputElement; + expect(numberInput).toBeTruthy(); + fireEvent.change(numberInput, { target: { value: '77' } }); + + fireEvent.click(screen.getByText('Create Spool')); + + await waitFor(() => { + expect(vi.mocked(mockedApi.createSpool)).toHaveBeenCalled(); + }); + expect(vi.mocked(mockedApi.createSpool).mock.calls[0][0]).toEqual( + expect.objectContaining({ material_number: '77' }), + ); + }); + + it('does not offer the material number in Spoolman mode (#2870)', async () => { + // There the number is Spoolman's filament-level article_number; an input + // the internal create path cannot store must not be shown. + vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({ + spoolman_enabled: 'true', + spoolman_url: 'http://spoolman.test', + spoolman_sync_mode: '', + spoolman_disable_weight_sync: '', + spoolman_report_partial_usage: '', + }); + const { container } = await openFullNewSpoolForm(); + + expect(container.querySelector('#spool-material-number')).toBeNull(); + }); }); diff --git a/frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx b/frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx new file mode 100644 index 000000000..f6087b3cb --- /dev/null +++ b/frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx @@ -0,0 +1,186 @@ +/** + * The "By Material Number" widget on the stats dashboard (#2870). + * + * It sits in the same grid as the widgets that follow the dashboard + * timeframe, so its usage half has to follow it too — otherwise it shows + * lifetime totals next to cards headed "Last 30 days". + * + * In Spoolman mode it aggregates the internal spool table, which is empty + * there, while the inventory list does show numbers mapped from Spoolman's + * filament.article_number. A permanently empty card saying "no material + * numbers assigned yet" next to an inventory full of them is worse than no + * card, so the widget is dropped in that mode. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import { StatsPage } from '../../pages/StatsPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const EMPTY_STATS = { + total_prints: 0, + successful_prints: 0, + failed_prints: 0, + cancelled_prints: 0, + total_print_time_hours: 0, + total_filament_grams: 0, + total_cost: 0, + prints_by_filament_type: {}, + prints_by_printer: {}, + average_time_accuracy: 0, + time_accuracy_by_printer: {}, + total_energy_kwh: 0, + total_energy_cost: 0, +}; + +let materialNumberRequests: URL[] = []; + +function setupHandlers(spoolmanEnabled: boolean, spoolmanGate?: Promise) { + server.use( + http.get('/api/v1/archives/stats', () => HttpResponse.json(EMPTY_STATS)), + http.get('/api/v1/printers/', () => HttpResponse.json([])), + http.get('/api/v1/archives/slim', () => HttpResponse.json([])), + http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD' })), + http.get('/api/v1/settings/spoolman', async () => { + // A gate lets a test hold the settings response back while the rest of + // the dashboard renders, which is the ordering the widget has to survive. + if (spoolmanGate) await spoolmanGate; + return HttpResponse.json({ + spoolman_enabled: spoolmanEnabled ? 'true' : 'false', + spoolman_url: spoolmanEnabled ? 'http://spoolman.local' : '', + }); + }), + http.get('/api/v1/archives/analysis/failures', () => + HttpResponse.json({ + period_days: 30, + total_prints: 0, + failed_prints: 0, + failure_rate: 0, + failures_by_reason: {}, + failures_by_filament: {}, + failures_by_printer: {}, + failures_by_hour: {}, + recent_failures: [], + trend: [], + }) + ), + http.get('/api/v1/inventory/stats/material-numbers', ({ request }) => { + materialNumberRequests.push(new URL(request.url)); + return HttpResponse.json([]); + }) + ); +} + +/** The suite stubs localStorage with bare mocks, so feed the timeframe in. */ +function withTimeframe(preset: string) { + (localStorage.getItem as ReturnType).mockImplementation((key: string) => + key === 'bambusy-stats-timeframe' ? JSON.stringify({ preset }) : null + ); +} + +describe('StatsPage material-number widget', () => { + beforeEach(() => { + materialNumberRequests = []; + (localStorage.getItem as ReturnType).mockReset(); + }); + + afterEach(() => { + (localStorage.getItem as ReturnType).mockReset(); + }); + + it('asks the endpoint for the dashboard timeframe', async () => { + withTimeframe('last-30'); + setupHandlers(false); + render(); + + await waitFor(() => { + expect(screen.getByText('By Material Number')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(materialNumberRequests.length).toBeGreaterThan(0); + }); + + const today = new Date(); + const from = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 29)); + expect(materialNumberRequests[0].searchParams.get('date_from')).toBe(from.toISOString().split('T')[0]); + expect(materialNumberRequests[0].searchParams.get('date_to')).toBe(today.toISOString().split('T')[0]); + }); + + it('asks for lifetime totals when the timeframe is all time', async () => { + withTimeframe('all-time'); + setupHandlers(false); + render(); + + await waitFor(() => { + expect(materialNumberRequests.length).toBeGreaterThan(0); + }); + expect(materialNumberRequests[0].searchParams.get('date_from')).toBeNull(); + expect(materialNumberRequests[0].searchParams.get('date_to')).toBeNull(); + }); + + it('drops the widget entirely in Spoolman mode', async () => { + setupHandlers(true); + render(); + + // Wait for the dashboard to be up before asserting on an absence. + await waitFor(() => { + expect(screen.getByText('Statistics')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByText('Filament Trends')).toBeInTheDocument(); + }); + + expect(screen.queryByText('By Material Number')).toBeNull(); + expect(materialNumberRequests).toHaveLength(0); + }); + + // #2870: the mode decision has to wait for the settings response. Deriving + // it from `undefined` treats "not loaded yet" as "internal mode", so a + // Spoolman install flashed the card and fired the aggregate request before + // the setting arrived. + it('holds the widget back until the Spoolman setting has resolved', async () => { + let openGate = () => {}; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + setupHandlers(true, gate); + render(); + + // The dashboard is fully up on the archive response alone. + await waitFor(() => { + expect(screen.getByText('Filament Trends')).toBeInTheDocument(); + }); + expect(screen.queryByText('By Material Number')).toBeNull(); + expect(materialNumberRequests).toHaveLength(0); + + openGate(); + await waitFor(() => { + expect(screen.queryByText('By Material Number')).toBeNull(); + }); + expect(materialNumberRequests).toHaveLength(0); + }); + + it('shows the widget once the setting says this is not Spoolman mode', async () => { + let openGate = () => {}; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + setupHandlers(false, gate); + render(); + + await waitFor(() => { + expect(screen.getByText('Filament Trends')).toBeInTheDocument(); + }); + expect(screen.queryByText('By Material Number')).toBeNull(); + + openGate(); + await waitFor(() => { + expect(screen.getByText('By Material Number')).toBeInTheDocument(); + }); + await waitFor(() => { + expect(materialNumberRequests.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4358ceb87..e67656474 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3658,6 +3658,9 @@ export interface InventorySpool { // User-defined category + per-spool low-stock threshold override (#729). category: string | null; low_stock_threshold_pct: number | null; + // Internal material / article number (#2870) — the purchasing identifier + // shared by all spools of the same product. + material_number: string | null; k_profiles?: SpoolKProfile[]; storage_location?: string | null; location_id?: number | null; @@ -3718,6 +3721,15 @@ export interface SupplierStats { cost: number; } +/** Per-material-number inventory aggregate (#2870). */ +export interface MaterialNumberStats { + material_number: string; + spool_count: number; + remaining_g: number; + consumed_g: number; + cost: number; +} + export interface SpoolmanBulkCreateResult { created: InventorySpool[]; requested_count: number; @@ -6798,6 +6810,15 @@ export const api = { method: 'PATCH', body: JSON.stringify(data), }), + // Per-material-number inventory aggregate (#2870). The date range narrows + // the usage half only — stock is point-in-time. + getMaterialNumberStats: (options?: { dateFrom?: string; dateTo?: string }) => { + const params = new URLSearchParams(); + if (options?.dateFrom) params.set('date_from', options.dateFrom); + if (options?.dateTo) params.set('date_to', options.dateTo); + const qs = params.toString(); + return request(`/inventory/stats/material-numbers${qs ? `?${qs}` : ''}`); + }, getSpoolUsageHistory: (spoolId: number, limit = 50) => request(`/inventory/spools/${spoolId}/usage?limit=${limit}`), getAllUsageHistory: (limit = 100, printerId?: number) => diff --git a/frontend/src/components/BulkEditSpoolsModal.tsx b/frontend/src/components/BulkEditSpoolsModal.tsx index b0bb0ac54..264975ec6 100644 --- a/frontend/src/components/BulkEditSpoolsModal.tsx +++ b/frontend/src/components/BulkEditSpoolsModal.tsx @@ -26,7 +26,8 @@ type EditableField = | 'label_weight' | 'core_weight' | 'category' - | 'low_stock_threshold_pct'; + | 'low_stock_threshold_pct' + | 'material_number'; type FieldSpec = { id: EditableField; @@ -43,6 +44,11 @@ type FieldSpec = { step?: number; /** Hex pattern for the rgba field. */ pattern?: string; + /** Lives on the internal spool row only. A Spoolman spool has no such + * field, so SpoolmanInventoryUpdate drops it, the payload dumps to {} and + * the route answers 400 "update must include at least one field". Offering + * it in Spoolman mode is offering a button that cannot work. */ + internalOnly?: boolean; }; const FIELDS: FieldSpec[] = [ @@ -58,8 +64,11 @@ const FIELDS: FieldSpec[] = [ { id: 'note', type: 'textarea', labelKey: 'inventory.note' }, { id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 }, { id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 }, - { id: 'category', type: 'searchable', labelKey: 'inventory.category' }, - { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 }, + { id: 'category', type: 'searchable', labelKey: 'inventory.category', internalOnly: true }, + { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1, internalOnly: true }, + // Internal material / article number (#2870) — bulk-assigning it is the + // main way existing inventories get numbered. + { id: 'material_number', type: 'searchable', labelKey: 'inventory.materialNumber', internalOnly: true }, ]; export interface BulkEditSpoolsModalProps { @@ -72,8 +81,11 @@ export interface BulkEditSpoolsModalProps { availableSubtypes: string[]; availableBrands: string[]; availableCategories: string[]; + availableMaterialNumbers: string[]; availableSlicerFilaments: string[]; availableSlicerFilamentNames: string[]; + /** Spoolman-backed inventory: hides the fields Spoolman cannot store. */ + spoolmanMode?: boolean; onClose: () => void; onApply: (patch: Partial>) => void; } @@ -210,10 +222,15 @@ function combineUnique(...lists: string[][]): string[] { export function BulkEditSpoolsModal({ isOpen, selectedCount, isPending, availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories, - availableSlicerFilaments, availableSlicerFilamentNames, + availableMaterialNumbers, availableSlicerFilaments, availableSlicerFilamentNames, + spoolmanMode = false, onClose, onApply, }: BulkEditSpoolsModalProps) { const { t } = useTranslation(); + const fields = useMemo( + () => FIELDS.filter((f) => !(spoolmanMode && f.internalOnly)), + [spoolmanMode], + ); // Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca // + local + built-in). Gated on `isOpen` so closed modal doesn't fetch. @@ -280,6 +297,10 @@ export function BulkEditSpoolsModal({ () => combineUnique(availableCategories).map((m) => ({ value: m, label: m })), [availableCategories], ); + const materialNumberOptions: Option[] = useMemo( + () => combineUnique(availableMaterialNumbers).map((m) => ({ value: m, label: m })), + [availableMaterialNumbers], + ); const slicerFilamentOptions: Option[] = useMemo(() => { // value = preset code (what goes into spool.slicer_filament), // label = display name so the user can find it by name. @@ -317,7 +338,7 @@ export function BulkEditSpoolsModal({ const buildPatch = (): Record => { const patch: Record = {}; - for (const f of FIELDS) { + for (const f of fields) { const raw = values[f.id]; if (raw === undefined) continue; const trimmed = typeof raw === 'string' ? raw.trim() : raw; @@ -345,7 +366,7 @@ export function BulkEditSpoolsModal({ // would be silently dropped from the patch — e.g. a malformed rgba hex. // Without this guard the user clicks Apply, the field is dropped, and the // success toast still fires for the OTHER fields. - const hasDroppedTickedField = FIELDS.some((f) => { + const hasDroppedTickedField = fields.some((f) => { const raw = values[f.id]; if (raw === undefined) return false; if (raw.trim() === '') return false; @@ -357,6 +378,7 @@ export function BulkEditSpoolsModal({ if (id === 'subtype') return subtypeOptions; if (id === 'brand') return brandOptions; if (id === 'category') return categoryOptions; + if (id === 'material_number') return materialNumberOptions; if (id === 'slicer_filament') return slicerFilamentOptions; if (id === 'slicer_filament_name') return slicerFilamentNameOptions; if (id === 'location_id') return locationOptions; @@ -469,7 +491,7 @@ export function BulkEditSpoolsModal({ {t('inventory.bulk.editHint')}

- {FIELDS.map((f) => { + {fields.map((f) => { const enabled = values[f.id] !== undefined; return (
diff --git a/frontend/src/components/ForecastPanel.tsx b/frontend/src/components/ForecastPanel.tsx index 3bb3a0f7e..dbc4d5c57 100644 --- a/frontend/src/components/ForecastPanel.tsx +++ b/frontend/src/components/ForecastPanel.tsx @@ -1281,6 +1281,7 @@ function ShoppingListPanel({ added_full: null, last_used: null, encode_time: null, category: 'Stock', low_stock_threshold_pct: null, + material_number: null, }; await api.bulkCreateSpools(spoolBase, item.quantity_spools); await api.removeFromShoppingList(id); diff --git a/frontend/src/components/MaterialNumberStats.tsx b/frontend/src/components/MaterialNumberStats.tsx new file mode 100644 index 000000000..24bd05f51 --- /dev/null +++ b/frontend/src/components/MaterialNumberStats.tsx @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; +import { api } from '../api/client'; + +// Consumption, cost and stock grouped by the internal material number +// (#2870) — the identifier the business actually purchases and costs by, +// unlike brand+material+colour. Data comes from the dedicated aggregate +// endpoint so archived spools' recorded usage still counts. + +interface MaterialNumberStatsProps { + currency: string; + // Dashboard timeframe. Narrows the consumption/cost columns only — the + // spool count and remaining weight are point-in-time stock. + dateFrom?: string; + dateTo?: string; +} + +function formatGrams(g: number): string { + if (Math.abs(g) >= 1000) return `${(g / 1000).toFixed(2)} kg`; + return `${Math.round(g)} g`; +} + +export function MaterialNumberStats({ currency, dateFrom, dateTo }: MaterialNumberStatsProps) { + const { t } = useTranslation(); + const { data, isLoading, isError } = useQuery({ + queryKey: ['material-number-stats', dateFrom ?? null, dateTo ?? null], + queryFn: () => api.getMaterialNumberStats({ dateFrom, dateTo }), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + // A failed request is not an empty inventory: telling someone who has + // numbered their spools to go and number them (because of a 403 from a + // missing INVENTORY_READ, or a dropped connection) sends them looking for + // a problem that isn't there. + if (isError) { + return

{t('stats.materialNumbers.loadFailed')}

; + } + + if (!data || data.length === 0) { + return

{t('stats.materialNumbers.empty')}

; + } + + return ( +
+ + + + + + + + + + + + {data.map((row) => ( + + + + + + + + ))} + +
{t('inventory.materialNumber')}{t('stats.materialNumbers.spools')}{t('stats.materialNumbers.remaining')}{t('stats.materialNumbers.consumed')}{t('stats.materialNumbers.cost')}
{row.material_number}{row.spool_count}{formatGrams(row.remaining_g)}{formatGrams(row.consumed_g)} + {currency} {row.cost.toFixed(2)} +
+
+ ); +} diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index f401f4ee6..266ba7e90 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -428,6 +428,7 @@ export function SpoolFormModal({ cost_per_kg: spool.cost_per_kg ?? null, category: spool.category || '', low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null, + material_number: spool.material_number || '', location_id: spool.location_id ?? null, spoolman_filament_id: null, }); @@ -786,6 +787,16 @@ export function SpoolFormModal({ } return Array.from(set).sort((a, b) => a.localeCompare(b)); })(); + // Autocomplete for the internal material number (#2870), mirroring the + // category datalist above. + const availableMaterialNumbers = (() => { + const set = new Set(); + for (const s of allSpools ?? []) { + const n = s.material_number?.trim(); + if (n) set.add(n); + } + return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + })(); const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20; const unassignMutation = useMutation({ @@ -968,6 +979,7 @@ export function SpoolFormModal({ cost_per_kg: formData.cost_per_kg, category: formData.category.trim() || null, low_stock_threshold_pct: formData.low_stock_threshold_pct, + material_number: formData.material_number.trim() || null, ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}), }; @@ -1174,6 +1186,7 @@ export function SpoolFormModal({ spoolCatalog={spoolCatalog} currencySymbol={currencySymbol} availableCategories={availableCategories} + availableMaterialNumbers={availableMaterialNumbers} availableLocations={storageLocations} onCreateLocation={async (name) => { try { @@ -1192,6 +1205,7 @@ export function SpoolFormModal({ } }} globalLowStockThreshold={globalLowStockThreshold} + spoolmanMode={spoolmanMode} />
diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index c171ea343..490d612b9 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -174,9 +174,11 @@ export function AdditionalSection({ spoolCatalog, currencySymbol, availableCategories, + availableMaterialNumbers, availableLocations = [], onCreateLocation, globalLowStockThreshold, + spoolmanMode = false, }: AdditionalSectionProps) { const { t } = useTranslation(); const { showToast } = useToast(); @@ -318,6 +320,33 @@ export function AdditionalSection({
+ {/* Material number (#2870). Hidden in Spoolman mode: there the number + is Spoolman's filament-level article_number, maintained in Spoolman + itself and surfaced read-only in the list. */} + {!spoolmanMode && ( +
+ + updateField('material_number', e.target.value)} + /> + {availableMaterialNumbers.length > 0 && ( + + {availableMaterialNumbers.map((n) => + )} +

{t('inventory.materialNumberHelp')}

+
+ )} + {/* Category (#729) */}
) : ( diff --git a/frontend/src/utils/inventorySearch.ts b/frontend/src/utils/inventorySearch.ts index a797bac1c..310ad39a2 100644 --- a/frontend/src/utils/inventorySearch.ts +++ b/frontend/src/utils/inventorySearch.ts @@ -35,6 +35,7 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean (spool.note?.toLowerCase().includes(q) ?? false) || (spool.slicer_filament_name?.toLowerCase().includes(q) ?? false) || (spool.storage_location?.toLowerCase().includes(q) ?? false) || + (spool.material_number?.toLowerCase().includes(q) ?? false) || (spool.suppliers?.some( (link) => link.supplier_name.toLowerCase().includes(q) ||