Преглед изворни кода

Suppliers as a managed list with per-spool assignments (#2996)

Thomansky пре 3 дана
родитељ
комит
053cfa73ad
46 измењених фајлова са 4843 додато и 54 уклоњено
  1. 393 18
      backend/app/api/routes/inventory.py
  2. 115 0
      backend/app/api/routes/spoolman_inventory.py
  3. 205 0
      backend/app/core/database.py
  4. 4 0
      backend/app/models/__init__.py
  5. 6 0
      backend/app/models/spool.py
  6. 133 0
      backend/app/models/supplier.py
  7. 8 0
      backend/app/schemas/spool.py
  8. 112 0
      backend/app/schemas/supplier.py
  9. 64 0
      backend/app/services/spool_csv.py
  10. 8 0
      backend/app/services/spool_tag_matcher.py
  11. 106 0
      backend/app/services/supplier_links.py
  12. 926 0
      backend/tests/integration/test_suppliers_api.py
  13. 45 27
      backend/tests/unit/test_bulk_spool_create.py
  14. 277 0
      backend/tests/unit/test_supplier_tables_migration.py
  15. 104 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  16. 154 0
      frontend/src/__tests__/components/SupplierSection.test.tsx
  17. 70 0
      frontend/src/__tests__/components/SupplierStats.test.tsx
  18. 163 0
      frontend/src/__tests__/components/SuppliersModal.test.tsx
  19. 3 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  20. 4 3
      frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx
  21. 144 0
      frontend/src/__tests__/pages/StatsPageSuppliers.test.tsx
  22. 85 0
      frontend/src/api/client.ts
  23. 82 0
      frontend/src/components/SpoolFormModal.tsx
  24. 82 0
      frontend/src/components/SupplierStats.tsx
  25. 386 0
      frontend/src/components/SuppliersModal.tsx
  26. 255 0
      frontend/src/components/spool-form/SupplierSection.tsx
  27. 2 1
      frontend/src/hooks/useWebSocket.ts
  28. 53 0
      frontend/src/i18n/locales/de.ts
  29. 53 0
      frontend/src/i18n/locales/en.ts
  30. 53 0
      frontend/src/i18n/locales/es.ts
  31. 51 0
      frontend/src/i18n/locales/fr.ts
  32. 51 0
      frontend/src/i18n/locales/it.ts
  33. 53 0
      frontend/src/i18n/locales/ja.ts
  34. 51 0
      frontend/src/i18n/locales/ko.ts
  35. 53 0
      frontend/src/i18n/locales/nl.ts
  36. 51 0
      frontend/src/i18n/locales/pt-BR.ts
  37. 51 0
      frontend/src/i18n/locales/ru.ts
  38. 51 0
      frontend/src/i18n/locales/sv.ts
  39. 51 0
      frontend/src/i18n/locales/tr.ts
  40. 51 0
      frontend/src/i18n/locales/uk.ts
  41. 51 0
      frontend/src/i18n/locales/zh-CN.ts
  42. 51 0
      frontend/src/i18n/locales/zh-TW.ts
  43. 96 4
      frontend/src/pages/InventoryPage.tsx
  44. 23 0
      frontend/src/pages/StatsPage.tsx
  45. 7 0
      frontend/src/utils/inventoryQueries.ts
  46. 6 1
      frontend/src/utils/inventorySearch.ts

+ 393 - 18
backend/app/api/routes/inventory.py

@@ -1,11 +1,12 @@
 import json
 import logging
+from datetime import date, datetime, time, timezone
 
 import httpx
 from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
 from fastapi.responses import Response, StreamingResponse
 from pydantic import BaseModel, Field, field_validator
-from sqlalchemy import delete, func, select
+from sqlalchemy import case, delete, func, select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -28,6 +29,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
 from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.spool import (
@@ -45,6 +47,14 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.schemas.supplier import (
+    SpoolSupplierLinkInput,
+    SpoolSupplierResponse,
+    SupplierCreate,
+    SupplierResponse,
+    SupplierStats,
+    SupplierUpdate,
+)
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
     DUPLICATE_LOCATION_NAME,
@@ -67,6 +77,7 @@ from backend.app.services.spool_csv import (
 )
 from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
+from backend.app.services.supplier_links import apply_supplier_inheritance, apply_supplier_inheritance_to_batch
 from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -83,6 +94,20 @@ _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
 
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 
+
+def spool_response_loads():
+    """Loader options for every query that answers with a ``SpoolResponse``.
+
+    Both relationships the schema reads carry the default loader, so the ~50
+    other ``select(Spool)`` call sites across the usage tracker, AMS sync,
+    labels and backup pay nothing for them (#2988).
+    """
+    return (
+        selectinload(Spool.k_profiles),
+        selectinload(Spool.supplier_links).selectinload(SpoolSupplier.supplier),
+    )
+
+
 # Bounded read size for the CSV import body so a chunked upload with no
 # Content-Length can't stream past the cap into memory before we notice.
 _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
@@ -759,6 +784,182 @@ async def delete_location(
     return {"status": "deleted"}
 
 
+# ── Supplier master list (#2988) ───────────────────────────────────────────
+#
+# Inventory master data that spools reference, exactly like Locations — so it
+# lives here, gated by the inventory permissions, not behind its own
+# permission set or a Settings page. Suppliers are *where filament is bought*,
+# distinct from ``Spool.brand`` (who made it).
+
+
+DUPLICATE_SUPPLIER_NAME = "A supplier with this name already exists"
+
+
+async def _supplier_by_name(db: AsyncSession, name: str, *, exclude_id: int | None = None) -> Supplier | None:
+    """Case-insensitive name lookup behind the duplicate guard (#2988).
+
+    Matches on the stored ``name_key``, so the comparison is the Python fold
+    the CSV import also uses — ``func.lower()`` would have folded ASCII only
+    on SQLite and let an umlaut'd case variant past the check.
+    """
+    query = select(Supplier).where(Supplier.name_key == supplier_name_key(name))
+    if exclude_id is not None:
+        query = query.where(Supplier.id != exclude_id)
+    return (await db.execute(query)).scalars().first()
+
+
+async def _supplier_reference_counts(db: AsyncSession) -> dict[int, int]:
+    """Spools referencing each supplier, across BOTH inventories.
+
+    The delete guard has to see Spoolman-mode assignments too — a supplier
+    used only by Spoolman spools must not be deletable just because the
+    built-in inventory has no reference to it.
+    """
+    counts: dict[int, int] = {}
+    for model in (SpoolSupplier, SpoolmanSpoolSupplier):
+        result = await db.execute(select(model.supplier_id, func.count(model.id)).group_by(model.supplier_id))
+        for supplier_id, count in result.all():
+            counts[supplier_id] = counts.get(supplier_id, 0) + count
+    return counts
+
+
+async def _prune_orphaned_spoolman_supplier_rows(db: AsyncSession) -> int:
+    """Drop twin rows whose Spoolman spool no longer exists (#2988).
+
+    ``_purge_local_rows_for_spool`` covers the deletes Bambuddy performs, but
+    Spoolman is a separate application with its own UI: a spool deleted there
+    — or a Spoolman instance that was rebuilt or replaced — leaves
+    ``spoolman_spool_suppliers`` rows behind that keep the supplier's
+    reference count non-zero, and nothing in Bambuddy can show or remove the
+    phantom reference. Without this, that 409 is permanent.
+
+    Reconciled on the delete attempt rather than on every listing: it costs
+    one Spoolman call, and only the route that is about to refuse needs the
+    answer. Archived spools count as live — archiving is a soft delete and the
+    assignment has to survive it. Returns the number of rows removed; 0 when
+    Spoolman is off or unreachable, which leaves the 409 standing rather than
+    dropping rows on the strength of a failed lookup.
+    """
+    settings = await _load_settings_map(db)
+    if not _spoolman_is_enabled(settings):
+        return 0
+    local_ids = set((await db.execute(select(SpoolmanSpoolSupplier.spoolman_spool_id).distinct())).scalars().all())
+    if not local_ids:
+        return 0
+    client = await _ensure_spoolman_client(settings)
+    if not client:
+        return 0
+    try:
+        spools = await client.get_all_spools(allow_archived=True)
+    except Exception:
+        logger.warning("Failed to fetch Spoolman spools to reconcile supplier assignments", exc_info=True)
+        return 0
+    stale = local_ids - {s.get("id") for s in spools if isinstance(s, dict)}
+    if not stale:
+        return 0
+    await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(stale)))
+    await db.commit()
+    logger.info("Dropped supplier assignments for %d Spoolman spool(s) that no longer exist", len(stale))
+    return len(stale)
+
+
+@router.get("/suppliers", response_model=list[SupplierResponse])
+async def list_suppliers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List all suppliers with their spool-usage counts."""
+    # name_key, not name: the list should read the way it looks, so "extrudr"
+    # sorts next to "Extrudr" rather than after "Zultrat".
+    result = await db.execute(select(Supplier).order_by(Supplier.name_key))
+    suppliers = result.scalars().all()
+    counts = await _supplier_reference_counts(db)
+    responses = []
+    for supplier in suppliers:
+        response = SupplierResponse.model_validate(supplier)
+        response.spool_count = counts.get(supplier.id, 0)
+        responses.append(response)
+    return responses
+
+
+@router.post("/suppliers", response_model=SupplierResponse, status_code=201)
+async def create_supplier(
+    data: SupplierCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Create a supplier (mirrors create_location, duplicate name included)."""
+    if await _supplier_by_name(db, data.name):
+        raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME)
+    supplier = Supplier(**data.model_dump())
+    db.add(supplier)
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        # The unique index behind the check above, for the concurrent case.
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) from exc
+    await db.refresh(supplier)
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return SupplierResponse.model_validate(supplier)
+
+
+@router.patch("/suppliers/{supplier_id}", response_model=SupplierResponse)
+async def update_supplier(
+    supplier_id: int,
+    data: SupplierUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update a supplier."""
+    result = await db.execute(select(Supplier).where(Supplier.id == supplier_id))
+    supplier = result.scalar_one_or_none()
+    if not supplier:
+        raise HTTPException(status_code=404, detail="Supplier not found")
+
+    fields = data.model_dump(exclude_unset=True)
+    if "name" in fields and await _supplier_by_name(db, fields["name"], exclude_id=supplier_id):
+        raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME)
+    for field, value in fields.items():
+        setattr(supplier, field, value)
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) from exc
+    await db.refresh(supplier)
+    await ws_manager.broadcast({"type": "inventory_changed"})
+
+    response = SupplierResponse.model_validate(supplier)
+    response.spool_count = (await _supplier_reference_counts(db)).get(supplier.id, 0)
+    return response
+
+
+@router.delete("/suppliers/{supplier_id}")
+async def delete_supplier(
+    supplier_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete a supplier when no spools reference it (mirrors delete_location)."""
+    result = await db.execute(select(Supplier).where(Supplier.id == supplier_id))
+    supplier = result.scalar_one_or_none()
+    if not supplier:
+        raise HTTPException(status_code=404, detail="Supplier not found")
+
+    if (await _supplier_reference_counts(db)).get(supplier_id, 0) > 0:
+        # Last chance before refusing: the reference may be a Spoolman spool
+        # that was deleted in Spoolman itself, which Bambuddy never hears about.
+        await _prune_orphaned_spoolman_supplier_rows(db)
+        if (await _supplier_reference_counts(db)).get(supplier_id, 0) > 0:
+            raise HTTPException(status_code=409, detail="Supplier has spools assigned and cannot be deleted")
+
+    await db.delete(supplier)
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"status": "deleted"}
+
+
 # ── Color Catalog CRUD ─────────────────────────────────────────────────────
 
 
@@ -1180,7 +1381,7 @@ async def list_spools(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """List all spools, excluding archived by default."""
-    query = select(Spool).options(selectinload(Spool.k_profiles))
+    query = select(Spool).options(*spool_response_loads())
     if not include_archived:
         query = query.where(Spool.archived_at.is_(None))
     query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
@@ -1201,7 +1402,13 @@ async def export_spools_csv(
     """Export the active inventory as CSV (same schema the importer accepts)."""
     from datetime import datetime, timezone
 
-    query = select(Spool).where(Spool.archived_at.is_(None)).order_by(Spool.material, Spool.brand, Spool.color_name)
+    query = (
+        select(Spool)
+        # The supplier columns (#2988) read the assignments off each row.
+        .options(selectinload(Spool.supplier_links).selectinload(SpoolSupplier.supplier))
+        .where(Spool.archived_at.is_(None))
+        .order_by(Spool.material, Spool.brand, Spool.color_name)
+    )
     result = await db.execute(query)
     spools = list(result.scalars().all())
     content = serialize(spools)
@@ -1259,7 +1466,20 @@ async def import_spools_csv(
     created = 0
     for row in preview.rows:
         if row.status == "valid" and row.spool is not None:
-            db.add(Spool(**row.spool))
+            spool = Spool(**row.spool)
+            db.add(spool)
+            # Supplier assignments resolved by name during parsing (#2988).
+            # Flush first so the spool has an id to hang the links on.
+            if row.supplier_ids:
+                await db.flush()
+                for supplier_id in row.supplier_ids:
+                    db.add(
+                        SpoolSupplier(
+                            spool_id=spool.id,
+                            supplier_id=supplier_id,
+                            is_purchase_source=supplier_id == row.purchase_supplier_id,
+                        )
+                    )
             created += 1
 
     if created:
@@ -1299,7 +1519,7 @@ async def get_spool_by_tag(
     if not normalized_tray_uuid and not normalized_tag_uid:
         raise HTTPException(400, "Provide tray_uuid and/or tag_uid")
 
-    base_query = select(Spool).options(selectinload(Spool.k_profiles))
+    base_query = select(Spool).options(*spool_response_loads())
     if not include_archived:
         base_query = base_query.where(Spool.archived_at.is_(None))
 
@@ -1324,7 +1544,7 @@ async def get_spool(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Get a single spool with k_profiles."""
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
     if not spool:
         raise HTTPException(404, "Spool not found")
@@ -1344,9 +1564,13 @@ async def create_spool(
         raise HTTPException(status_code=400, detail=str(exc)) from exc
     spool = Spool(**payload)
     db.add(spool)
+    await db.flush()
+    # A new spool of a product that already carries supplier assignments
+    # inherits the source list (#2988).
+    await apply_supplier_inheritance(db, spool)
     await db.commit()
     await db.refresh(spool)
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool.id))
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
@@ -1368,9 +1592,12 @@ async def bulk_create_spools(
         spool = Spool(**payload)
         db.add(spool)
         spools.append(spool)
+    await db.flush()
+    # Every copy gets its own link rows, from one donor lookup (#2988).
+    await apply_supplier_inheritance_to_batch(db, spools)
     await db.commit()
     ids = [s.id for s in spools]
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id.in_(ids)))
     await ws_manager.broadcast({"type": "inventory_changed"})
     return list(result.scalars().all())
 
@@ -1401,7 +1628,7 @@ async def update_spool(
         setattr(spool, field, value)
 
     await db.commit()
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
@@ -1440,7 +1667,7 @@ async def archive_spool(
 
     spool.archived_at = datetime.now(timezone.utc)
     await db.commit()
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
@@ -1459,7 +1686,7 @@ async def restore_spool(
 
     spool.archived_at = None
     await db.commit()
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
@@ -1491,7 +1718,7 @@ async def reset_spool_consumed_counter(
 
     spool.weight_used_baseline = spool.weight_used or 0
     await db.commit()
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
 
@@ -1689,6 +1916,56 @@ async def replace_k_profiles(
     return new_profiles
 
 
+@router.put("/spools/{spool_id}/suppliers", response_model=list[SpoolSupplierResponse])
+async def replace_spool_suppliers(
+    spool_id: int,
+    links: list[SpoolSupplierLinkInput],
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Replace a spool's supplier assignments (#2988, batch save).
+
+    Same replace-all shape as the k-profiles endpoint. At most one assignment
+    may be the purchase source — the record of where this concrete spool was
+    actually bought; the rest read as alternative sources.
+    """
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Spool not found")
+
+    supplier_ids = [link.supplier_id for link in links]
+    if len(set(supplier_ids)) != len(supplier_ids):
+        raise HTTPException(400, "Duplicate supplier in assignment list")
+    if sum(1 for link in links if link.is_purchase_source) > 1:
+        raise HTTPException(400, "Only one assignment can be the purchase source")
+    if supplier_ids:
+        found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids)))
+        missing = set(supplier_ids) - {row[0] for row in found.all()}
+        if missing:
+            raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}")
+
+    existing = await db.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == spool_id))
+    for old in existing.scalars().all():
+        await db.delete(old)
+    await db.flush()
+
+    new_links = []
+    for link in links:
+        row = SpoolSupplier(spool_id=spool_id, **link.model_dump())
+        db.add(row)
+        new_links.append(row)
+
+    await db.commit()
+    refreshed = await db.execute(
+        select(SpoolSupplier)
+        .options(selectinload(SpoolSupplier.supplier))
+        .where(SpoolSupplier.id.in_([row.id for row in new_links]))
+        .order_by(SpoolSupplier.id)
+    )
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return list(refreshed.scalars().all())
+
+
 @router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
 async def list_filament_presets(
     spool_id: int,
@@ -1769,7 +2046,7 @@ async def list_assignments(
     from backend.app.services.printer_manager import printer_manager
 
     query = select(SpoolAssignment).options(
-        selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+        selectinload(SpoolAssignment.spool).options(*spool_response_loads()),
         selectinload(SpoolAssignment.printer),
     )
     if printer_id is not None:
@@ -1834,7 +2111,7 @@ async def assign_spool(
     from backend.app.services.printer_manager import printer_manager
 
     # 1. Validate spool exists and is not archived
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == data.spool_id))
     spool = result.scalar_one_or_none()
     if not spool:
         raise HTTPException(404, "Spool not found")
@@ -2013,7 +2290,7 @@ async def assign_spool(
     result = await db.execute(
         select(SpoolAssignment)
         .options(
-            selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+            selectinload(SpoolAssignment.spool).options(*spool_response_loads()),
             selectinload(SpoolAssignment.printer),
         )
         .where(SpoolAssignment.id == assignment.id)
@@ -2118,7 +2395,7 @@ async def link_tag_to_spool(
     ``tag_already_linked`` 409, which names that spool so a caller can offer
     to move the tag instead of only reporting that it is taken (#3110).
     """
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
     if not spool:
         raise HTTPException(404, "Spool not found")
@@ -2199,7 +2476,7 @@ async def link_tag_to_spool(
         spool.data_origin = data.data_origin
 
     await db.commit()
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id))
     return result.scalar_one()
 
 
@@ -2230,6 +2507,94 @@ async def get_spool_usage_history(
     return list(result.scalars().all())
 
 
+@router.get("/stats/suppliers", response_model=list[SupplierStats])
+async def get_supplier_stats(
+    date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
+    date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Aggregate the inventory by purchase-source supplier (#2988).
+
+    Groups by the supplier a spool was actually bought from (the
+    ``is_purchase_source`` assignment), so "how much did we run through
+    supplier X" reads directly. Stock comes from active spools; consumption
+    and cost from the recorded usage history, archived spools included —
+    their consumption happened. Sorted by consumption, heaviest first, then by name.
+
+    ``date_from`` / ``date_to`` scope the usage half only, so the widget can
+    honour the dashboard timeframe like every other one on that page. Stock is
+    point-in-time by nature and is never windowed.
+    """
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    purchase_link = (SpoolSupplier.spool_id == Spool.id) & SpoolSupplier.is_purchase_source.is_(True)
+    usage_window = []
+    if date_from:
+        usage_window.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+    if date_to:
+        usage_window.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+
+    remaining = Spool.label_weight - Spool.weight_used
+    inventory_rows = await db.execute(
+        select(
+            SpoolSupplier.supplier_id,
+            func.count(Spool.id),
+            # Clamp each spool before summing: an over-consumed spool counts as
+            # empty, not as negative stock that eats into its neighbours.
+            func.sum(case((remaining > 0, remaining), else_=0.0)),
+        )
+        .select_from(Spool)
+        .join(SpoolSupplier, purchase_link)
+        .where(Spool.archived_at.is_(None))
+        .group_by(SpoolSupplier.supplier_id)
+    )
+
+    usage_rows = await db.execute(
+        select(
+            SpoolSupplier.supplier_id,
+            func.sum(SpoolUsageHistory.weight_used),
+            func.sum(SpoolUsageHistory.cost),
+        )
+        .select_from(SpoolUsageHistory)
+        .join(Spool, SpoolUsageHistory.spool_id == Spool.id)
+        .join(SpoolSupplier, purchase_link)
+        .where(*usage_window)
+        .group_by(SpoolSupplier.supplier_id)
+    )
+
+    names = dict((await db.execute(select(Supplier.id, Supplier.name))).all())
+
+    stats: dict[int, SupplierStats] = {}
+    for supplier_id, count, remaining_g in inventory_rows.all():
+        stats[supplier_id] = SupplierStats(
+            supplier_id=supplier_id,
+            supplier_name=names.get(supplier_id, f"#{supplier_id}"),
+            spool_count=count,
+            remaining_g=float(remaining_g or 0),
+            consumed_g=0.0,
+            cost=0.0,
+        )
+    for supplier_id, consumed, cost in usage_rows.all():
+        entry = stats.get(supplier_id)
+        if entry is None:
+            entry = SupplierStats(
+                supplier_id=supplier_id,
+                supplier_name=names.get(supplier_id, f"#{supplier_id}"),
+                spool_count=0,
+                remaining_g=0.0,
+                consumed_g=0.0,
+                cost=0.0,
+            )
+            stats[supplier_id] = entry
+        entry.consumed_g = float(consumed or 0)
+        entry.cost = float(cost or 0)
+
+    # Name breaks ties: a narrow date range leaves most rows at 0 g consumed,
+    # and those would otherwise come back in GROUP BY order.
+    return sorted(stats.values(), key=lambda s: (-s.consumed_g, supplier_name_key(s.supplier_name)))
+
+
 @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
 async def get_all_usage_history(
     limit: int = 100,
@@ -2717,5 +3082,15 @@ async def create_spool_from_slot(
             "spool_id": spool.id,
         }
     )
-    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    # populate_existing because `spool` is the same identity-mapped instance
+    # create_spool_from_tray built: it pre-initialises `supplier_links` to []
+    # so the flush can't lazy-load it, and SQLAlchemy will not overwrite an
+    # already-loaded collection on a plain re-select — the inherited
+    # assignments (#2988) would be in the table but missing from the response.
+    result = await db.execute(
+        select(Spool)
+        .options(*spool_response_loads())
+        .where(Spool.id == spool.id)
+        .execution_options(populate_existing=True)
+    )
     return result.scalar_one()

+ 115 - 0
backend/app/api/routes/spoolman_inventory.py

@@ -41,9 +41,11 @@ from backend.app.models.settings import Settings
 from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
     maybe_sync_spoolman_locations,
@@ -476,6 +478,19 @@ async def list_spools(
         for m in mapped:
             m["k_profiles"] = kp_by_spool.get(m["id"], [])
 
+        # Supplier assignments (#2988) live Bambuddy-side even for Spoolman
+        # spools, so the list carries them in both modes identically.
+        link_result = await db.execute(
+            select(SpoolmanSpoolSupplier)
+            .options(selectinload(SpoolmanSpoolSupplier.supplier))
+            .where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(spool_ids))
+        )
+        links_by_spool: dict[int, list[dict]] = {}
+        for link in link_result.scalars().all():
+            links_by_spool.setdefault(link.spoolman_spool_id, []).append(_supplier_link_to_dict(link))
+        for m in mapped:
+            m["suppliers"] = links_by_spool.get(m["id"], [])
+
     await enrich_spool_dicts_with_location_id(db, mapped)
     return mapped
 
@@ -498,6 +513,12 @@ async def get_spool(
 
     kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
     mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
+    link_result = await db.execute(
+        select(SpoolmanSpoolSupplier)
+        .options(selectinload(SpoolmanSpoolSupplier.supplier))
+        .where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)
+    )
+    mapped["suppliers"] = [_supplier_link_to_dict(link) for link in link_result.scalars().all()]
     await enrich_spool_dicts_with_location_id(db, [mapped])
     return mapped
 
@@ -902,6 +923,22 @@ async def update_spool(
     return _map_spoolman_spool(updated)
 
 
+async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
+    """Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind.
+
+    Spoolman owns the spool; Bambuddy owns the K profiles, the filament preset
+    overrides and the supplier assignments, each keyed by the remote id with no
+    foreign key that could cascade. The K-profile and preset leaks are inert,
+    but a leaked ``spoolman_spool_suppliers`` row keeps the supplier's
+    reference count non-zero, so deleting that supplier answers 409 forever
+    with no way for the user to find the phantom reference (#2988).
+
+    The caller commits.
+    """
+    for model in (SpoolmanKProfile, SpoolmanFilamentPreset, SpoolmanSpoolSupplier):
+        await db.execute(delete(model).where(model.spoolman_spool_id == spool_id))
+
+
 @router.delete("/spools/{spool_id}")
 async def delete_spool(
     spool_id: int = Path(..., gt=0),
@@ -912,6 +949,8 @@ async def delete_spool(
     client = await _get_client(db)
     async with _translate_spoolman_errors():
         await client.delete_spool(spool_id)
+    await _purge_local_rows_for_spool(db, spool_id)
+    await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
     return {"status": "deleted"}
 
@@ -1042,6 +1081,7 @@ async def bulk_delete_spools(
         try:
             async with _translate_spoolman_errors():
                 await client.delete_spool(sid)
+            await _purge_local_rows_for_spool(db, sid)
             deleted += 1
         except HTTPException as exc:
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
@@ -1049,6 +1089,7 @@ async def bulk_delete_spools(
             logger.exception("Spoolman bulk-delete failed for spool %s", sid)
             errors.append({"id": sid, "status": 500, "detail": str(exc)})
     if deleted:
+        await db.commit()
         await ws_manager.broadcast({"type": "inventory_changed"})
     return {"deleted": deleted, "errors": errors}
 
@@ -2149,3 +2190,77 @@ async def save_spoolman_k_profiles(
         await db.refresh(obj)
 
     return [_k_profile_to_dict(p) for p in saved]
+
+
+def _supplier_link_to_dict(link: SpoolmanSpoolSupplier) -> dict:
+    """Same shape as ``SpoolSupplierResponse`` so the frontend renders both
+    inventories with one component."""
+    return {
+        "id": link.id,
+        "supplier_id": link.supplier_id,
+        "supplier_name": link.supplier_name,
+        "supplier_article_number": link.supplier_article_number,
+        "quoted_price_per_kg": link.quoted_price_per_kg,
+        "is_purchase_source": link.is_purchase_source,
+    }
+
+
+@router.get("/spools/{spool_id}/suppliers")
+async def get_spoolman_spool_suppliers(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[dict]:
+    """Supplier assignments for a Spoolman spool (#2988, Bambuddy-side rows)."""
+    await _get_client(db)
+    result = await db.execute(
+        select(SpoolmanSpoolSupplier)
+        .options(selectinload(SpoolmanSpoolSupplier.supplier))
+        .where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)
+    )
+    return [_supplier_link_to_dict(link) for link in result.scalars().all()]
+
+
+@router.put("/spools/{spool_id}/suppliers")
+async def save_spoolman_spool_suppliers(
+    spool_id: int = Path(..., gt=0),
+    links: list[SpoolSupplierLinkInput] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> list[dict]:
+    """Replace a Spoolman spool's supplier assignments (#2988).
+
+    Mirror of the built-in inventory's replace-all endpoint — Spoolman owns
+    the spool, Bambuddy owns the assignment (``SpoolmanKProfile`` precedent),
+    so the rows are local and the spool is only verified to exist remotely.
+    """
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        await client.get_spool(spool_id)
+
+    supplier_ids = [link.supplier_id for link in links]
+    if len(set(supplier_ids)) != len(supplier_ids):
+        raise HTTPException(400, "Duplicate supplier in assignment list")
+    if sum(1 for link in links if link.is_purchase_source) > 1:
+        raise HTTPException(400, "Only one assignment can be the purchase source")
+    if supplier_ids:
+        found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids)))
+        missing = set(supplier_ids) - {row[0] for row in found.all()}
+        if missing:
+            raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}")
+
+    await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id))
+    saved: list[SpoolmanSpoolSupplier] = []
+    for link in links:
+        row = SpoolmanSpoolSupplier(spoolman_spool_id=spool_id, **link.model_dump())
+        db.add(row)
+        saved.append(row)
+    await db.commit()
+    refreshed = await db.execute(
+        select(SpoolmanSpoolSupplier)
+        .options(selectinload(SpoolmanSpoolSupplier.supplier))
+        .where(SpoolmanSpoolSupplier.id.in_([row.id for row in saved]))
+        .order_by(SpoolmanSpoolSupplier.id)
+    )
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return [_supplier_link_to_dict(link) for link in refreshed.scalars().all()]

+ 205 - 0
backend/app/core/database.py

@@ -5076,6 +5076,10 @@ async def run_migrations(conn):
     # on fresh installs only — this covers databases whose table predates it.
     await _migrate_location_ha_sensor_unique_binding(conn)
 
+    # Migration: supplier master list + spool assignments (#2988).
+    # create_all() covers fresh installs; this covers upgrades.
+    await _migrate_create_supplier_tables(conn)
+
     # 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.
@@ -5145,6 +5149,207 @@ async def _migrate_confirm_prompt_body_template(conn) -> None:
     )
 
 
+async def _migrate_create_supplier_tables(conn) -> None:
+    """Create the supplier tables on databases that predate #2988.
+
+    ``Base.metadata.create_all()`` covers fresh installs; upgrades get the
+    tables here, following the ``_migrate_create_finance_tables`` shape.
+    ``spool_suppliers`` deliberately has NO ON DELETE CASCADE on the supplier
+    side — the API refuses to delete a referenced supplier (409) so
+    assignments can never silently orphan.
+    """
+    if is_sqlite():
+        statements = [
+            """
+            CREATE TABLE IF NOT EXISTS suppliers (
+                id INTEGER PRIMARY KEY,
+                name VARCHAR(200) NOT NULL,
+                name_key VARCHAR(200) NOT NULL,
+                website VARCHAR(500),
+                customer_number VARCHAR(100),
+                note VARCHAR(500),
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS spool_suppliers (
+                id INTEGER PRIMARY KEY,
+                spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
+                supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
+                supplier_article_number VARCHAR(100),
+                quoted_price_per_kg FLOAT,
+                is_purchase_source BOOLEAN NOT NULL DEFAULT 0,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers (
+                id INTEGER PRIMARY KEY,
+                spoolman_spool_id INTEGER NOT NULL,
+                supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
+                supplier_article_number VARCHAR(100),
+                quoted_price_per_kg FLOAT,
+                is_purchase_source BOOLEAN NOT NULL DEFAULT 0,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id)
+            )
+            """,
+        ]
+    else:
+        statements = [
+            """
+            CREATE TABLE IF NOT EXISTS suppliers (
+                id SERIAL PRIMARY KEY,
+                name VARCHAR(200) NOT NULL,
+                name_key VARCHAR(200) NOT NULL,
+                website VARCHAR(500),
+                customer_number VARCHAR(100),
+                note VARCHAR(500),
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS spool_suppliers (
+                id SERIAL PRIMARY KEY,
+                spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
+                supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
+                supplier_article_number VARCHAR(100),
+                quoted_price_per_kg FLOAT,
+                is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers (
+                id SERIAL PRIMARY KEY,
+                spoolman_spool_id INTEGER NOT NULL,
+                supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
+                supplier_article_number VARCHAR(100),
+                quoted_price_per_kg FLOAT,
+                is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id)
+            )
+            """,
+        ]
+    for statement in statements:
+        await _safe_execute(conn, statement)
+    # The model declares index=True on these; fresh installs get them from
+    # create_all(), migrated databases need them spelled out.
+    await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_suppliers_name ON suppliers (name)")
+    await _migrate_supplier_name_key(conn)
+    await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_spool_id ON spool_suppliers (spool_id)")
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_supplier_id ON spool_suppliers (supplier_id)"
+    )
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_spoolman_spool_id"
+        " ON spoolman_spool_suppliers (spoolman_spool_id)",
+    )
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_supplier_id ON spoolman_spool_suppliers (supplier_id)",
+    )
+
+
+async def _migrate_supplier_name_key(conn) -> None:
+    """Backfill ``suppliers.name_key`` and collapse case-insensitive duplicates (#2988).
+
+    The unique index cannot simply be created: a database written by an
+    earlier build of this branch is allowed to hold two suppliers whose names
+    differ only in case, and ``CREATE UNIQUE INDEX`` refuses to build over
+    them. ``_safe_execute`` re-raises that IntegrityError, which would abort
+    startup with no route to recovery from the UI — the same trap
+    ``_migrate_location_ha_sensor_unique_binding`` clears first.
+
+    Colliding rows are merged rather than deleted, because a supplier is
+    referenced: the oldest row wins (it is the one assignments and the CSV
+    import already resolved to), its empty fields are filled from the
+    duplicate, every assignment is re-pointed to it, and only then is the
+    duplicate dropped. An assignment the surviving row already holds for the
+    same spool is dropped instead of re-pointed — the (spool, supplier) pair
+    is unique.
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.supplier import supplier_name_key
+
+    # Only a table written by an earlier build of this branch lacks the column
+    # (the CREATE TABLE above declares it NOT NULL). ADD COLUMN cannot carry
+    # NOT NULL without a default, so it is added nullable, backfilled below and
+    # tightened afterwards where the database can do that in place.
+    await _safe_execute(conn, "ALTER TABLE suppliers ADD COLUMN name_key VARCHAR(200)")
+
+    async with conn.begin_nested():
+        rows = (
+            await conn.execute(
+                text("SELECT id, name, name_key, website, customer_number, note FROM suppliers ORDER BY id")
+            )
+        ).fetchall()
+        kept: dict[str, int] = {}
+        for row in rows:
+            key = supplier_name_key(row.name or "")
+            winner_id = kept.get(key)
+            if winner_id is None:
+                kept[key] = row.id
+                if row.name_key != key:
+                    await conn.execute(
+                        text("UPDATE suppliers SET name_key = :key WHERE id = :id"),
+                        {"key": key, "id": row.id},
+                    )
+                continue
+            params = {"keep": winner_id, "drop": row.id}
+            await conn.execute(
+                text(
+                    "DELETE FROM spool_suppliers WHERE supplier_id = :drop AND spool_id IN "
+                    "(SELECT spool_id FROM spool_suppliers WHERE supplier_id = :keep)"
+                ),
+                params,
+            )
+            await conn.execute(text("UPDATE spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params)
+            await conn.execute(
+                text(
+                    "DELETE FROM spoolman_spool_suppliers WHERE supplier_id = :drop AND spoolman_spool_id IN "
+                    "(SELECT spoolman_spool_id FROM spoolman_spool_suppliers WHERE supplier_id = :keep)"
+                ),
+                params,
+            )
+            await conn.execute(
+                text("UPDATE spoolman_spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params
+            )
+            await conn.execute(
+                text(
+                    "UPDATE suppliers SET website = COALESCE(website, :website), "
+                    "customer_number = COALESCE(customer_number, :customer_number), "
+                    "note = COALESCE(note, :note) WHERE id = :keep"
+                ),
+                {
+                    "website": row.website,
+                    "customer_number": row.customer_number,
+                    "note": row.note,
+                    "keep": winner_id,
+                },
+            )
+            await conn.execute(text("DELETE FROM suppliers WHERE id = :drop"), {"drop": row.id})
+            logger.info("Merged duplicate supplier %r (id=%s) into id=%s", row.name, row.id, winner_id)
+
+    # Superseded by ix_suppliers_name_key: lower(name) folds ASCII only, so it
+    # never enforced the rule for non-ASCII names in the first place.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS uq_suppliers_name_lower")
+    await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_suppliers_name_key ON suppliers (name_key)")
+    # NULLs never collide in a unique index, so the guarantee belongs in the
+    # schema, as create_all() declares it on fresh installs. Every row has a
+    # key by now. SQLite cannot alter a column in place; there the ORM hook on
+    # Supplier.name is what writes it.
+    if not is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE suppliers ALTER COLUMN name_key SET NOT NULL")
+
+
 async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
     """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
 

+ 4 - 0
backend/app/models/__init__.py

@@ -39,6 +39,7 @@ from backend.app.models.spool_filament_preset import SpoolFilamentPreset, Spoolm
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.models.user_email_pref import UserEmailPreference
 from backend.app.models.user_otp_code import UserOTPCode
@@ -90,6 +91,9 @@ __all__ = [
     "SpoolAssignment",
     "SpoolCatalogEntry",
     "SpoolUsageHistory",
+    "Supplier",
+    "SpoolSupplier",
+    "SpoolmanSpoolSupplier",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "SponsorToastState",

+ 6 - 0
backend/app/models/spool.py

@@ -83,9 +83,15 @@ class Spool(Base):
     )
     assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
     location: Mapped["Location | None"] = relationship(back_populates="spools")
+    # Supplier assignments (#2988): where this product can be bought, with
+    # per-assignment article number / price and a purchase-source marker.
+    # Default loader like every other relationship here — the handful of
+    # routes that embed them ask for selectinload() at the query site.
+    supplier_links: Mapped[list["SpoolSupplier"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
 
 
 from backend.app.models.location import Location  # noqa: E402
 from backend.app.models.spool_assignment import SpoolAssignment  # noqa: E402
 from backend.app.models.spool_filament_preset import SpoolFilamentPreset  # noqa: E402
 from backend.app.models.spool_k_profile import SpoolKProfile  # noqa: E402
+from backend.app.models.supplier import SpoolSupplier  # noqa: E402

+ 133 - 0
backend/app/models/supplier.py

@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy import (
+    Boolean,
+    DateTime,
+    Float,
+    ForeignKey,
+    Integer,
+    String,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.spool import Spool
+
+
+def supplier_name_key(name: str) -> str:
+    """Case-insensitive lookup key stored on ``Supplier.name_key``.
+
+    Folded in Python, not in SQL: SQLite's ``lower()`` folds ASCII only, so a
+    unique index on ``lower(name)`` lets “Ökofilament” and “ökofilament” both
+    through — and the CSV import, which folds in Python, then collapses them
+    onto one map entry and resolves to whichever row it built last. Every
+    caller goes through this one function so the two folds cannot drift.
+    """
+    return name.strip().lower()
+
+
+class Supplier(Base):
+    """Managed supplier master list (#2988).
+
+    A supplier is *where filament is bought* — distinct from ``Spool.brand``,
+    which is who made it. One supplier carries many brands, and the same
+    product is available from several suppliers, hence the n:m assignment
+    below instead of a free-text field that drifts in spelling.
+    """
+
+    __tablename__ = "suppliers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(200), index=True)
+    # Case-insensitive uniqueness -- the whole feature keys on the name. The
+    # CSV import resolves a column of names against this table and the master
+    # list is what a rename re-points, so "Extrudr" and "extrudr" must not be
+    # two rows. Stored key rather than a unique index on lower(name), for the
+    # reason in supplier_name_key: only the Python fold is Unicode-correct on
+    # every backend, and it is the same fold the import map uses.
+    name_key: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
+    # Shop / website URL, purely informational.
+    website: Mapped[str | None] = mapped_column(String(500))
+    # The business's own customer number AT this supplier.
+    customer_number: Mapped[str | None] = mapped_column(String(100))
+    note: Mapped[str | None] = mapped_column(String(500))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    spool_links: Mapped[list[SpoolSupplier]] = relationship(back_populates="supplier")
+
+    @validates("name")
+    def _sync_name_key(self, _key: str, value: str) -> str:
+        """Derive name_key from every name write, so no insert path can skip it."""
+        self.name_key = supplier_name_key(value)
+        return value
+
+
+class SpoolSupplier(Base):
+    """Spool-to-supplier assignment with per-assignment attributes (#2988).
+
+    Modelled after ``CostCenterMember`` (surrogate id + UNIQUE pair) rather
+    than a bare association table, because the assignment carries data: the
+    supplier's own article number for the product (NOT the internal material
+    number from #2870), the price at this supplier, and whether this concrete
+    spool was actually bought there — the other rows are alternative sources.
+    """
+
+    __tablename__ = "spool_suppliers"
+    __table_args__ = (UniqueConstraint("spool_id", "supplier_id", name="uq_spool_suppliers_spool_supplier"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
+    supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
+    # The supplier's article number for this product.
+    supplier_article_number: Mapped[str | None] = mapped_column(String(100))
+    # QUOTED price per kg at this supplier, for comparing sources. Named so it
+    # can never be read as actual cost: ``spool.cost_per_kg`` is the cost basis
+    # for every print and is never written from here.
+    quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
+    # True on the assignment this spool was actually purchased from.
+    is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    supplier: Mapped[Supplier] = relationship(back_populates="spool_links")
+    spool: Mapped[Spool] = relationship(back_populates="supplier_links")
+
+    @property
+    def supplier_name(self) -> str:
+        """Flattened for SpoolSupplierResponse. Every route that embeds links
+        chains selectinload() onto ``supplier``, so this never lazy-loads."""
+        return self.supplier.name if self.supplier else ""
+
+
+class SpoolmanSpoolSupplier(Base):
+    """``SpoolSupplier`` for a Spoolman-managed spool.
+
+    Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns the
+    supplier assignment, so the row is local and keyed by the remote spool id
+    with no foreign key to enforce it. Suppliers are Bambuddy-side on purpose —
+    Spoolman's ``vendor`` is the manufacturer, not the seller.
+    """
+
+    __tablename__ = "spoolman_spool_suppliers"
+    __table_args__ = (UniqueConstraint("spoolman_spool_id", "supplier_id", name="uq_spoolman_spool_suppliers_pair"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
+    supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
+    supplier_article_number: Mapped[str | None] = mapped_column(String(100))
+    quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
+    is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    supplier: Mapped[Supplier] = relationship()
+
+    @property
+    def supplier_name(self) -> str:
+        return self.supplier.name if self.supplier else ""

+ 8 - 0
backend/app/schemas/spool.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.supplier import SpoolSupplierResponse
+
 # Visual variant applied to a spool's swatch — purely cosmetic, does not
 # affect MQTT/firmware. Kept independent of `subtype` so users can override
 # the rendering hint without touching Bambu's categorical filament label.
@@ -241,9 +243,15 @@ class SpoolResponse(SpoolBase):
     created_at: datetime
     updated_at: datetime
     k_profiles: list[SpoolKProfileResponse] = []
+    # Supplier assignments (#2988): where this product can be bought, with
+    # per-assignment article number / price and a purchase-source marker.
+    # Reads the ORM relationship `supplier_links`, which every route
+    # answering with this schema loads explicitly (see spool_response_loads).
+    suppliers: list[SpoolSupplierResponse] = Field(default=[], validation_alias="supplier_links")
 
     class Config:
         from_attributes = True
+        populate_by_name = True
 
 
 class SpoolAssignmentCreate(BaseModel):

+ 112 - 0
backend/app/schemas/supplier.py

@@ -0,0 +1,112 @@
+"""Pydantic schemas for the supplier master list (#2988)."""
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field, field_validator
+
+# The CSV `suppliers` column joins names with "; " (#2988). A name carrying
+# the separator would split into unknown names on import and silently drop the
+# assignments, so it is refused at the edge rather than escaped — the column
+# stays readable and hand-editable.
+SUPPLIER_NAME_SEPARATOR = ";"
+
+
+def validate_supplier_name(value: str) -> str:
+    trimmed = value.strip()
+    if not trimmed:
+        raise ValueError("name must not be empty")
+    if SUPPLIER_NAME_SEPARATOR in trimmed:
+        raise ValueError("name must not contain ';' — it separates suppliers in the CSV export")
+    return trimmed
+
+
+class SupplierBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=200)
+    website: str | None = Field(default=None, max_length=500)
+    # The business's own customer number AT this supplier.
+    customer_number: str | None = Field(default=None, max_length=100)
+    note: str | None = Field(default=None, max_length=500)
+
+
+class SupplierCreate(SupplierBase):
+    # Only on the write schemas: SupplierResponse inherits SupplierBase, and a
+    # row written before the rule existed must still be readable. mode="before"
+    # so the name is trimmed before max_length sees it — a padded name at the
+    # cap is not too long once stripped. Non-strings pass through to the type
+    # check.
+    @field_validator("name", mode="before")
+    @classmethod
+    def _normalize_name(cls, value: object) -> object:
+        return validate_supplier_name(value) if isinstance(value, str) else value
+
+
+class SupplierUpdate(BaseModel):
+    # Optional so a PATCH can leave the name alone. An explicit null is a 422
+    # here instead of a NOT NULL violation surfacing as a 500.
+    name: str | None = Field(default=None, min_length=1, max_length=200)
+    website: str | None = Field(default=None, max_length=500)
+    customer_number: str | None = Field(default=None, max_length=100)
+    note: str | None = Field(default=None, max_length=500)
+
+    @field_validator("name", mode="before")
+    @classmethod
+    def _normalize_name(cls, value: object) -> object:
+        if value is None:
+            raise ValueError("name must not be null")
+        return validate_supplier_name(value) if isinstance(value, str) else value
+
+
+class SupplierResponse(SupplierBase):
+    id: int
+    # Number of spools referencing this supplier — shown in the settings list
+    # and the reason a delete is refused (409) instead of orphaning links.
+    spool_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class SpoolSupplierLinkInput(BaseModel):
+    """One spool-to-supplier assignment as written by the spool dialog."""
+
+    supplier_id: int = Field(..., gt=0)
+    # The supplier's own article number for the product — NOT the internal
+    # material number (#2870).
+    supplier_article_number: str | None = Field(default=None, max_length=100)
+    # Quoted price per kg at this supplier, for comparing sources. Never a
+    # cost basis — ``spool.cost_per_kg`` stays authoritative and is not
+    # written from assignments.
+    quoted_price_per_kg: float | None = Field(default=None, ge=0)
+    # Marks where this concrete spool was actually bought; the other
+    # assignments are alternative sources.
+    is_purchase_source: bool = False
+
+
+class SpoolSupplierResponse(BaseModel):
+    id: int
+    supplier_id: int
+    supplier_name: str
+    supplier_article_number: str | None = None
+    quoted_price_per_kg: float | None = None
+    is_purchase_source: bool = False
+
+    class Config:
+        from_attributes = True
+
+
+class SupplierStats(BaseModel):
+    """Per-supplier inventory aggregate (#2988), purchase-source spools only.
+
+    ``spool_count`` and ``remaining_g`` cover active spools bought at this
+    supplier; ``consumed_g`` and ``cost`` sum the recorded usage history of
+    every purchase-source spool, archived included.
+    """
+
+    supplier_id: int
+    supplier_name: str
+    spool_count: int
+    remaining_g: float
+    consumed_g: float
+    cost: float

+ 64 - 0
backend/app/services/spool_csv.py

@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.spool import Spool
+from backend.app.models.supplier import Supplier, supplier_name_key
 from backend.app.schemas.spool import SpoolCreate
 
 # Fixed CSV header, in output order. Round-trips cleanly: export writes these
@@ -54,6 +55,16 @@ CSV_COLUMNS = [
     "storage_location",
     "category",
     "low_stock_threshold_pct",
+    # 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
+    # existing supplier list — trimmed, case-insensitive — and NEVER creates
+    # suppliers; an unknown name is a dry-run warning, not a row error, and
+    # that assignment is dropped. Article number and quoted price stay out of
+    # the CSV: they belong to the assignment, not the spool, and would break
+    # the round-trip.
+    "suppliers",
+    "purchase_supplier",
 ]
 
 # Upload ceiling for the import endpoint. A spool inventory CSV is a few KB
@@ -105,6 +116,11 @@ class ImportRowResult(BaseModel):
     # same CSV doesn't silently duplicate the inventory.
     duplicate_of_existing: bool = False
     spool: dict | None = None
+    # Resolved supplier assignments (#2988): ids matched by name from the
+    # `suppliers` / `purchase_supplier` columns. Unknown names are dropped
+    # with a preview warning — the import never creates suppliers.
+    supplier_ids: list[int] = []
+    purchase_supplier_id: int | None = None
 
 
 class ImportPreview(BaseModel):
@@ -193,6 +209,19 @@ def _spool_key(material: str | None, brand: str | None, color_name: str | None)
     )
 
 
+async def _load_supplier_map(db: AsyncSession) -> dict[str, int]:
+    """Existing suppliers keyed by ``Supplier.name_key`` for CSV matching.
+
+    Import resolves the `suppliers` / `purchase_supplier` columns against
+    this map and never creates suppliers — the master list is curated in the
+    UI, and a typo in a CSV must not silently mint a new supplier. The key is
+    unambiguous because it is the same column the unique index is on, so two
+    names that land on one entry here cannot both exist as rows.
+    """
+    result = await db.execute(select(Supplier.id, Supplier.name_key))
+    return {name_key: supplier_id for supplier_id, name_key in result.all()}
+
+
 async def _load_existing_spool_keys(db: AsyncSession) -> set[tuple[str, str, str]]:
     """Load material+brand+color_name keys of active spools for the dup warning.
 
@@ -306,6 +335,8 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie
     # issuing a SELECT per row.
     catalog = await _load_color_catalog(db)
     existing_keys = await _load_existing_spool_keys(db)
+    supplier_map = await _load_supplier_map(db)
+    unknown_suppliers: set[str] = set()
 
     def cell(row: list[str], field: str) -> str:
         idx = col_index.get(field)
@@ -460,6 +491,30 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie
             # dict so the ORM object carries it.
             spool_data["last_used"] = last_used
 
+        # Supplier assignments (#2988): match names against the existing list,
+        # never create. Unknown names are warnings, not row errors — the row
+        # imports and the unmatched assignment is dropped. The purchase source
+        # counts as an assignment even when the `suppliers` cell omits it.
+        supplier_ids: list[int] = []
+        purchase_supplier_id: int | None = None
+        names = [n.strip() for n in cell(raw_row, "suppliers").split(";") if n.strip()]
+        purchase_name = cell(raw_row, "purchase_supplier").strip()
+        if purchase_name and supplier_name_key(purchase_name) not in {supplier_name_key(n) for n in names}:
+            names.append(purchase_name)
+        for name in names:
+            key = supplier_name_key(name)
+            supplier_id = supplier_map.get(key)
+            if supplier_id is None:
+                # Once per name, not once per row: a 500-row export against an
+                # empty supplier list is one missing supplier, not 500 problems.
+                if key not in unknown_suppliers:
+                    unknown_suppliers.add(key)
+                    warnings.append(f"Unknown supplier '{name}' — assignments dropped")
+            elif supplier_id not in supplier_ids:
+                supplier_ids.append(supplier_id)
+        if purchase_name:
+            purchase_supplier_id = supplier_map.get(supplier_name_key(purchase_name))
+
         rows.append(
             ImportRowResult(
                 row_number=row_number,
@@ -472,6 +527,8 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie
                 cross_material_color=cross_material_color,
                 duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
                 spool=spool_data,
+                supplier_ids=supplier_ids,
+                purchase_supplier_id=purchase_supplier_id,
             )
         )
         valid += 1
@@ -537,6 +594,13 @@ def _cell_value(spool: Spool, col: str) -> str:
     if col == "remaining":
         # Derived for display: label_weight - weight_used, clamped at 0.
         return str(max(0, round((spool.label_weight or 0) - (spool.weight_used or 0))))
+    if col == "suppliers":
+        # Derived (#2988): "; "-joined supplier names, no decoration — the
+        # purchase source has its own column so import can match plain names.
+        # The export query loads supplier_links explicitly.
+        return "; ".join(link.supplier_name for link in spool.supplier_links)
+    if col == "purchase_supplier":
+        return next((link.supplier_name for link in spool.supplier_links if link.is_purchase_source), "")
     value = getattr(spool, col, None)
     if value is None:
         return ""

+ 8 - 0
backend/app/services/spool_tag_matcher.py

@@ -255,9 +255,17 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     # when creating SpoolAssignment runs synchronously outside the greenlet.
     spool.k_profiles = []
     spool.assignments = []
+    spool.supplier_links = []
     db.add(spool)
     await db.flush()
 
+    # A new spool of a product that already carries supplier assignments
+    # inherits the source list (#2988) — a scanned refill arrives knowing
+    # where it can be bought.
+    from backend.app.services.supplier_links import apply_supplier_inheritance
+
+    await apply_supplier_inheritance(db, spool)
+
     logger.info(
         "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
         spool.id,

+ 106 - 0
backend/app/services/supplier_links.py

@@ -0,0 +1,106 @@
+"""Supplier-assignment inheritance for newly created spools (#2988).
+
+A new spool of a product that other spools already carry supplier
+assignments for should arrive knowing where it can be bought — whether it
+is created manually, via the API, or by the RFID auto-add. Only the source
+list is copied; ``is_purchase_source`` is deliberately reset, because where
+THIS spool was bought is not something the donor can know.
+"""
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.supplier import SpoolSupplier
+
+
+async def find_supplier_link_templates_for_product(
+    db: AsyncSession,
+    *,
+    material: str | None,
+    subtype: str | None,
+    brand: str | None,
+    color_name: str | None,
+) -> list[dict]:
+    """Return supplier-link field dicts from the newest matching spool.
+
+    Product identity is the (material, subtype, brand, color_name) string
+    tuple — the same key FilamentSkuSettings groups by. Archived spools
+    count as donors: a product being out of stock doesn't change where it
+    can be bought.
+    """
+    if not material:
+        return []
+
+    def _same(column, value):
+        return column.is_(None) if value is None else column == value
+
+    donor = await db.execute(
+        select(Spool.id)
+        .join(SpoolSupplier, SpoolSupplier.spool_id == Spool.id)
+        .where(
+            Spool.material == material,
+            _same(Spool.subtype, subtype),
+            _same(Spool.brand, brand),
+            _same(Spool.color_name, color_name),
+        )
+        .order_by(Spool.updated_at.desc())
+        .limit(1)
+    )
+    donor_id = donor.scalars().first()
+    if donor_id is None:
+        return []
+
+    links = await db.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == donor_id))
+    return [
+        {
+            "supplier_id": link.supplier_id,
+            "supplier_article_number": link.supplier_article_number,
+            "quoted_price_per_kg": link.quoted_price_per_kg,
+            "is_purchase_source": False,
+        }
+        for link in links.scalars().all()
+    ]
+
+
+async def apply_supplier_inheritance(db: AsyncSession, spool: Spool) -> None:
+    """Copy supplier assignments onto a freshly created (flushed) spool.
+
+    No-op when the spool already has assignments. The caller commits.
+    """
+    existing = await db.execute(select(SpoolSupplier.id).where(SpoolSupplier.spool_id == spool.id).limit(1))
+    if existing.first() is not None:
+        return
+    templates = await find_supplier_link_templates_for_product(
+        db,
+        material=spool.material,
+        subtype=spool.subtype,
+        brand=spool.brand,
+        color_name=spool.color_name,
+    )
+    _attach(db, spool, templates)
+
+
+async def apply_supplier_inheritance_to_batch(db: AsyncSession, spools: list[Spool]) -> None:
+    """Inheritance for a bulk create: one donor lookup for the whole batch.
+
+    Every copy is built from the same payload, so the product tuple — and with
+    it the answer — is identical for all of them. The caller commits.
+    """
+    if not spools:
+        return
+    first = spools[0]
+    templates = await find_supplier_link_templates_for_product(
+        db,
+        material=first.material,
+        subtype=first.subtype,
+        brand=first.brand,
+        color_name=first.color_name,
+    )
+    for spool in spools:
+        _attach(db, spool, templates)
+
+
+def _attach(db: AsyncSession, spool: Spool, templates: list[dict]) -> None:
+    for template in templates:
+        db.add(SpoolSupplier(spool_id=spool.id, **template))

+ 926 - 0
backend/tests/integration/test_suppliers_api.py

@@ -0,0 +1,926 @@
+"""API coverage for the supplier master list and spool assignments (#2988).
+
+The master list lives under /api/v1/inventory/suppliers (Locations pattern),
+gated by the plain inventory permissions. Assignments exist for both
+inventories: `spool_suppliers` for built-in spools and the
+`spoolman_spool_suppliers` twin keyed by the remote spool id.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.inventory import DUPLICATE_SUPPLIER_NAME
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier
+
+SAMPLE_SPOOLMAN_SPOOL = {
+    "id": 7,
+    "filament": {
+        "id": 1,
+        "name": "PETG CF",
+        "material": "PETG",
+        "weight": 1000,
+        "color_hex": "000000",
+        "vendor": {"id": 1, "name": "BrandX"},
+    },
+    "remaining_weight": 600.0,
+    "used_weight": 400.0,
+    "location": None,
+    "comment": None,
+    "first_used": None,
+    "last_used": None,
+    "registered": "2024-01-01T00:00:00+00:00",
+    "archived": False,
+    "price": None,
+    "extra": {},
+}
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Matte",
+            "brand": "Bambu Lab",
+            "color_name": "Charcoal",
+            "rgba": "333333FF",
+            "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
+
+
+@pytest.fixture
+async def supplier_factory(db_session: AsyncSession):
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        defaults = {"name": f"Supplier {_counter[0]}"}
+        defaults.update(kwargs)
+        supplier = Supplier(**defaults)
+        db_session.add(supplier)
+        await db_session.commit()
+        await db_session.refresh(supplier)
+        return supplier
+
+    return _create
+
+
+@pytest.fixture
+async def spoolman_settings(db_session: AsyncSession):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def mock_spoolman_client():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
+    client.get_distinct_locations = AsyncMock(return_value=[])
+    client.delete_spool = AsyncMock(return_value=None)
+
+    with (
+        patch(
+            "backend.app.api.routes.spoolman_inventory._get_client",
+            AsyncMock(return_value=client),
+        ),
+        # The supplier delete reconciles twin rows against Spoolman before it
+        # refuses (#2988); inventory.py resolves its own client.
+        patch("backend.app.api.routes.inventory.get_spoolman_client", AsyncMock(return_value=client)),
+    ):
+        yield client
+
+
+@pytest.mark.unit
+def test_supplier_relationships_use_the_default_loader():
+    """No relationship-level eager loader (#2988).
+
+    ``Spool.supplier_links`` used to be ``lazy="selectin"``, which made every
+    ``select(Spool)`` in the app — usage tracker, AMS sync, labels, backup —
+    pay two extra round trips for assignments it never reads. The routes that
+    embed them ask for ``selectinload()`` at the query site instead.
+    """
+    from sqlalchemy import inspect as sa_inspect
+
+    assert sa_inspect(Spool).relationships["supplier_links"].lazy == "select"
+    assert sa_inspect(SpoolSupplier).relationships["supplier"].lazy == "select"
+    assert sa_inspect(SpoolmanSpoolSupplier).relationships["supplier"].lazy == "select"
+
+
+class TestSupplierCrud:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_and_list(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/suppliers",
+            json={"name": "Filament24", "website": "https://filament24.example", "customer_number": "C-1042"},
+        )
+        assert resp.status_code == 201
+        body = resp.json()
+        assert body["name"] == "Filament24"
+        assert body["spool_count"] == 0
+
+        listing = await async_client.get("/api/v1/inventory/suppliers")
+        assert listing.status_code == 200
+        assert [s["name"] for s in listing.json()] == ["Filament24"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update(self, async_client: AsyncClient, supplier_factory):
+        supplier = await supplier_factory(name="Old Name")
+        resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": "New Name"})
+        assert resp.status_code == 200
+        assert resp.json()["name"] == "New Name"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_unreferenced(self, async_client: AsyncClient, supplier_factory):
+        supplier = await supplier_factory()
+        resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
+        assert resp.status_code == 200
+        assert (await async_client.get("/api/v1/inventory/suppliers")).json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_referenced_is_refused(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        supplier = await supplier_factory()
+        spool = await spool_factory()
+        db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
+        await db_session.commit()
+
+        resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
+        assert resp.status_code == 409
+        assert "cannot be deleted" in resp.json()["detail"]
+
+        # The listing surfaces the usage count behind the refusal.
+        listing = await async_client.get("/api/v1/inventory/suppliers")
+        assert listing.json()[0]["spool_count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_refused_for_spoolman_only_reference(
+        self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
+    ):
+        """A supplier used only by Spoolman-mode assignments is still protected."""
+        supplier = await supplier_factory()
+        db_session.add(SpoolmanSpoolSupplier(spoolman_spool_id=7, supplier_id=supplier.id))
+        await db_session.commit()
+
+        resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
+        assert resp.status_code == 409
+        assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_reads_case_insensitively(self, async_client: AsyncClient, supplier_factory):
+        """Ordered on the folded name, so "extrudr" sits between "Bambu Store"
+        and "Zultrat" rather than after every capitalised name (#2988)."""
+        for name in ["Zultrat", "extrudr", "Bambu Store"]:
+            await supplier_factory(name=name)
+        listing = (await async_client.get("/api/v1/inventory/suppliers")).json()
+        assert [s["name"] for s in listing] == ["Bambu Store", "extrudr", "Zultrat"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_padded_name_at_the_cap_is_measured_after_trimming(
+        self, async_client: AsyncClient, supplier_factory
+    ):
+        """The length limit applies to the stored name, not to surrounding
+        whitespace that is stripped anyway (#2988)."""
+        name = "S" * 200
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": f"  {name}  "})
+        assert resp.status_code == 201
+        assert resp.json()["name"] == name
+
+        other = await supplier_factory(name="Other")
+        resp = await async_client.patch(f"/api/v1/inventory/suppliers/{other.id}", json={"name": f" {'T' * 200} "})
+        assert resp.status_code == 200
+        assert resp.json()["name"] == "T" * 200
+
+        # A name that really is too long is still refused.
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "U" * 201})
+        assert resp.status_code == 422
+
+
+class TestSupplierNameUniqueness:
+    """Supplier names are the feature's key (#2988): CSV import resolves
+    against them and a rename re-points every assignment, so two rows with
+    the same name silently send an import to the wrong supplier."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_name_is_refused(self, async_client: AsyncClient):
+        assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})).status_code == 201
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
+        assert resp.status_code == 409
+        assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
+        assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_is_case_and_whitespace_insensitive(self, async_client: AsyncClient):
+        """The CSV map is keyed on the trimmed lower-cased name, so a case
+        variant would be just as ambiguous as an exact duplicate."""
+        await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "  eXtRuDr "})
+        assert resp.status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_is_refused_for_non_ascii_names(self, async_client: AsyncClient):
+        """The fold has to be the Python one to be worth anything here.
+
+        SQLite's lower() folds ASCII only, so a unique index on lower(name)
+        saw these as two different names and let both in — while the import
+        map, which folds in Python, collapsed them onto a single entry and
+        resolved to whichever row it built last. That is exactly the silent
+        wrong-supplier assignment the rule exists to prevent.
+        """
+        assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Ökofilament"})).status_code == 201
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "ökofilament"})
+        assert resp.status_code == 409
+        assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
+        assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_onto_an_existing_name_is_refused(self, async_client: AsyncClient, supplier_factory):
+        a = await supplier_factory(name="Extrudr")
+        b = await supplier_factory(name="Filament24")
+        resp = await async_client.patch(f"/api/v1/inventory/suppliers/{b.id}", json={"name": "extrudr"})
+        assert resp.status_code == 409
+        # Renaming a supplier to the name it already has is not a conflict.
+        assert (
+            await async_client.patch(f"/api/v1/inventory/suppliers/{a.id}", json={"name": "Extrudr"})
+        ).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_name_is_trimmed_on_write(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "  Extrudr  "})
+        assert resp.status_code == 201
+        assert resp.json()["name"] == "Extrudr"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explicit_null_name_is_a_validation_error(self, async_client: AsyncClient, supplier_factory):
+        """422, not the 500 a NOT NULL violation used to produce."""
+        supplier = await supplier_factory()
+        resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": None})
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_csv_separator_in_name_is_refused(self, async_client: AsyncClient):
+        """A ';' in the name would split into unknown names on CSV import and
+        silently drop every assignment that used it."""
+        resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr; GmbH"})
+        assert resp.status_code == 422
+
+
+class TestSpoolSupplierAssignments:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_replace_all_and_embed_in_spool_response(
+        self, async_client: AsyncClient, supplier_factory, spool_factory
+    ):
+        a = await supplier_factory(name="Supplier A")
+        b = await supplier_factory(name="Supplier B")
+        spool = await spool_factory()
+
+        resp = await async_client.put(
+            f"/api/v1/inventory/spools/{spool.id}/suppliers",
+            json=[
+                {
+                    "supplier_id": a.id,
+                    "supplier_article_number": "A-100",
+                    "quoted_price_per_kg": 19.99,
+                    "is_purchase_source": True,
+                },
+                {"supplier_id": b.id, "quoted_price_per_kg": 22.5},
+            ],
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert {row["supplier_name"] for row in body} == {"Supplier A", "Supplier B"}
+        assert [row["is_purchase_source"] for row in sorted(body, key=lambda r: r["supplier_id"])] == [True, False]
+
+        # Embedded in the inventory listing.
+        listing = await async_client.get("/api/v1/inventory/spools")
+        spool_row = next(s for s in listing.json() if s["id"] == spool.id)
+        assert {row["supplier_name"] for row in spool_row["suppliers"]} == {"Supplier A", "Supplier B"}
+
+        # Replace-all: shrinking the list removes the other assignment.
+        resp = await async_client.put(
+            f"/api/v1/inventory/spools/{spool.id}/suppliers",
+            json=[{"supplier_id": b.id, "is_purchase_source": True}],
+        )
+        assert resp.status_code == 200
+        assert [row["supplier_name"] for row in resp.json()] == ["Supplier B"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_two_purchase_sources_are_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
+        a = await supplier_factory()
+        b = await supplier_factory()
+        spool = await spool_factory()
+
+        resp = await async_client.put(
+            f"/api/v1/inventory/spools/{spool.id}/suppliers",
+            json=[
+                {"supplier_id": a.id, "is_purchase_source": True},
+                {"supplier_id": b.id, "is_purchase_source": True},
+            ],
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_supplier_is_refused(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+        resp = await async_client.put(
+            f"/api/v1/inventory/spools/{spool.id}/suppliers",
+            json=[{"supplier_id": 999999}],
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_supplier_is_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
+        a = await supplier_factory()
+        spool = await spool_factory()
+        resp = await async_client.put(
+            f"/api/v1/inventory/spools/{spool.id}/suppliers",
+            json=[{"supplier_id": a.id}, {"supplier_id": a.id}],
+        )
+        assert resp.status_code == 400
+
+
+class TestSpoolmanSupplierAssignments:
+    """Spoolman parity (#2988): same endpoints, same shape, twin table."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_replace_all_and_embed(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
+    ):
+        a = await supplier_factory(name="Supplier A")
+
+        resp = await async_client.put(
+            "/api/v1/spoolman/inventory/spools/7/suppliers",
+            json=[
+                {
+                    "supplier_id": a.id,
+                    "supplier_article_number": "A-100",
+                    "quoted_price_per_kg": 19.99,
+                    "is_purchase_source": True,
+                }
+            ],
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body[0]["supplier_name"] == "Supplier A"
+        assert body[0]["quoted_price_per_kg"] == 19.99
+
+        # Same rows via GET, embedded in the single-spool and list responses.
+        assert (await async_client.get("/api/v1/spoolman/inventory/spools/7/suppliers")).json() == body
+        single = await async_client.get("/api/v1/spoolman/inventory/spools/7")
+        assert [row["supplier_name"] for row in single.json()["suppliers"]] == ["Supplier A"]
+        listing = await async_client.get("/api/v1/spoolman/inventory/spools")
+        spool_row = next(s for s in listing.json() if s["id"] == 7)
+        assert [row["supplier_name"] for row in spool_row["suppliers"]] == ["Supplier A"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_validation_matches_builtin_inventory(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
+    ):
+        a = await supplier_factory()
+        b = await supplier_factory()
+
+        resp = await async_client.put(
+            "/api/v1/spoolman/inventory/spools/7/suppliers",
+            json=[{"supplier_id": a.id, "is_purchase_source": True}, {"supplier_id": b.id, "is_purchase_source": True}],
+        )
+        assert resp.status_code == 400
+
+        resp = await async_client.put(
+            "/api/v1/spoolman/inventory/spools/7/suppliers",
+            json=[{"supplier_id": 999999}],
+        )
+        assert resp.status_code == 404
+
+
+class TestSupplierInheritance:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_new_spool_of_same_product_inherits_sources(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        supplier = await supplier_factory(name="Supplier A")
+        donor = await spool_factory()
+        db_session.add(
+            SpoolSupplier(
+                spool_id=donor.id,
+                supplier_id=supplier.id,
+                supplier_article_number="A-100",
+                quoted_price_per_kg=19.99,
+                is_purchase_source=True,
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
+        )
+        assert resp.status_code == 200
+        suppliers = resp.json()["suppliers"]
+        assert [row["supplier_name"] for row in suppliers] == ["Supplier A"]
+        assert suppliers[0]["supplier_article_number"] == "A-100"
+        assert suppliers[0]["quoted_price_per_kg"] == 19.99
+        # Where THIS spool was bought is unknown — never inherited.
+        assert suppliers[0]["is_purchase_source"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_different_product_inherits_nothing(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        supplier = await supplier_factory()
+        donor = await spool_factory()
+        db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id))
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={"material": "PETG", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["suppliers"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_create_gives_every_copy_its_own_links(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        """One donor lookup for the batch, one set of rows per copy."""
+        supplier = await supplier_factory(name="Supplier A")
+        donor = await spool_factory()
+        db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk",
+            json={
+                "spool": {"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
+                "quantity": 3,
+            },
+        )
+        assert resp.status_code == 200
+        created = resp.json()
+        assert len(created) == 3
+        for row in created:
+            assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
+            assert row["suppliers"][0]["supplier_article_number"] == "A-100"
+            assert row["suppliers"][0]["is_purchase_source"] is False
+
+        # Own rows, not shared ones.
+        assert len({row["suppliers"][0]["id"] for row in created}) == 3
+
+
+class TestSupplierLifecycle:
+    """What happens to assignments when the spool they hang on goes away."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_spool_frees_its_supplier(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        supplier = await supplier_factory()
+        spool = await spool_factory()
+        db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
+        await db_session.commit()
+
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
+        assert (await async_client.delete(f"/api/v1/inventory/spools/{spool.id}")).status_code == 200
+
+        # delete-orphan on Spool.supplier_links takes the assignment with it,
+        # so the supplier stops being referenced and becomes deletable.
+        assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_spoolman_spool_drops_the_twin_rows(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
+    ):
+        """Spoolman owns the spool, Bambuddy owns the assignment, and nothing
+        in the database can cascade it. A leaked row keeps the supplier's
+        reference count non-zero, so the delete guard would answer 409 for a
+        spool the user can no longer see (#2988)."""
+        supplier = await supplier_factory()
+        assert (
+            await async_client.put(
+                "/api/v1/spoolman/inventory/spools/7/suppliers",
+                json=[{"supplier_id": supplier.id, "is_purchase_source": True}],
+            )
+        ).status_code == 200
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
+
+        assert (await async_client.delete("/api/v1/spoolman/inventory/spools/7")).status_code == 200
+
+        rows = await db_session.execute(select(SpoolmanSpoolSupplier))
+        assert rows.scalars().all() == []
+        assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_delete_drops_the_twin_rows_too(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
+    ):
+        supplier = await supplier_factory()
+        assert (
+            await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
+        ).status_code == 200
+        # The row has to be proven present before the delete, or the empty
+        # assertion below holds whether or not the purge did anything.
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
+
+        resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk-delete", json={"ids": [7]})
+        assert resp.status_code == 200
+        assert resp.json()["deleted"] == 1
+        rows = await db_session.execute(select(SpoolmanSpoolSupplier))
+        assert rows.scalars().all() == []
+        assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_reconciles_a_spool_deleted_in_spoolman_itself(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
+    ):
+        """Spoolman is a separate application with its own UI, and Bambuddy
+        only hears about the deletes it performs itself. A spool removed over
+        there leaves its assignment behind, and that phantom reference used to
+        make the supplier permanently undeletable with nothing on any screen
+        that could show or clear it (#2988)."""
+        supplier = await supplier_factory()
+        assert (
+            await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
+        ).status_code == 200
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
+
+        # Spool 7 disappears from Spoolman without Bambuddy doing anything.
+        mock_spoolman_client.get_all_spools = AsyncMock(return_value=[])
+
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
+        rows = await db_session.execute(select(SpoolmanSpoolSupplier))
+        assert rows.scalars().all() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reconcile_keeps_assignments_of_an_archived_spool(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
+    ):
+        """Archiving is a soft delete: the spool is still there and its
+        assignment has to survive, so the reconcile asks for the archived ones
+        too and the delete still answers 409."""
+        supplier = await supplier_factory()
+        assert (
+            await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
+        ).status_code == 200
+
+        async def _all_spools(allow_archived: bool = False):
+            return [dict(SAMPLE_SPOOLMAN_SPOOL, archived=True)] if allow_archived else []
+
+        mock_spoolman_client.get_all_spools = AsyncMock(side_effect=_all_spools)
+
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reconcile_keeps_the_rows_when_spoolman_is_unreachable(
+        self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
+    ):
+        """A failed lookup is not evidence that the spool is gone."""
+        supplier = await supplier_factory()
+        assert (
+            await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
+        ).status_code == 200
+
+        mock_spoolman_client.get_all_spools = AsyncMock(side_effect=RuntimeError("Cannot reach Spoolman"))
+
+        assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
+        rows = await db_session.execute(select(SpoolmanSpoolSupplier))
+        assert len(rows.scalars().all()) == 1
+
+
+class TestSupplierStats:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_groups_by_purchase_source(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        a = await supplier_factory(name="Supplier A")
+        b = await supplier_factory(name="Supplier B")
+        bought_at_a = await spool_factory(label_weight=1000, weight_used=200)
+        alt_only = await spool_factory(color_name="Red")
+        db_session.add_all(
+            [
+                SpoolSupplier(spool_id=bought_at_a.id, supplier_id=a.id, is_purchase_source=True),
+                # Alternative source only — must NOT count toward supplier B.
+                SpoolSupplier(spool_id=alt_only.id, supplier_id=b.id, is_purchase_source=False),
+                SpoolUsageHistory(
+                    spool_id=bought_at_a.id, weight_used=150, percent_used=15, status="completed", cost=3.0
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/inventory/stats/suppliers")
+        assert resp.status_code == 200
+        rows = resp.json()
+        assert len(rows) == 1
+        assert rows[0]["supplier_name"] == "Supplier A"
+        assert rows[0]["spool_count"] == 1
+        assert rows[0]["remaining_g"] == pytest.approx(800)
+        assert rows[0]["consumed_g"] == pytest.approx(150)
+        # Cost comes from the recorded usage history (spool.cost_per_kg based),
+        # never from quoted_price_per_kg.
+        assert rows[0]["cost"] == pytest.approx(3.0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_usage_half_honours_the_date_window(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        """The dashboard timeframe scopes consumption and cost; stock is
+        point-in-time and stays out of the window (#2988)."""
+        from datetime import datetime, timedelta, timezone
+
+        supplier = await supplier_factory(name="Supplier A")
+        spool = await spool_factory(label_weight=1000, weight_used=300)
+        now = datetime.now(timezone.utc)
+        db_session.add_all(
+            [
+                SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True),
+                SpoolUsageHistory(
+                    spool_id=spool.id,
+                    weight_used=100,
+                    percent_used=10,
+                    status="completed",
+                    cost=2.0,
+                    created_at=now - timedelta(days=90),
+                ),
+                SpoolUsageHistory(
+                    spool_id=spool.id,
+                    weight_used=200,
+                    percent_used=20,
+                    status="completed",
+                    cost=4.0,
+                    created_at=now - timedelta(days=2),
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        lifetime = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
+        assert lifetime[0]["consumed_g"] == pytest.approx(300)
+        assert lifetime[0]["cost"] == pytest.approx(6.0)
+
+        date_from = (now - timedelta(days=30)).date().isoformat()
+        windowed = (await async_client.get(f"/api/v1/inventory/stats/suppliers?date_from={date_from}")).json()
+        assert windowed[0]["consumed_g"] == pytest.approx(200)
+        assert windowed[0]["cost"] == pytest.approx(4.0)
+        # Stock is not windowed.
+        assert windowed[0]["spool_count"] == 1
+        assert windowed[0]["remaining_g"] == pytest.approx(700)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_over_consumed_spool_counts_as_empty_not_negative(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        """Each spool is clamped before the sum, as the inventory page shows it:
+        a full 1000 g spool next to one over-consumed by 200 g is 1000 g in
+        stock, not 800 g (#2988)."""
+        supplier = await supplier_factory(name="Supplier A")
+        full = await spool_factory(label_weight=1000, weight_used=0)
+        over = await spool_factory(label_weight=1000, weight_used=1200, color_name="Red")
+        db_session.add_all(
+            [
+                SpoolSupplier(spool_id=full.id, supplier_id=supplier.id, is_purchase_source=True),
+                SpoolSupplier(spool_id=over.id, supplier_id=supplier.id, is_purchase_source=True),
+            ]
+        )
+        await db_session.commit()
+
+        rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
+        assert rows[0]["spool_count"] == 2
+        assert rows[0]["remaining_g"] == pytest.approx(1000)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_equal_consumption_falls_back_to_the_name(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        """A narrow date range leaves most rows at 0 g; they read by name
+        instead of in whatever order the GROUP BY produced (#2988)."""
+        names = ["zultrat", "Extrudr", "Bambu Store"]
+        for name in names:
+            supplier = await supplier_factory(name=name)
+            spool = await spool_factory(color_name=name)
+            db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True))
+        heavy = await supplier_factory(name="Yet Another")
+        heavy_spool = await spool_factory(color_name="Heavy")
+        db_session.add_all(
+            [
+                SpoolSupplier(spool_id=heavy_spool.id, supplier_id=heavy.id, is_purchase_source=True),
+                SpoolUsageHistory(spool_id=heavy_spool.id, weight_used=50, percent_used=5, status="completed"),
+            ]
+        )
+        await db_session.commit()
+
+        rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
+        assert [r["supplier_name"] for r in rows] == ["Yet Another", "Bambu Store", "Extrudr", "zultrat"]
+
+
+class TestSupplierCsv:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_carries_both_columns(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        a = await supplier_factory(name="Supplier A")
+        b = await supplier_factory(name="Supplier B")
+        spool = await spool_factory()
+        db_session.add_all(
+            [
+                SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True),
+                SpoolSupplier(spool_id=spool.id, supplier_id=b.id),
+            ]
+        )
+        await db_session.commit()
+
+        export = await async_client.get("/api/v1/inventory/spools/export")
+        assert export.status_code == 200
+        header, row = export.text.splitlines()[:2]
+        columns = header.split(",")
+        assert "suppliers" in columns
+        assert "purchase_supplier" in columns
+        assert "Supplier A; Supplier B" in row
+        assert row.split(",")[columns.index("purchase_supplier")].strip('"') == "Supplier A"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_matches_by_name_and_creates_nothing(
+        self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
+    ):
+        await supplier_factory(name="Supplier A")
+        await supplier_factory(name="Supplier B")
+        csv_text = (
+            "material,brand,color_name,suppliers,purchase_supplier\n"
+            'PLA,Bambu Lab,Charcoal,"  supplier a ; Supplier B",Supplier B\n'
+            "PETG,Bambu Lab,Red,Unknown Corp,\n"
+        )
+
+        # Dry run: the unknown name is a warning, not a row error.
+        preview = await async_client.post(
+            "/api/v1/inventory/spools/import?dry_run=true",
+            files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
+        )
+        assert preview.status_code == 200
+        body = preview.json()
+        assert body["valid_count"] == 2
+        assert body["error_count"] == 0
+        assert any("Unknown Corp" in w for w in body["warnings"])
+
+        # Real import: both rows land; assignments match by name, trimmed and
+        # case-insensitive; the unknown name is dropped and NOT created.
+        result = await async_client.post(
+            "/api/v1/inventory/spools/import",
+            files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
+        )
+        assert result.status_code == 200
+        assert result.json()["created"] == 2
+
+        listing = await async_client.get("/api/v1/inventory/spools")
+        by_color = {s["color_name"]: s for s in listing.json()}
+        charcoal = by_color["Charcoal"]["suppliers"]
+        assert {row["supplier_name"] for row in charcoal} == {"Supplier A", "Supplier B"}
+        assert [row["supplier_name"] for row in charcoal if row["is_purchase_source"]] == ["Supplier B"]
+        assert by_color["Red"]["suppliers"] == []
+        suppliers = (await async_client.get("/api/v1/inventory/suppliers")).json()
+        assert {s["name"] for s in suppliers} == {"Supplier A", "Supplier B"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_round_trip_preserves_assignments(
+        self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
+    ):
+        a = await supplier_factory(name="Supplier A")
+        spool = await spool_factory()
+        db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True))
+        await db_session.commit()
+
+        export = await async_client.get("/api/v1/inventory/spools/export")
+        result = await async_client.post(
+            "/api/v1/inventory/spools/import",
+            files={"file": ("spools.csv", export.content, "text/csv")},
+        )
+        assert result.status_code == 200
+        assert result.json()["created"] == 1
+
+        listing = (await async_client.get("/api/v1/inventory/spools")).json()
+        assert len(listing) == 2
+        for row in listing:
+            assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
+            assert row["suppliers"][0]["is_purchase_source"] is True
+
+
+class TestFromSlotInheritance:
+    """The RFID "+ Add to inventory" path (#2988).
+
+    POST /spools/from-slot builds the spool through create_spool_from_tray,
+    which pre-initialises spool.supplier_links to []. The inheritance rows are
+    added afterwards, so the closing query has to repopulate the collection —
+    otherwise the identity-mapped instance answers with the stale empty list
+    and the caller sees no suppliers until the next fetch.
+    """
+
+    @staticmethod
+    def _status_for_tray(ams_id: int, tray_id: int, tray: dict):
+        status = MagicMock()
+        status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
+        return status
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_from_slot_response_carries_inherited_suppliers(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        supplier_factory,
+        spool_factory,
+        db_session: AsyncSession,
+    ):
+        printer = await printer_factory(name="X1C-supplier-inherit")
+        supplier = await supplier_factory(name="Supplier A")
+        donor = await spool_factory(material="PLA", subtype=None, brand="Bambu Lab", color_name="Clear")
+        db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
+        await db_session.commit()
+
+        # alpha=00 → create_spool_from_tray names the colour "Clear", matching
+        # the donor product without needing a colour-catalogue row.
+        tray = {
+            "tray_type": "PLA",
+            "tray_color": "11223300",
+            "tag_uid": "1122334455667788",
+            "tray_uuid": "0123456789ABCDEF0123456789ABCDEF",
+        }
+        with patch(
+            "backend.app.services.printer_manager.printer_manager.get_status",
+            return_value=self._status_for_tray(0, 1, tray),
+        ):
+            resp = await async_client.post(
+                "/api/v1/inventory/spools/from-slot",
+                json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+            )
+
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert [row["supplier_name"] for row in body["suppliers"]] == ["Supplier A"]
+        assert body["suppliers"][0]["supplier_article_number"] == "A-100"
+        assert body["suppliers"][0]["is_purchase_source"] is False
+
+        # The row was always written — the defect was the response reading a
+        # stale collection off the identity-mapped instance.
+        rows = await db_session.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == body["id"]))
+        assert [row.supplier_id for row in rows.scalars().all()] == [supplier.id]

+ 45 - 27
backend/tests/unit/test_bulk_spool_create.py

@@ -5,6 +5,7 @@ Tests:
 - Bulk create endpoint creates the requested number of spools
 - Bulk create with quantity=1 (single spool)
 - Bulk create returns spools with k_profiles loaded
+- Bulk create copies the inherited supplier links onto every copy
 """
 
 from unittest.mock import AsyncMock, MagicMock
@@ -12,6 +13,8 @@ from unittest.mock import AsyncMock, MagicMock
 import pytest
 from pydantic import ValidationError
 
+from backend.app.models.spool import Spool
+from backend.app.models.supplier import SpoolSupplier
 from backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate
 
 # ── Schema Validation ──────────────────────────────────────────────────────
@@ -96,6 +99,40 @@ def _make_mock_spool(spool_id):
     return spool
 
 
+def _result(rows):
+    """A stand-in for the Result of a query that selected ``rows``."""
+    result = MagicMock()
+    result.scalars.return_value.all.return_value = list(rows)
+    result.scalars.return_value.first.return_value = rows[0] if rows else None
+    result.first.return_value = (rows[0],) if rows else None
+    return result
+
+
+def _make_db(refetched, *, donor_id=None, donor_links=()):
+    """Session double that answers each of the bulk path's queries separately.
+
+    The endpoint runs three different selects — the supplier donor lookup,
+    that donor's link rows (#2988), and the re-fetch of the created spools —
+    so one canned result for all of them hands Spool rows to the supplier
+    code and invents a donor that was never seeded. Route on what each select
+    asks for instead. Returns the session and the list ``db.add`` lands in.
+    """
+    added = []
+    db = AsyncMock()
+    db.add = added.append
+
+    async def execute(statement, *_args, **_kwargs):
+        described = statement.column_descriptions[0]
+        if described["entity"] is SpoolSupplier:
+            return _result(donor_links)
+        if described["name"] == "id":
+            return _result([] if donor_id is None else [donor_id])
+        return _result(refetched)
+
+    db.execute = AsyncMock(side_effect=execute)
+    return db, added
+
+
 class TestBulkCreateEndpoint:
     """Tests for the bulk_create_spools endpoint logic."""
 
@@ -109,15 +146,7 @@ class TestBulkCreateEndpoint:
             quantity=3,
         )
 
-        db = AsyncMock()
-        added_objects = []
-        db.add = lambda obj: added_objects.append(obj)
-
-        # Mock the re-fetch query
-        mock_result = MagicMock()
-        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]
-        mock_result.scalars.return_value.all.return_value = mock_spools
-        db.execute = AsyncMock(return_value=mock_result)
+        db, added_objects = _make_db([_make_mock_spool(i + 1) for i in range(3)])
 
         result = await bulk_create_spools(data=data, db=db, _=None)
 
@@ -135,14 +164,7 @@ class TestBulkCreateEndpoint:
             quantity=1,
         )
 
-        db = AsyncMock()
-        added_objects = []
-        db.add = lambda obj: added_objects.append(obj)
-
-        mock_result = MagicMock()
-        mock_spools = [_make_mock_spool(1)]
-        mock_result.scalars.return_value.all.return_value = mock_spools
-        db.execute = AsyncMock(return_value=mock_result)
+        db, added_objects = _make_db([_make_mock_spool(1)])
 
         result = await bulk_create_spools(data=data, db=db, _=None)
 
@@ -165,19 +187,15 @@ class TestBulkCreateEndpoint:
             quantity=3,
         )
 
-        db = AsyncMock()
-        added_objects = []
-        db.add = lambda obj: added_objects.append(obj)
-
-        mock_result = MagicMock()
-        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]
-        mock_result.scalars.return_value.all.return_value = mock_spools
-        db.execute = AsyncMock(return_value=mock_result)
+        db, added_objects = _make_db([_make_mock_spool(i + 1) for i in range(3)])
 
         await bulk_create_spools(data=data, db=db, _=None)
 
-        # All added Spool objects should have the same material/brand/color
-        for spool_obj in added_objects:
+        # The spools, not everything the session was handed: the bulk path also
+        # adds the inherited supplier links, and a SpoolSupplier has no material.
+        spools = [obj for obj in added_objects if isinstance(obj, Spool)]
+        assert len(spools) == 3
+        for spool_obj in spools:
             assert spool_obj.material == "ABS"
             assert spool_obj.brand == "Bambu Lab"
             assert spool_obj.color_name == "Black"

+ 277 - 0
backend/tests/unit/test_supplier_tables_migration.py

@@ -0,0 +1,277 @@
+"""Migration tests for the supplier tables (#2988).
+
+A database that predates the feature must gain both tables on upgrade, and
+re-running the migration must be a no-op (CREATE TABLE IF NOT EXISTS via
+_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_without_supplier_tables():
+    """create_all builds the current schema; dropping the tables reproduces a
+    database from a Bambuddy version that predates #2988."""
+    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("DROP TABLE spoolman_spool_suppliers"))
+        await conn.execute(text("DROP TABLE spool_suppliers"))
+        await conn.execute(text("DROP TABLE suppliers"))
+    yield engine
+    await engine.dispose()
+
+
+@pytest.fixture
+async def engine_with_pre_fix_suppliers():
+    """A database written by an earlier build of this branch (#2988).
+
+    The supplier tables are there, ``suppliers`` has no ``name_key`` column,
+    and nothing stopped two rows whose names differ only in case -- which is
+    exactly the state a unique index cannot be built over.
+    """
+    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("DROP TABLE suppliers"))
+        await conn.execute(
+            text(
+                "CREATE TABLE suppliers ("
+                " id INTEGER PRIMARY KEY AUTOINCREMENT,"
+                " name VARCHAR(200) NOT NULL,"
+                " website VARCHAR(500),"
+                " customer_number VARCHAR(100),"
+                " note VARCHAR(500),"
+                " created_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
+                " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
+            )
+        )
+    yield engine
+    await engine.dispose()
+
+
+async def test_migration_creates_supplier_tables(engine_without_supplier_tables):
+    async with engine_without_supplier_tables.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_without_supplier_tables.begin() as conn:
+        await conn.execute(text("INSERT INTO suppliers (name, name_key) VALUES ('Supplier A', 'supplier a')"))
+        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)
+                """
+            )
+        )
+        await conn.execute(
+            text(
+                """
+                INSERT INTO spool_suppliers (spool_id, supplier_id, quoted_price_per_kg, is_purchase_source)
+                SELECT s.id, sup.id, 19.99, 1 FROM spool s, suppliers sup
+                """
+            )
+        )
+        # Spoolman twin (#2988 parity): local row keyed by the remote spool id.
+        await conn.execute(
+            text(
+                """
+                INSERT INTO spoolman_spool_suppliers (spoolman_spool_id, supplier_id, is_purchase_source)
+                SELECT 7, sup.id, 1 FROM suppliers sup
+                """
+            )
+        )
+
+    async with engine_without_supplier_tables.connect() as conn:
+        links = (await conn.execute(text("SELECT supplier_id, is_purchase_source FROM spool_suppliers"))).all()
+        twin_links = (
+            await conn.execute(text("SELECT spoolman_spool_id, supplier_id FROM spoolman_spool_suppliers"))
+        ).all()
+    assert len(links) == 1
+    assert len(twin_links) == 1
+
+
+async def test_migration_is_idempotent(engine_without_supplier_tables):
+    async with engine_without_supplier_tables.begin() as conn:
+        await run_migrations(conn)
+    async with engine_without_supplier_tables.begin() as conn:
+        await conn.execute(text("INSERT INTO suppliers (name, name_key) VALUES ('Kept', 'kept')"))
+    async with engine_without_supplier_tables.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_without_supplier_tables.connect() as conn:
+        names = (await conn.execute(text("SELECT name FROM suppliers"))).scalars().all()
+    # Existing rows survive the re-run — the CREATE is swallowed, not applied.
+    assert names == ["Kept"]
+
+
+async def test_migration_enforces_case_insensitive_unique_names(engine_without_supplier_tables):
+    """The name is what the CSV import resolves against (#2988), so an
+    upgraded database gets the same unique index create_all gives a fresh one.
+
+    Every spelling goes in with the key the application computes, which is the
+    point of storing it: the fold is the Python one, so the umlauted variant
+    is refused too. A unique index on ``lower(name)`` let that one through,
+    because SQLite's ``lower()`` folds ASCII only.
+    """
+    from sqlalchemy.exc import IntegrityError
+
+    from backend.app.models.supplier import supplier_name_key
+
+    async def _insert(conn, name: str) -> None:
+        await conn.execute(
+            text("INSERT INTO suppliers (name, name_key) VALUES (:n, :k)"),
+            {"n": name, "k": supplier_name_key(name)},
+        )
+
+    async with engine_without_supplier_tables.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_without_supplier_tables.begin() as conn:
+        await _insert(conn, "Extrudr")
+        await _insert(conn, "Ökofilament")
+
+    for variant in ("extrudr", "  eXtRuDr ", "ökofilament"):
+        with pytest.raises(IntegrityError):
+            async with engine_without_supplier_tables.begin() as conn:
+                await _insert(conn, variant)
+
+
+async def test_upgraded_database_refuses_a_supplier_without_a_name_key(engine_without_supplier_tables):
+    """The upgrade path declares name_key NOT NULL, as create_all() does on a
+    fresh install (#2988). NULLs never collide in a unique index, so a row
+    without a key would slip past the case-insensitive uniqueness."""
+    from sqlalchemy.exc import IntegrityError
+
+    async with engine_without_supplier_tables.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_without_supplier_tables.connect() as conn:
+        columns = (await conn.execute(text("PRAGMA table_info(suppliers)"))).all()
+    assert {c.name: c.notnull for c in columns}["name_key"] == 1
+
+    with pytest.raises(IntegrityError):
+        async with engine_without_supplier_tables.begin() as conn:
+            await conn.execute(text("INSERT INTO suppliers (name) VALUES ('No Key')"))
+
+
+async def test_migration_collapses_duplicates_instead_of_aborting(engine_with_pre_fix_suppliers):
+    """An upgrade over rows this branch itself allowed must not abort startup.
+
+    CREATE UNIQUE INDEX refuses to build over the duplicates and _safe_execute
+    re-raises that IntegrityError out of run_migrations, so without the
+    collapse Bambuddy never finishes starting — and every migration queued
+    after this one is skipped with it (#2988).
+
+    Collapsed by merging, not deleting: a supplier is referenced. The oldest
+    row wins — it is the one assignments and the import already resolved to —
+    keeps its own spelling, takes over the assignments and fills its empty
+    fields from the duplicate.
+    """
+    async with engine_with_pre_fix_suppliers.begin() as conn:
+        await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (1, 'Extrudr')"))
+        await conn.execute(
+            text("INSERT INTO suppliers (id, name, website) VALUES (2, 'extrudr', 'https://extrudr.example')")
+        )
+        await conn.execute(
+            text(
+                "INSERT INTO spool_suppliers (spool_id, supplier_id, supplier_article_number, is_purchase_source)"
+                " VALUES (5, 2, 'EX-42', 1)"
+            )
+        )
+        await conn.execute(
+            text(
+                "INSERT INTO spoolman_spool_suppliers (spoolman_spool_id, supplier_id, is_purchase_source) VALUES (7, 2, 0)"
+            )
+        )
+
+    async with engine_with_pre_fix_suppliers.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_pre_fix_suppliers.connect() as conn:
+        suppliers = (await conn.execute(text("SELECT id, name, name_key, website FROM suppliers"))).all()
+        links = (
+            await conn.execute(text("SELECT spool_id, supplier_id, supplier_article_number FROM spool_suppliers"))
+        ).all()
+        twins = (await conn.execute(text("SELECT spoolman_spool_id, supplier_id FROM spoolman_spool_suppliers"))).all()
+
+    assert suppliers == [(1, "Extrudr", "extrudr", "https://extrudr.example")]
+    assert links == [(5, 1, "EX-42")]
+    assert twins == [(7, 1)]
+
+
+async def test_migration_collapses_non_ascii_case_variants(engine_with_pre_fix_suppliers):
+    """These two are in the database precisely because the SQL fold is ASCII
+    only: an index on lower(name) never saw them as the same name (#2988)."""
+    async with engine_with_pre_fix_suppliers.begin() as conn:
+        await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (1, :n)"), {"n": "Ökofilament"})
+        await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (2, :n)"), {"n": "ökofilament"})
+
+    async with engine_with_pre_fix_suppliers.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_pre_fix_suppliers.connect() as conn:
+        rows = (await conn.execute(text("SELECT name, name_key FROM suppliers"))).all()
+    assert rows == [("Ökofilament", "ökofilament")]
+
+
+async def test_merge_drops_an_assignment_the_surviving_row_already_has(engine_with_pre_fix_suppliers):
+    """(spool, supplier) is unique, so a spool assigned to BOTH duplicates
+    cannot have both rows re-pointed — the survivor's own row stays."""
+    async with engine_with_pre_fix_suppliers.begin() as conn:
+        await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (1, 'Extrudr'), (2, 'EXTRUDR')"))
+        await conn.execute(
+            text(
+                "INSERT INTO spool_suppliers (spool_id, supplier_id, supplier_article_number, is_purchase_source)"
+                " VALUES (5, 1, 'KEPT', 0), (5, 2, 'DROPPED', 0), (6, 2, 'MOVED', 0)"
+            )
+        )
+
+    async with engine_with_pre_fix_suppliers.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_pre_fix_suppliers.connect() as conn:
+        links = (
+            await conn.execute(
+                text("SELECT spool_id, supplier_id, supplier_article_number FROM spool_suppliers ORDER BY spool_id")
+            )
+        ).all()
+    assert links == [(5, 1, "KEPT"), (6, 1, "MOVED")]

+ 104 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -47,6 +47,11 @@ vi.mock('../../api/client', () => ({
     getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
     unassignSpool: vi.fn().mockResolvedValue({}),
     unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
+    // Suppliers (#2988) — the SupplierSection inside the form loads these.
+    getSuppliers: vi.fn().mockResolvedValue([]),
+    createSupplier: vi.fn().mockResolvedValue({ id: 1, name: 'S' }),
+    setSpoolSuppliers: vi.fn().mockResolvedValue([]),
+    setSpoolmanSpoolSuppliers: vi.fn().mockResolvedValue([]),
   },
   ApiError: class ApiError extends Error {
     status: number;
@@ -1367,6 +1372,105 @@ describe('SpoolFormModal copy mode', () => {
     const [payload] = vi.mocked(api.createSpool).mock.calls[0];
     expect((payload as Record<string, unknown>).weight_used).toBe(0);
   });
+
+  // The dialog seeds the supplier chips from the spool being copied and shows
+  // them, so the copy has to actually get them (#2988). The backend's
+  // inheritance is not a stand-in: it keys on the (material, subtype, brand,
+  // color_name) tuple, so it resolves to the NEWEST spool of that product
+  // rather than the one on screen, and in Spoolman mode it never runs at all.
+  const spoolWithSuppliers: InventorySpool = {
+    ...existingSpool,
+    suppliers: [
+      {
+        id: 11,
+        supplier_id: 4,
+        supplier_name: 'Extrudr',
+        supplier_article_number: 'EX-42',
+        quoted_price_per_kg: 21.5,
+        is_purchase_source: true,
+      },
+    ],
+  };
+
+  async function clickCopy() {
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+    const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
+      .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
+    expect(saveBtn).toBeTruthy();
+    fireEvent.click(saveBtn!);
+  }
+
+  it('saves the supplier assignments it displays when copying', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithSuppliers}
+        mode="copy"
+        currencySymbol="$"
+      />
+    );
+
+    await clickCopy();
+
+    await waitFor(() => {
+      expect(api.setSpoolSuppliers).toHaveBeenCalledTimes(1);
+    });
+    // createSpool is mocked to answer id 99.
+    expect(vi.mocked(api.setSpoolSuppliers).mock.calls[0]).toEqual([
+      99,
+      [
+        {
+          supplier_id: 4,
+          supplier_article_number: 'EX-42',
+          quoted_price_per_kg: 21.5,
+          // Where a copy was bought is unknown — only the source list carries over.
+          is_purchase_source: false,
+        },
+      ],
+    ]);
+  });
+
+  it('saves them through the Spoolman endpoint in Spoolman mode', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithSuppliers}
+        mode="copy"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    await clickCopy();
+
+    await waitFor(() => {
+      expect(api.setSpoolmanSpoolSuppliers).toHaveBeenCalledTimes(1);
+    });
+    // createSpoolmanInventorySpool is mocked to answer id 88.
+    expect(vi.mocked(api.setSpoolmanSpoolSuppliers).mock.calls[0][0]).toBe(88);
+    expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
+  });
+
+  it('leaves an untouched create to the backend inheritance', async () => {
+    render(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" />);
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
+    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
+    fireEvent.click(submitButton!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+    // An empty replace-all here would wipe what the backend just inherited.
+    expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
+  });
 });
 
 // The "#<id>" affordance in the modal header (#1385) is only meaningful when

+ 154 - 0
frontend/src/__tests__/components/SupplierSection.test.tsx

@@ -0,0 +1,154 @@
+/**
+ * Tests for the spool dialog's supplier multi-select (#2988).
+ */
+
+import { useState } from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SupplierSection, type SupplierLinkDraft } from '../../components/spool-form/SupplierSection';
+import { api } from '../../api/client';
+import { inventorySuppliersQueryKey } from '../../utils/inventoryQueries';
+
+const mockShowToast = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSuppliers: vi.fn(),
+    createSupplier: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+const suppliers = [
+  { id: 1, name: 'Filament24', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '' },
+  { id: 2, name: 'PrintStore', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '' },
+];
+
+function Harness({ initial = [] as SupplierLinkDraft[], onChange = (_: SupplierLinkDraft[]) => {} }) {
+  const [links, setLinks] = useState<SupplierLinkDraft[]>(initial);
+  return (
+    <SupplierSection
+      links={links}
+      onChange={(next) => {
+        setLinks(next);
+        onChange(next);
+      }}
+      currencySymbol="€"
+    />
+  );
+}
+
+// The supplier list is read through react-query under the shared
+// inventory-suppliers key, so every render needs a client (#2988).
+function renderSection(ui: React.ReactElement) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return { ...render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>), client };
+}
+
+describe('SupplierSection', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue(suppliers);
+  });
+
+  it('adds a supplier from the dropdown as a chip', async () => {
+    const onChange = vi.fn();
+    const user = userEvent.setup();
+    renderSection(<Harness onChange={onChange} />);
+
+    await user.click(screen.getByRole('button', { name: /Add supplier/ }));
+    await user.click(await screen.findByRole('button', { name: 'Filament24' }));
+
+    expect(screen.getByText('Filament24')).toBeInTheDocument();
+    expect(onChange).toHaveBeenCalledWith([
+      expect.objectContaining({ supplier_id: 1, supplier_name: 'Filament24', is_purchase_source: false }),
+    ]);
+  });
+
+  it('creates a new supplier inline without leaving the dialog', async () => {
+    (api.createSupplier as ReturnType<typeof vi.fn>).mockResolvedValue({
+      id: 3, name: 'NewShop', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '',
+    });
+    const user = userEvent.setup();
+    renderSection(<Harness />);
+
+    await user.click(screen.getByRole('button', { name: /Add supplier/ }));
+    await user.type(await screen.findByPlaceholderText(/Search suppliers/), 'NewShop');
+    await user.click(screen.getByRole('button', { name: /Create "NewShop"/ }));
+
+    await waitFor(() => {
+      expect(api.createSupplier).toHaveBeenCalledWith({ name: 'NewShop' });
+    });
+    expect(screen.getByText('NewShop')).toBeInTheDocument();
+  });
+
+  it('says the load failed instead of offering an empty list', async () => {
+    // Silently rendering nothing invites the user to create a supplier that
+    // already exists.
+    (api.getSuppliers as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
+    const user = userEvent.setup();
+    renderSection(<Harness />);
+
+    await user.click(screen.getByRole('button', { name: /Add supplier/ }));
+    expect(await screen.findByText(/Failed to load suppliers/i)).toBeInTheDocument();
+  });
+
+  it('re-reads the list when the shared key is invalidated', async () => {
+    const user = userEvent.setup();
+    const { client } = renderSection(<Harness />);
+    await user.click(screen.getByRole('button', { name: /Add supplier/ }));
+    await screen.findByRole('button', { name: 'Filament24' });
+
+    (api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue([
+      ...suppliers,
+      { id: 9, name: 'Elsewhere', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '' },
+    ]);
+    await client.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
+
+    expect(await screen.findByRole('button', { name: 'Elsewhere' })).toBeInTheDocument();
+  });
+
+  it('keeps at most one purchase source across assignments', async () => {
+    const onChange = vi.fn();
+    const user = userEvent.setup();
+    renderSection(
+      <Harness
+        initial={[
+          { supplier_id: 1, supplier_name: 'Filament24', supplier_article_number: '', quoted_price_per_kg: null, is_purchase_source: true },
+          { supplier_id: 2, supplier_name: 'PrintStore', supplier_article_number: '', quoted_price_per_kg: null, is_purchase_source: false },
+        ]}
+        onChange={onChange}
+      />,
+    );
+
+    const checkboxes = screen.getAllByRole('checkbox');
+    await user.click(checkboxes[1]);
+
+    const last = onChange.mock.calls.at(-1)?.[0] as SupplierLinkDraft[];
+    expect(last.find((l) => l.supplier_id === 2)?.is_purchase_source).toBe(true);
+    // The previous purchase source was demoted automatically.
+    expect(last.find((l) => l.supplier_id === 1)?.is_purchase_source).toBe(false);
+  });
+
+  it('removes an assignment via the chip', async () => {
+    const onChange = vi.fn();
+    const user = userEvent.setup();
+    renderSection(
+      <Harness
+        initial={[
+          { supplier_id: 1, supplier_name: 'Filament24', supplier_article_number: '', quoted_price_per_kg: null, is_purchase_source: false },
+        ]}
+        onChange={onChange}
+      />,
+    );
+
+    await user.click(screen.getByRole('button', { name: /Remove/i }));
+    expect(onChange).toHaveBeenLastCalledWith([]);
+    expect(screen.queryByText('Filament24')).not.toBeInTheDocument();
+  });
+});

+ 70 - 0
frontend/src/__tests__/components/SupplierStats.test.tsx

@@ -0,0 +1,70 @@
+/**
+ * Tests for the "By Supplier" dashboard widget (#2988).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SupplierStats } from '../../components/SupplierStats';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: { getSupplierStats: vi.fn() },
+}));
+
+const rows = [
+  { supplier_id: 1, supplier_name: 'Filament24', spool_count: 2, remaining_g: 1400, consumed_g: 600, cost: 12.5 },
+];
+
+function renderWidget(props: { dateFrom?: string; dateTo?: string } = {}) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <SupplierStats currency="€" {...props} />
+    </QueryClientProvider>,
+  );
+}
+
+describe('SupplierStats', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(api.getSupplierStats).mockResolvedValue(rows);
+  });
+
+  it('passes the dashboard timeframe to the endpoint', async () => {
+    renderWidget({ dateFrom: '2026-08-01', dateTo: '2026-08-31' });
+    expect(await screen.findByText('Filament24')).toBeInTheDocument();
+    expect(api.getSupplierStats).toHaveBeenCalledWith('2026-08-01', '2026-08-31');
+  });
+
+  it('refetches when the timeframe changes', async () => {
+    const { rerender } = renderWidget({ dateFrom: '2026-08-01' });
+    await screen.findByText('Filament24');
+
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    rerender(
+      <QueryClientProvider client={client}>
+        <SupplierStats currency="€" dateFrom="2026-01-01" />
+      </QueryClientProvider>,
+    );
+    await screen.findByText('Filament24');
+
+    // The range is part of the query key, so a new window is a new read.
+    expect(vi.mocked(api.getSupplierStats).mock.calls).toContainEqual(['2026-01-01', undefined]);
+  });
+
+  it('reports a failed request as an error, not as "no suppliers yet"', async () => {
+    // A 403 from a missing inventory:read or a 500 used to render the empty
+    // state, telling the user they had not assigned suppliers they had.
+    vi.mocked(api.getSupplierStats).mockRejectedValue(new Error('boom'));
+    renderWidget();
+    expect(await screen.findByText(/Could not load supplier statistics/i)).toBeInTheDocument();
+    expect(screen.queryByText(/No purchases recorded yet/i)).not.toBeInTheDocument();
+  });
+
+  it('still shows the empty state when the inventory really is empty', async () => {
+    vi.mocked(api.getSupplierStats).mockResolvedValue([]);
+    renderWidget();
+    expect(await screen.findByText(/No purchases recorded yet/i)).toBeInTheDocument();
+  });
+});

+ 163 - 0
frontend/src/__tests__/components/SuppliersModal.test.tsx

@@ -0,0 +1,163 @@
+/**
+ * Tests for the SuppliersModal (#2988) — the supplier master list opened from the Inventory toolbar.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SuppliersModal } from '../../components/SuppliersModal';
+import { api, ApiError } from '../../api/client';
+import { inventorySuppliersQueryKey } from '../../utils/inventoryQueries';
+
+const mockShowToast = vi.fn();
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const original = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ApiError: original.ApiError,
+    api: {
+      getSuppliers: vi.fn(),
+      createSupplier: vi.fn(),
+      updateSupplier: vi.fn(),
+      deleteSupplier: vi.fn(),
+    },
+  };
+});
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+const suppliers = [
+  {
+    id: 1,
+    name: 'Filament24',
+    website: 'https://filament24.example',
+    customer_number: 'C-1042',
+    note: null,
+    spool_count: 3,
+    created_at: '2026-01-01',
+    updated_at: '2026-01-01',
+  },
+  {
+    id: 2,
+    name: 'PrintStore',
+    website: null,
+    customer_number: null,
+    note: 'B2B only',
+    spool_count: 0,
+    created_at: '2026-01-01',
+    updated_at: '2026-01-01',
+  },
+];
+
+// The list lives in react-query under the shared inventory-suppliers key, so
+// the modal needs a client the same way LocationsModal's tests give it one.
+function renderModal(open = true) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  const result = render(
+    <QueryClientProvider client={client}>
+      <SuppliersModal open={open} onClose={() => {}} />
+    </QueryClientProvider>,
+  );
+  return { ...result, client };
+}
+
+describe('SuppliersModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue(suppliers);
+  });
+
+  it('lists suppliers with their spool usage counts', async () => {
+    renderModal();
+    expect(await screen.findByText('Filament24')).toBeInTheDocument();
+    expect(screen.getByText('PrintStore')).toBeInTheDocument();
+    expect(screen.getByText('3')).toBeInTheDocument();
+    expect(screen.getByText('C-1042')).toBeInTheDocument();
+  });
+
+  it('creates a supplier through the add form and re-reads the list', async () => {
+    const newShop = {
+      id: 3,
+      name: 'NewShop',
+      website: null,
+      customer_number: null,
+      note: null,
+      spool_count: 0,
+      created_at: '2026-01-01',
+      updated_at: '2026-01-01',
+    };
+    (api.createSupplier as ReturnType<typeof vi.fn>).mockResolvedValue(newShop);
+    // The row appears because the shared query is invalidated and refetched,
+    // not because the component pushed it into a private copy.
+    (api.getSuppliers as ReturnType<typeof vi.fn>)
+      .mockResolvedValueOnce(suppliers)
+      .mockResolvedValue([...suppliers, newShop]);
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('Filament24');
+
+    await user.click(screen.getByRole('button', { name: /Add/i }));
+    await user.type(screen.getByPlaceholderText(/Name \(e\.g\./), 'NewShop');
+    const addButtons = screen.getAllByRole('button', { name: /Add/i });
+    await user.click(addButtons[addButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(api.createSupplier).toHaveBeenCalledWith({
+        name: 'NewShop',
+        website: null,
+        customer_number: null,
+        note: null,
+      });
+    });
+    expect(await screen.findByText('NewShop')).toBeInTheDocument();
+  });
+
+  it('picks up a supplier created elsewhere when the shared key is invalidated', async () => {
+    // What the inventory_changed broadcast does: useWebSocket invalidates
+    // inventory-suppliers, and every consumer of that key re-reads (#2988).
+    const { client } = renderModal();
+    await screen.findByText('Filament24');
+    expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument();
+
+    (api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue([
+      ...suppliers,
+      { ...suppliers[1], id: 9, name: 'Elsewhere' },
+    ]);
+    await client.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
+
+    expect(await screen.findByText('Elsewhere')).toBeInTheDocument();
+  });
+
+  it('says the load failed instead of showing an empty list', async () => {
+    // An empty list reads as "no suppliers yet" and invites the user to
+    // create a duplicate of one they cannot see.
+    (api.getSuppliers as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
+    renderModal();
+    expect(await screen.findByText(/Failed to load suppliers/i)).toBeInTheDocument();
+  });
+
+  it('surfaces the delete guard when the supplier is still referenced', async () => {
+    (api.deleteSupplier as ReturnType<typeof vi.fn>).mockRejectedValue(
+      new ApiError('Supplier is assigned to 3 spool(s)', 409),
+    );
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('Filament24');
+
+    // Open the confirm for the referenced supplier (first row).
+    const deleteButtons = document.querySelectorAll('button.text-red-500');
+    await user.click(deleteButtons[0] as HTMLElement);
+    // The confirm message warns about the existing assignments.
+    expect(await screen.findByText(/assigned to 3 spool/)).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /Delete/i }));
+
+    await waitFor(() => {
+      expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining('3'), 'error');
+    });
+    // Row stays — nothing was deleted.
+    expect(screen.getByText('Filament24')).toBeInTheDocument();
+  });
+});

+ 3 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -465,6 +465,9 @@ describe('useWebSocket hook', () => {
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-locations'] });
+      // #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'] });
 
       vi.useRealTimers();
       vi.unstubAllGlobals();

+ 4 - 3
frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx

@@ -67,11 +67,12 @@ describe('InventoryPage — header layout', () => {
       expect(group.querySelector('button')).toBeInTheDocument();
     });
 
-    // Without this the group is as wide as all five buttons laid end to end,
+    // Without this the group is as wide as all its buttons laid end to end,
     // whatever the viewport is.
     expect(group.className).toContain('flex-wrap');
-    // All five actions stay in that one group -- wrapping them is the fix,
+    // All six actions (CSV import/export, Locations, Suppliers (#2988),
+    // labels, add) stay in that one group -- wrapping them is the fix,
     // hiding any of them is not.
-    expect(group.querySelectorAll('button')).toHaveLength(5);
+    expect(group.querySelectorAll('button')).toHaveLength(6);
   });
 });

+ 144 - 0
frontend/src/__tests__/pages/StatsPageSuppliers.test.tsx

@@ -0,0 +1,144 @@
+/**
+ * The "By Supplier" widget on the stats dashboard (#2988).
+ *
+ * It aggregates the internal spool table's purchase-source assignments. In
+ * Spoolman mode those assignments live in the Spoolman twin table instead, so
+ * the card would say "no suppliers yet" next to an inventory showing supplier
+ * chips. The widget is dropped in that mode, and held back until the mode is
+ * known so a Spoolman install never flashes it.
+ */
+
+import { describe, it, expect, beforeEach } 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 supplierRequests: 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 holds 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/suppliers', ({ request }) => {
+      supplierRequests.push(new URL(request.url));
+      return HttpResponse.json([]);
+    })
+  );
+}
+
+function gated() {
+  let open = () => {};
+  const gate = new Promise<void>((resolve) => {
+    open = resolve;
+  });
+  return { gate, open: () => open() };
+}
+
+describe('StatsPage supplier widget', () => {
+  beforeEach(() => {
+    supplierRequests = [];
+  });
+
+  it('shows the widget in internal inventory mode', async () => {
+    setupHandlers(false);
+    render(<StatsPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('By Supplier')).toBeInTheDocument();
+    });
+    await waitFor(() => {
+      expect(supplierRequests.length).toBeGreaterThan(0);
+    });
+  });
+
+  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('Filament Trends')).toBeInTheDocument();
+    });
+
+    expect(screen.queryByText('By Supplier')).toBeNull();
+    expect(supplierRequests).toHaveLength(0);
+  });
+
+  it('holds the widget back until the Spoolman setting has resolved', async () => {
+    const { gate, open } = gated();
+    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 Supplier')).toBeNull();
+    expect(supplierRequests).toHaveLength(0);
+
+    open();
+    await waitFor(() => {
+      expect(screen.queryByText('By Supplier')).toBeNull();
+    });
+    expect(supplierRequests).toHaveLength(0);
+  });
+
+  it('shows the widget once the setting says this is not Spoolman mode', async () => {
+    const { gate, open } = gated();
+    setupHandlers(false, gate);
+    render(<StatsPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('By Supplier')).toBeNull();
+
+    open();
+    await waitFor(() => {
+      expect(screen.getByText('By Supplier')).toBeInTheDocument();
+    });
+  });
+});

+ 85 - 0
frontend/src/api/client.ts

@@ -3661,6 +3661,61 @@ export interface InventorySpool {
   k_profiles?: SpoolKProfile[];
   storage_location?: string | null;
   location_id?: number | null;
+  // Supplier assignments (#2988). Absent in Spoolman mode — Spoolman's
+  // vendor is the manufacturer, not the seller, so there is no mapping.
+  suppliers?: SpoolSupplierLink[];
+}
+
+// ── Suppliers (#2988) ──────────────────────────────────────────────────────
+
+/** Where filament is bought — distinct from brand (who made it). */
+export interface Supplier {
+  id: number;
+  name: string;
+  website: string | null;
+  customer_number: string | null;
+  note: string | null;
+  /** Spools referencing this supplier; a referenced supplier cannot be deleted. */
+  spool_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface SupplierInput {
+  name: string;
+  website?: string | null;
+  customer_number?: string | null;
+  note?: string | null;
+}
+
+/** One spool-to-supplier assignment as written by the spool dialog. */
+export interface SpoolSupplierLinkInput {
+  supplier_id: number;
+  /** The supplier's own article number — NOT the internal material number. */
+  supplier_article_number?: string | null;
+  /** Quoted price for comparison — never the cost basis (spool.cost_per_kg). */
+  quoted_price_per_kg?: number | null;
+  /** Marks where this concrete spool was actually bought. */
+  is_purchase_source?: boolean;
+}
+
+export interface SpoolSupplierLink {
+  id: number;
+  supplier_id: number;
+  supplier_name: string;
+  supplier_article_number: string | null;
+  quoted_price_per_kg: number | null;
+  is_purchase_source: boolean;
+}
+
+/** Per-supplier inventory aggregate (#2988), purchase-source spools only. */
+export interface SupplierStats {
+  supplier_id: number;
+  supplier_name: string;
+  spool_count: number;
+  remaining_g: number;
+  consumed_g: number;
+  cost: number;
 }
 
 export interface SpoolmanBulkCreateResult {
@@ -6662,6 +6717,36 @@ export const api = {
     request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
   resetSpoolCatalog: () =>
     request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
+  // ── Suppliers (#2988) — inventory master data, Locations pattern ─────────
+  getSuppliers: () =>
+    request<Supplier[]>('/inventory/suppliers'),
+  createSupplier: (data: SupplierInput) =>
+    request<Supplier>('/inventory/suppliers', { method: 'POST', body: JSON.stringify(data) }),
+  updateSupplier: (id: number, data: Partial<SupplierInput>) =>
+    request<Supplier>(`/inventory/suppliers/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
+  deleteSupplier: (id: number) =>
+    request<{ status: string }>(`/inventory/suppliers/${id}`, { method: 'DELETE' }),
+  setSpoolSuppliers: (spoolId: number, links: SpoolSupplierLinkInput[]) =>
+    request<SpoolSupplierLink[]>(`/inventory/spools/${spoolId}/suppliers`, {
+      method: 'PUT',
+      body: JSON.stringify(links),
+    }),
+  // Spoolman parity: the assignment rows live Bambuddy-side, keyed by the
+  // remote spool id — same request/response shape as the built-in inventory.
+  setSpoolmanSpoolSuppliers: (spoolmanSpoolId: number, links: SpoolSupplierLinkInput[]) =>
+    request<SpoolSupplierLink[]>(`/spoolman/inventory/spools/${spoolmanSpoolId}/suppliers`, {
+      method: 'PUT',
+      body: JSON.stringify(links),
+    }),
+  // date_from/date_to scope the usage half only, so the widget can follow the
+  // dashboard timeframe; stock stays point-in-time.
+  getSupplierStats: (dateFrom?: string, dateTo?: string) => {
+    const params = new URLSearchParams();
+    if (dateFrom) params.set('date_from', dateFrom);
+    if (dateTo) params.set('date_to', dateTo);
+    const qs = params.toString();
+    return request<SupplierStats[]>(`/inventory/stats/suppliers${qs ? `?${qs}` : ''}`);
+  },
   getLocations: () =>
     request<StorageLocation[]>('/inventory/locations'),
   createLocation: (data: { name: string; identifier?: string | null }) =>

+ 82 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -20,6 +20,7 @@ import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
+import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { normaliseFlow } from '../utils/nozzleFlow';
@@ -81,6 +82,13 @@ export function SpoolFormModal({
   // through core_weight.
   const [coreWeightTouched, setCoreWeightTouched] = useState(false);
   const [locationIdTouched, setLocationIdTouched] = useState(false);
+  // Supplier assignments (#2988). Held outside SpoolFormData — they are
+  // relational and saved through their own replace-all endpoint. An untouched
+  // create does not send them, so it keeps the backend's inherited
+  // assignments instead of wiping them with an empty list; a copy always
+  // sends them, see saveSupplierLinks.
+  const [supplierLinks, setSupplierLinks] = useState<SupplierLinkDraft[]>([]);
+  const [supplierLinksTouched, setSupplierLinksTouched] = useState(false);
   const [quickAdd, setQuickAdd] = useState(false);
   const [quantity, setQuantity] = useState(1);
 
@@ -446,10 +454,23 @@ export function SpoolFormModal({
         } else {
           setSelectedProfiles(new Map());
         }
+        // Supplier assignments (#2988) — copied on copy: they describe where
+        // the product is bought, which is what a copy shares.
+        setSupplierLinks(
+          (spool.suppliers ?? []).map((link) => ({
+            supplier_id: link.supplier_id,
+            supplier_name: link.supplier_name,
+            supplier_article_number: link.supplier_article_number ?? '',
+            quoted_price_per_kg: link.quoted_price_per_kg,
+            // Where a COPY was bought is unknown — only a real edit keeps it.
+            is_purchase_source: isCopying ? false : link.is_purchase_source,
+          }))
+        );
       } else {
         setFormData(defaultFormData);
         setPresetInputValue('');
         setSelectedProfiles(new Map());
+        setSupplierLinks([]);
       }
       // Reset on every open, not just the create path (#1905). The modal keeps
       // its state while closed, and the Quick Add toggle only renders in create
@@ -471,6 +492,7 @@ export function SpoolFormModal({
       // inheriting. Only Spoolman spools report the flag (#2908).
       setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
       setLocationIdTouched(false);
+      setSupplierLinksTouched(false);
     }
   }, [isOpen, spool, mode, isCopying]);
 
@@ -582,6 +604,8 @@ export function SpoolFormModal({
       if (newSpool?.id) {
         const ok = await savePrinterProfiles(newSpool.id);
         if (!ok) return;
+        const suppliersOk = await saveSupplierLinks(newSpool.id);
+        if (!suppliersOk) return;
       }
       await refreshSpoolQueries();
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
@@ -625,6 +649,12 @@ export function SpoolFormModal({
           await savePrinterProfiles(s.id);
         }
       }
+      // Every copy of a bulk add shares the same supplier assignments (#2988).
+      if (shouldSaveSupplierLinks) {
+        for (const s of createdSpools) {
+          await saveSupplierLinks(s.id);
+        }
+      }
       await refreshSpoolQueries();
       if (onSpoolsCreated) onSpoolsCreated(createdSpools);
       if (spoolmanResult && spoolmanResult.failed_count > 0) {
@@ -658,6 +688,8 @@ export function SpoolFormModal({
       if (spool?.id) {
         const ok = await savePrinterProfiles(spool.id);
         if (!ok) return;
+        const suppliersOk = await saveSupplierLinks(spool.id);
+        if (!suppliersOk) return;
       }
       await refreshSpoolQueries();
       showToast(t('inventory.spoolUpdated'), 'success');
@@ -774,6 +806,41 @@ export function SpoolFormModal({
     },
   });
 
+  // Supplier assignments (#2988): replace-all save.
+  //
+  // Skipped on an untouched create so the backend's inheritance can fill the
+  // new spool in — an empty list would wipe what it just attached. A COPY is
+  // the opposite case and always saves: the dialog seeded the chips from the
+  // spool being copied and showed them, so they have to be what the copy
+  // gets. Inheritance cannot stand in for that — it keys on the (material,
+  // subtype, brand, color_name) tuple and so resolves to the NEWEST spool of
+  // the product rather than the one on screen, and in Spoolman mode there is
+  // no inheritance at all.
+  const shouldSaveSupplierLinks = supplierLinksTouched || isCopying;
+
+  const saveSupplierLinks = async (spoolId: number): Promise<boolean> => {
+    if (!shouldSaveSupplierLinks) return true;
+    // Spoolman parity (#2988): the assignment rows live Bambuddy-side either
+    // way; only the endpoint differs (twin table keyed by the remote id).
+    const save = spoolmanMode ? api.setSpoolmanSpoolSuppliers : api.setSpoolSuppliers;
+    try {
+      await save(
+        spoolId,
+        supplierLinks.map((link) => ({
+          supplier_id: link.supplier_id,
+          supplier_article_number: link.supplier_article_number.trim() || null,
+          quoted_price_per_kg: link.quoted_price_per_kg,
+          is_purchase_source: link.is_purchase_source,
+        })),
+      );
+      return true;
+    } catch (err) {
+      console.error('SpoolFormModal.saveSupplierLinks failed:', err);
+      showToast(t('inventory.suppliers.saveFailed'), 'error');
+      return false;
+    }
+  };
+
   // Save everything the Printers tab holds: one K profile per hotend and the
   // per-printer-model preset overrides. Returns false if either write failed,
   // which keeps the modal open so the user does not lose what they picked.
@@ -1117,6 +1184,21 @@ export function SpoolFormModal({
                 />
               </div>
 
+              {/* Suppliers (#2988) — both inventories: the assignments live
+                  Bambuddy-side either way (Spoolman's vendor is the
+                  manufacturer, not the seller), so the same section renders
+                  in Spoolman mode and saves to the twin endpoint. */}
+              <div>
+                <SupplierSection
+                  links={supplierLinks}
+                  onChange={(next) => {
+                    setSupplierLinks(next);
+                    setSupplierLinksTouched(true);
+                  }}
+                  currencySymbol={currencySymbol}
+                />
+              </div>
+
               {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
               {isEditing && spool && !spoolmanMode && (
                 <div>

+ 82 - 0
frontend/src/components/SupplierStats.tsx

@@ -0,0 +1,82 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+
+// Consumption, cost and stock grouped by the purchase-source supplier
+// (#2988) — "how much did we run through supplier X" straight from the
+// recorded usage history, archived spools included.
+
+interface SupplierStatsProps {
+  currency: string;
+  // The dashboard timeframe, the same one every other widget on that page
+  // honours. It scopes consumption and cost; stock is point-in-time.
+  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 SupplierStats({ currency, dateFrom, dateTo }: SupplierStatsProps) {
+  const { t } = useTranslation();
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['supplier-stats', dateFrom ?? 'all', dateTo ?? 'all'],
+    queryFn: () => api.getSupplierStats(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: reporting a 403 or a 500 as
+  // "you have not assigned any" tells the user they have not done something
+  // they have.
+  if (isError) {
+    return (
+      <p className="flex items-center gap-2 text-sm text-red-400 py-4">
+        <AlertTriangle className="w-4 h-4" />
+        {t('stats.suppliers.error')}
+      </p>
+    );
+  }
+
+  if (!data || data.length === 0) {
+    return <p className="text-sm text-bambu-gray py-4">{t('stats.suppliers.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('stats.suppliers.supplier')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.suppliers.spools')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.suppliers.remaining')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.suppliers.consumed')}</th>
+            <th className="py-2 font-medium text-right">{t('stats.suppliers.cost')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {data.map((row) => (
+            <tr key={row.supplier_id} className="border-b border-bambu-dark-tertiary/50 last:border-b-0">
+              <td className="py-2 pr-4 text-white font-medium">{row.supplier_name}</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>
+  );
+}

+ 386 - 0
frontend/src/components/SuppliersModal.tsx

@@ -0,0 +1,386 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Store, Plus, Trash2, Loader2, Pencil, Check, X, Search, ExternalLink, AlertTriangle } from 'lucide-react';
+import { api, ApiError } from '../api/client';
+import type { Supplier } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { inventorySuppliersQueryKey, invalidateInventorySuppliers } from '../utils/inventoryQueries';
+import { ConfirmModal } from './ConfirmModal';
+
+interface SuppliersModalProps {
+  open: boolean;
+  onClose: () => void;
+}
+
+// Managed supplier master list (#2988) — where filament is bought, distinct
+// from brand (who made it). Inventory master data that spools reference, so
+// it opens from the Inventory toolbar exactly like Locations, in both the
+// built-in and the Spoolman inventory.
+export function SuppliersModal({ open, onClose }: SuppliersModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  // The list lives in react-query under the key useWebSocket invalidates on
+  // inventory_changed, so a supplier created inline in the spool dialog — or
+  // by another user, in another tab — shows up here (#2988).
+  const { data: suppliers = [], isLoading: loading, isError } = useQuery({
+    queryKey: inventorySuppliersQueryKey,
+    queryFn: api.getSuppliers,
+    enabled: open,
+  });
+  const [search, setSearch] = useState('');
+
+  // Add/Edit form state
+  const [showAddForm, setShowAddForm] = useState(false);
+  const [editingId, setEditingId] = useState<number | null>(null);
+  const [formName, setFormName] = useState('');
+  const [formWebsite, setFormWebsite] = useState('');
+  const [formCustomerNumber, setFormCustomerNumber] = useState('');
+  const [formNote, setFormNote] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  const [deleteSupplier, setDeleteSupplier] = useState<Supplier | null>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [open, onClose]);
+
+  if (!open) return null;
+
+  const filtered = suppliers.filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
+
+  const resetForm = () => {
+    setFormName('');
+    setFormWebsite('');
+    setFormCustomerNumber('');
+    setFormNote('');
+  };
+
+  const formPayload = () => ({
+    name: formName.trim(),
+    website: formWebsite.trim() || null,
+    customer_number: formCustomerNumber.trim() || null,
+    note: formNote.trim() || null,
+  });
+
+  const handleAdd = async () => {
+    if (!formName.trim()) {
+      showToast(t('inventory.suppliers.nameRequired'), 'error');
+      return;
+    }
+    setSaving(true);
+    try {
+      await api.createSupplier(formPayload());
+      await invalidateInventorySuppliers(queryClient);
+      setShowAddForm(false);
+      resetForm();
+      showToast(t('inventory.suppliers.added'), 'success');
+    } catch (err) {
+      console.error('SuppliersModal.handleAdd failed:', err);
+      // 409: the name is taken — say so instead of a generic failure.
+      showToast(
+        t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.addFailed'),
+        'error',
+      );
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const startEdit = (supplier: Supplier) => {
+    setEditingId(supplier.id);
+    setFormName(supplier.name);
+    setFormWebsite(supplier.website ?? '');
+    setFormCustomerNumber(supplier.customer_number ?? '');
+    setFormNote(supplier.note ?? '');
+  };
+
+  const cancelEdit = () => {
+    setEditingId(null);
+    resetForm();
+  };
+
+  const handleUpdate = async (id: number) => {
+    if (!formName.trim()) {
+      showToast(t('inventory.suppliers.nameRequired'), 'error');
+      return;
+    }
+    setSaving(true);
+    try {
+      await api.updateSupplier(id, formPayload());
+      await invalidateInventorySuppliers(queryClient);
+      cancelEdit();
+      showToast(t('inventory.suppliers.updated'), 'success');
+    } catch (err) {
+      console.error('SuppliersModal.handleUpdate failed:', err);
+      showToast(
+        t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.updateFailed'),
+        'error',
+      );
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleDelete = async () => {
+    if (!deleteSupplier) return;
+    try {
+      await api.deleteSupplier(deleteSupplier.id);
+      await invalidateInventorySuppliers(queryClient);
+      showToast(t('inventory.suppliers.deleted'), 'success');
+    } catch (err) {
+      console.error('SuppliersModal.handleDelete failed:', err);
+      // 409: still referenced by spools — surface the guard, not a generic error.
+      if (err instanceof ApiError && err.status === 409) {
+        showToast(t('inventory.suppliers.deleteBlocked', { count: deleteSupplier.spool_count }), 'error');
+      } else {
+        showToast(t('inventory.suppliers.deleteFailed'), 'error');
+      }
+    } finally {
+      setDeleteSupplier(null);
+    }
+  };
+
+  const formFields = (
+    <div className="space-y-2">
+      <input
+        type="text"
+        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+        placeholder={t('inventory.suppliers.namePlaceholder')}
+        value={formName}
+        maxLength={200}
+        onChange={(e) => setFormName(e.target.value)}
+      />
+      <div className="flex gap-2">
+        <input
+          type="text"
+          className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+          placeholder={t('inventory.suppliers.websitePlaceholder')}
+          value={formWebsite}
+          maxLength={500}
+          onChange={(e) => setFormWebsite(e.target.value)}
+        />
+        <input
+          type="text"
+          className="w-40 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+          placeholder={t('inventory.suppliers.customerNumberPlaceholder')}
+          value={formCustomerNumber}
+          maxLength={100}
+          onChange={(e) => setFormCustomerNumber(e.target.value)}
+        />
+      </div>
+      <input
+        type="text"
+        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+        placeholder={t('inventory.suppliers.notePlaceholder')}
+        value={formNote}
+        maxLength={500}
+        onChange={(e) => setFormNote(e.target.value)}
+      />
+    </div>
+  );
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      <div className="absolute inset-0 bg-black/60" onClick={onClose} />
+      <div
+        className="relative w-full max-w-3xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="suppliers-modal-title"
+      >
+        <div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <Store className="w-5 h-5 text-bambu-gray" />
+            <h2 id="suppliers-modal-title" className="text-lg font-semibold text-white">
+              {t('inventory.suppliers.title')}
+            </h2>
+            <span className="text-sm text-bambu-gray">({suppliers.length})</span>
+          </div>
+          <div className="flex items-center gap-2">
+            <button
+              onClick={() => { setShowAddForm(true); setEditingId(null); resetForm(); }}
+              className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
+            >
+              <Plus className="w-4 h-4" />
+              <span className="hidden sm:inline">{t('common.add')}</span>
+            </button>
+            <button
+              onClick={onClose}
+              className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+              aria-label={t('common.close')}
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+
+        <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-4">
+          <p className="text-sm text-bambu-gray">{t('inventory.suppliers.description')}</p>
+
+          <div className="relative">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+            <input
+              type="text"
+              className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+              placeholder={t('inventory.suppliers.search')}
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+            />
+          </div>
+
+          {showAddForm && (
+            <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary space-y-3">
+              <h3 className="text-sm font-medium text-white">{t('inventory.suppliers.addNew')}</h3>
+              {formFields}
+              <div className="flex gap-2 justify-end">
+                <button
+                  onClick={() => { setShowAddForm(false); resetForm(); }}
+                  className="px-3 py-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+                >
+                  {t('common.cancel')}
+                </button>
+                <button
+                  onClick={handleAdd}
+                  disabled={saving}
+                  className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1"
+                >
+                  {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                  {t('common.add')}
+                </button>
+              </div>
+            </div>
+          )}
+
+          {loading ? (
+            <div className="flex items-center justify-center py-8 text-bambu-gray">
+              <Loader2 className="w-5 h-5 animate-spin mr-2" />
+              {t('common.loading')}
+            </div>
+          ) : isError ? (
+            // A failed load must not read as "no suppliers yet" — that invites
+            // the user to create one that already exists.
+            <div className="flex items-center justify-center gap-2 py-8 text-sm text-red-400">
+              <AlertTriangle className="w-4 h-4" />
+              {t('inventory.suppliers.loadFailed')}
+            </div>
+          ) : (
+            <div className="border border-bambu-dark-tertiary rounded-lg">
+              <table className="w-full text-sm">
+                <thead className="bg-bambu-dark sticky top-0">
+                  <tr>
+                    <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
+                    <th className="px-4 py-2 text-left text-bambu-gray font-medium hidden sm:table-cell">
+                      {t('inventory.suppliers.customerNumber')}
+                    </th>
+                    <th className="px-4 py-2 text-right text-bambu-gray font-medium w-20">
+                      {t('inventory.suppliers.spools')}
+                    </th>
+                    <th className="px-4 py-2 w-24"></th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {filtered.length === 0 ? (
+                    <tr>
+                      <td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
+                        {search ? t('inventory.suppliers.noMatch') : t('inventory.suppliers.empty')}
+                      </td>
+                    </tr>
+                  ) : (
+                    filtered.map((supplier) => (
+                      <tr key={supplier.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark align-top">
+                        {editingId === supplier.id ? (
+                          <>
+                            <td className="px-4 py-2" colSpan={3}>{formFields}</td>
+                            <td className="px-4 py-2">
+                              <div className="flex justify-end gap-1">
+                                <button
+                                  onClick={() => handleUpdate(supplier.id)}
+                                  disabled={saving}
+                                  className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
+                                >
+                                  {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                                </button>
+                                <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
+                                  <X className="w-4 h-4" />
+                                </button>
+                              </div>
+                            </td>
+                          </>
+                        ) : (
+                          <>
+                            <td className="px-4 py-2 text-white">
+                              <div className="flex items-center gap-1.5">
+                                <span>{supplier.name}</span>
+                                {supplier.website && (
+                                  <a
+                                    href={supplier.website}
+                                    target="_blank"
+                                    rel="noopener noreferrer"
+                                    className="text-bambu-gray hover:text-bambu-green"
+                                    title={supplier.website}
+                                  >
+                                    <ExternalLink className="w-3.5 h-3.5" />
+                                  </a>
+                                )}
+                              </div>
+                              {supplier.note && <div className="text-xs text-bambu-gray mt-0.5">{supplier.note}</div>}
+                            </td>
+                            <td className="px-4 py-2 text-bambu-gray hidden sm:table-cell">
+                              {supplier.customer_number || '-'}
+                            </td>
+                            <td className="px-4 py-2 text-right font-mono text-bambu-gray">{supplier.spool_count}</td>
+                            <td className="px-4 py-2">
+                              <div className="flex justify-end gap-1">
+                                <button
+                                  onClick={() => { startEdit(supplier); setShowAddForm(false); }}
+                                  className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+                                >
+                                  <Pencil className="w-4 h-4" />
+                                </button>
+                                <button
+                                  onClick={() => setDeleteSupplier(supplier)}
+                                  className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
+                                >
+                                  <Trash2 className="w-4 h-4" />
+                                </button>
+                              </div>
+                            </td>
+                          </>
+                        )}
+                      </tr>
+                    ))
+                  )}
+                </tbody>
+              </table>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {deleteSupplier && (
+        <ConfirmModal
+          title={t('inventory.suppliers.deleteTitle')}
+          message={
+            deleteSupplier.spool_count > 0
+              ? t('inventory.suppliers.deleteConfirmReferenced', {
+                  name: deleteSupplier.name,
+                  count: deleteSupplier.spool_count,
+                })
+              : t('inventory.suppliers.deleteConfirm', { name: deleteSupplier.name })
+          }
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={handleDelete}
+          onCancel={() => setDeleteSupplier(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 255 - 0
frontend/src/components/spool-form/SupplierSection.tsx

@@ -0,0 +1,255 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, ChevronDown, Loader2, Plus, Store, X } from 'lucide-react';
+import { api, ApiError } from '../../api/client';
+import type { Supplier } from '../../api/client';
+import { useToast } from '../../contexts/ToastContext';
+import { inventorySuppliersQueryKey, invalidateInventorySuppliers } from '../../utils/inventoryQueries';
+
+// One editable supplier assignment as the form holds it (#2988). Mirrors
+// SpoolSupplierLinkInput but keeps the name so chips render without lookups.
+export interface SupplierLinkDraft {
+  supplier_id: number;
+  supplier_name: string;
+  supplier_article_number: string;
+  /** Quoted price for comparison — never the cost basis (spool.cost_per_kg). */
+  quoted_price_per_kg: number | null;
+  is_purchase_source: boolean;
+}
+
+interface SupplierSectionProps {
+  links: SupplierLinkDraft[];
+  onChange: (links: SupplierLinkDraft[]) => void;
+  currencySymbol: string;
+}
+
+// Multi-select with chips for the spool dialog (#2988): a material can come
+// from several suppliers. Each chip carries the per-assignment fields
+// (supplier's article number, price there, bought-here marker) and "+ new
+// supplier" creates a master-list entry without leaving the dialog.
+export function SupplierSection({ links, onChange, currencySymbol }: SupplierSectionProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [open, setOpen] = useState(false);
+  const [search, setSearch] = useState('');
+  const [creating, setCreating] = useState(false);
+  // Shared key with SuppliersModal, invalidated by the inventory_changed
+  // broadcast — a supplier created in either place shows up in both (#2988).
+  const { data: suppliers = [], isLoading, isError } = useQuery({
+    queryKey: inventorySuppliersQueryKey,
+    queryFn: api.getSuppliers,
+  });
+
+  useEffect(() => {
+    if (!open) return;
+    const onDocClick = (e: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+        setOpen(false);
+        setSearch('');
+      }
+    };
+    document.addEventListener('mousedown', onDocClick);
+    return () => document.removeEventListener('mousedown', onDocClick);
+  }, [open]);
+
+  const linkedIds = useMemo(() => new Set(links.map((l) => l.supplier_id)), [links]);
+  const candidates = useMemo(
+    () =>
+      suppliers
+        .filter((s) => !linkedIds.has(s.id))
+        .filter((s) => s.name.toLowerCase().includes(search.trim().toLowerCase())),
+    [suppliers, linkedIds, search]
+  );
+  const exactMatch = suppliers.some((s) => s.name.toLowerCase() === search.trim().toLowerCase());
+
+  const addLink = (supplier: Supplier) => {
+    onChange([
+      ...links,
+      {
+        supplier_id: supplier.id,
+        supplier_name: supplier.name,
+        supplier_article_number: '',
+        quoted_price_per_kg: null,
+        is_purchase_source: false,
+      },
+    ]);
+    setOpen(false);
+    setSearch('');
+  };
+
+  const createAndAdd = async () => {
+    const name = search.trim();
+    if (!name) return;
+    setCreating(true);
+    try {
+      const supplier = await api.createSupplier({ name });
+      await invalidateInventorySuppliers(queryClient);
+      addLink(supplier);
+    } catch (err) {
+      console.error('SupplierSection.createAndAdd failed:', err);
+      showToast(
+        t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.createFailed'),
+        'error',
+      );
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  const updateLink = (supplierId: number, patch: Partial<SupplierLinkDraft>) => {
+    onChange(
+      links.map((link) => {
+        if (link.supplier_id !== supplierId) {
+          // Only one assignment can be the purchase source.
+          return patch.is_purchase_source ? { ...link, is_purchase_source: false } : link;
+        }
+        return { ...link, ...patch };
+      })
+    );
+  };
+
+  const removeLink = (supplierId: number) => {
+    onChange(links.filter((link) => link.supplier_id !== supplierId));
+  };
+
+  return (
+    <div>
+      <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.suppliers.label')}</label>
+
+      {links.length > 0 && (
+        <div className="space-y-2 mb-2">
+          {links.map((link) => (
+            <div key={link.supplier_id} className="p-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
+              <div className="flex items-center gap-2">
+                <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/10 text-bambu-green">
+                  <Store className="w-3 h-3" />
+                  {link.supplier_name}
+                </span>
+                <label
+                  className="ml-auto flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer"
+                  title={t('inventory.suppliers.purchaseSourceHelp')}
+                >
+                  <input
+                    type="checkbox"
+                    className="w-3.5 h-3.5 accent-bambu-green"
+                    checked={link.is_purchase_source}
+                    onChange={(e) => updateLink(link.supplier_id, { is_purchase_source: e.target.checked })}
+                  />
+                  {t('inventory.suppliers.purchaseSource')}
+                </label>
+                <button
+                  type="button"
+                  onClick={() => removeLink(link.supplier_id)}
+                  className="p-1 rounded text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+                  aria-label={t('common.remove')}
+                >
+                  <X className="w-3.5 h-3.5" />
+                </button>
+              </div>
+              <div className="flex gap-2 mt-2">
+                <input
+                  type="text"
+                  className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+                  placeholder={t('inventory.suppliers.articleNumberPlaceholder')}
+                  value={link.supplier_article_number}
+                  maxLength={100}
+                  onChange={(e) => updateLink(link.supplier_id, { supplier_article_number: e.target.value })}
+                />
+                <div className="relative w-28">
+                  <span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray pointer-events-none">
+                    {currencySymbol}
+                  </span>
+                  <input
+                    type="number"
+                    className="w-full pl-6 pr-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs text-right placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+                    placeholder={t('inventory.suppliers.pricePlaceholder')}
+                    title={t('inventory.suppliers.priceHelp')}
+                    min={0}
+                    step={0.01}
+                    value={link.quoted_price_per_kg ?? ''}
+                    onChange={(e) =>
+                      updateLink(link.supplier_id, {
+                        quoted_price_per_kg: e.target.value === '' ? null : parseFloat(e.target.value),
+                      })
+                    }
+                  />
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      <div className="relative" ref={containerRef}>
+        <button
+          type="button"
+          onClick={() => setOpen((prev) => !prev)}
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-bambu-gray hover:text-white flex items-center justify-between transition-colors focus:outline-none focus:border-bambu-green"
+        >
+          <span>{t('inventory.suppliers.addSupplier')}</span>
+          <ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
+        </button>
+        {open && (
+          <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg">
+            <div className="p-2 border-b border-bambu-dark-tertiary">
+              <input
+                type="text"
+                autoFocus
+                className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+                placeholder={t('inventory.suppliers.searchPlaceholder')}
+                value={search}
+                onChange={(e) => setSearch(e.target.value)}
+              />
+            </div>
+            <div className="max-h-48 overflow-y-auto py-1">
+              {isLoading ? (
+                <div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('common.loading')}
+                </div>
+              ) : isError ? (
+                // An empty list here would invite the user to create a
+                // supplier that already exists, so say the load failed.
+                <div className="px-3 py-2 text-sm text-red-400 flex items-center gap-2">
+                  <AlertTriangle className="w-4 h-4" />
+                  {t('inventory.suppliers.loadFailed')}
+                </div>
+              ) : (
+                <>
+                  {candidates.map((supplier) => (
+                    <button
+                      key={supplier.id}
+                      type="button"
+                      onClick={() => addLink(supplier)}
+                      className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark-tertiary"
+                    >
+                      {supplier.name}
+                    </button>
+                  ))}
+                  {candidates.length === 0 && !search.trim() && (
+                    <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.suppliers.noneLeft')}</div>
+                  )}
+                  {search.trim() && !exactMatch && (
+                    <button
+                      type="button"
+                      onClick={createAndAdd}
+                      disabled={creating}
+                      className="w-full px-3 py-2 text-left text-sm text-bambu-green hover:bg-bambu-dark-tertiary flex items-center gap-1.5"
+                    >
+                      {creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
+                      {t('inventory.suppliers.createNew', { name: search.trim() })}
+                    </button>
+                  )}
+                </>
+              )}
+            </div>
+          </div>
+        )}
+      </div>
+      <p className="text-xs text-bambu-gray mt-1">{t('inventory.suppliers.help')}</p>
+    </div>
+  );
+}

+ 2 - 1
frontend/src/hooks/useWebSocket.ts

@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
 import { useToast } from '../contexts/ToastContext';
 import { useTranslation } from 'react-i18next';
 import { api, ApiError } from '../api/client';
-import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
+import { inventoryLocationsQueryKey, inventorySuppliersQueryKey } from '../utils/inventoryQueries';
 
 // The only auth-failure close code /api/v1/ws emits (websocket.py
 // _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
@@ -408,6 +408,7 @@ export function useWebSocket() {
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('spoolman-inventory-spools');
         debouncedInvalidate(inventoryLocationsQueryKey[0]);
+        debouncedInvalidate(inventorySuppliersQueryKey[0]);
         break;
 
       case 'spool_assignment_changed':

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

@@ -1670,6 +1670,17 @@ export default {
     printActivity: 'Druckaktivität',
     filamentTypes: 'Filamenttypen',
     filamentTrends: 'Filamenttrends',
+    // Consumption/cost grouped by purchase-source supplier (#2988)
+    suppliers: {
+      title: 'Nach Lieferant',
+      empty: 'Noch keine Käufe erfasst. Markieren Sie an einer Spule, bei welchem Lieferanten sie gekauft wurde, um Verbrauch und Kosten hier zu gruppieren.',
+      error: 'Lieferantenstatistik konnte nicht geladen werden',
+      supplier: 'Lieferant',
+      spools: 'Spulen',
+      remaining: 'Verbleibend',
+      consumed: 'Verbraucht',
+      cost: 'Kosten',
+    },
     failureAnalysis: 'Fehleranalyse',
     timeAccuracy: 'Zeitgenauigkeit',
     successful: 'Erfolgreich:',
@@ -4814,6 +4825,48 @@ export default {
     storageLocationNone: 'Kein Lagerort',
     lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
     lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
+    // Suppliers (#2988): the master list and the per-spool assignments.
+    suppliers: {
+      label: 'Lieferanten',
+      none: 'Kein Lieferant',
+      addSupplier: 'Lieferant hinzufügen…',
+      searchPlaceholder: 'Lieferanten durchsuchen…',
+      noneLeft: 'Alle Lieferanten sind bereits zugewiesen',
+      createNew: '"{{name}}" anlegen',
+      createFailed: 'Lieferant konnte nicht angelegt werden',
+      purchaseSource: 'Hier gekauft',
+      purchaseSourceHelp: 'Markiert, wo diese Spule tatsächlich gekauft wurde; die übrigen Einträge sind alternative Bezugsquellen.',
+      articleNumberPlaceholder: 'Artikelnr. des Lieferanten',
+      pricePlaceholder: 'Angebotspreis/kg',
+      priceHelp: 'Angebotspreis bei diesem Lieferanten, zum Vergleich der Bezugsquellen — der eigene Preis pro kg der Spule bleibt die Kostenbasis.',
+      help: 'Wo dieses Produkt gekauft werden kann. Der hervorgehobene Eintrag ist die Bezugsquelle dieser Spule.',
+      saveFailed: 'Lieferantenzuordnungen konnten nicht gespeichert werden',
+      title: 'Lieferanten',
+      description: 'Verwalten Sie, wo Sie Filament kaufen — nicht zu verwechseln mit der Marke, also dem Hersteller. Weisen Sie Spulen im Spulendialog Lieferanten zu; ein Lieferant, der noch Spulen zugewiesen ist, kann nicht gelöscht werden.',
+      search: 'Lieferanten durchsuchen...',
+      addNew: 'Neuen Lieferanten hinzufügen',
+      namePlaceholder: 'Name (z. B. Filament24)',
+      websitePlaceholder: 'Website / Shop-URL',
+      customerNumberPlaceholder: 'Kundennr.',
+      notePlaceholder: 'Notiz',
+      nameRequired: 'Name ist erforderlich',
+      duplicateName: 'Ein Lieferant mit diesem Namen existiert bereits',
+      customerNumber: 'Kundennr.',
+      spools: 'Spulen',
+      noMatch: 'Keine Lieferanten entsprechen Ihrer Suche',
+      empty: 'Noch keine Lieferanten',
+      added: 'Lieferant hinzugefügt',
+      addFailed: 'Lieferant konnte nicht hinzugefügt werden',
+      updated: 'Lieferant aktualisiert',
+      updateFailed: 'Lieferant konnte nicht aktualisiert werden',
+      deleted: 'Lieferant gelöscht',
+      deleteFailed: 'Lieferant konnte nicht gelöscht werden',
+      deleteBlocked: 'Löschen nicht möglich: noch {{count}} Spule(n) zugewiesen',
+      loadFailed: 'Lieferanten konnten nicht geladen werden',
+      deleteTitle: 'Lieferant löschen',
+      deleteConfirm: '"{{name}}" löschen?',
+      deleteConfirmReferenced: '"{{name}}" ist {{count}} Spule(n) zugewiesen. Das Löschen ist blockiert, bis diese Zuordnungen entfernt wurden.',
+    },
     // RFID button rename (was "Tag löschen")
     clearRfid: 'RFID-Tag löschen',
     rfidCleared: 'RFID-Tag gelöscht',

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

@@ -1687,6 +1687,17 @@ export default {
     printActivity: 'Print Activity',
     filamentTypes: 'Filament Types',
     filamentTrends: 'Filament Trends',
+    // Consumption/cost grouped by purchase-source supplier (#2988).
+    suppliers: {
+      title: 'By Supplier',
+      empty: 'No purchases recorded yet. Mark on a spool which supplier it was bought from to group consumption and costs here.',
+      error: 'Could not load supplier statistics',
+      supplier: 'Supplier',
+      spools: 'Spools',
+      remaining: 'Remaining',
+      consumed: 'Consumed',
+      cost: 'Cost',
+    },
     failureAnalysis: 'Failure Analysis',
     timeAccuracy: 'Time Accuracy',
     successful: 'Successful:',
@@ -4854,6 +4865,48 @@ export default {
     storageLocationNone: 'No location set',
     lowStockThresholdOverride: 'Low-stock threshold (this spool)',
     lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
+    // Suppliers (#2988): the master list and the per-spool assignments.
+    suppliers: {
+      label: 'Suppliers',
+      none: 'No supplier',
+      addSupplier: 'Add supplier…',
+      searchPlaceholder: 'Search suppliers…',
+      noneLeft: 'All suppliers are already assigned',
+      createNew: 'Create "{{name}}"',
+      createFailed: 'Failed to create supplier',
+      purchaseSource: 'Bought here',
+      purchaseSourceHelp: 'Marks where this spool was actually purchased; the other entries are alternative sources.',
+      articleNumberPlaceholder: 'Supplier article no.',
+      pricePlaceholder: 'Quoted price/kg',
+      priceHelp: 'Quoted price at this supplier, for comparing sources — the spool\'s own cost per kg stays the cost basis.',
+      help: 'Where this product can be bought. The highlighted entry is where this spool was purchased.',
+      saveFailed: 'Failed to save supplier assignments',
+      title: 'Suppliers',
+      description: 'Manage where you buy filament — distinct from brand, which is who made it. Assign suppliers to spools in the spool dialog; a supplier that is still assigned to spools cannot be deleted.',
+      search: 'Search suppliers...',
+      addNew: 'Add new supplier',
+      namePlaceholder: 'Name (e.g. Filament24)',
+      websitePlaceholder: 'Website / shop URL',
+      customerNumberPlaceholder: 'Customer no.',
+      notePlaceholder: 'Note',
+      nameRequired: 'Name is required',
+      duplicateName: 'A supplier with this name already exists',
+      customerNumber: 'Customer no.',
+      spools: 'Spools',
+      noMatch: 'No suppliers match your search',
+      empty: 'No suppliers yet',
+      added: 'Supplier added',
+      addFailed: 'Failed to add supplier',
+      updated: 'Supplier updated',
+      updateFailed: 'Failed to update supplier',
+      deleted: 'Supplier deleted',
+      deleteFailed: 'Failed to delete supplier',
+      deleteBlocked: 'Cannot delete: still assigned to {{count}} spool(s)',
+      loadFailed: 'Failed to load suppliers',
+      deleteTitle: 'Delete Supplier',
+      deleteConfirm: 'Delete "{{name}}"?',
+      deleteConfirmReferenced: '"{{name}}" is assigned to {{count}} spool(s). Deleting is blocked until those assignments are removed.',
+    },
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: 'Clear RFID Tag',

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

@@ -1670,6 +1670,17 @@ export default {
     printActivity: 'Actividad de impresión',
     filamentTypes: 'Tipos de filamento',
     filamentTrends: 'Tendencias del filamento',
+    // Consumption/cost grouped by purchase-source supplier (#2988).
+    suppliers: {
+      title: 'Por proveedor',
+      empty: 'Aún no hay compras registradas. Marque en una bobina a qué proveedor se compró para agrupar aquí el consumo y los costes.',
+      error: 'No se pudieron cargar las estadísticas de proveedores',
+      supplier: 'Proveedor',
+      spools: 'Bobinas',
+      remaining: 'Restante',
+      consumed: 'Consumido',
+      cost: 'Coste',
+    },
     failureAnalysis: 'Análisis de fallos',
     timeAccuracy: 'Precisión temporal',
     successful: 'Con éxito:',
@@ -4817,6 +4828,48 @@ 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}}%).',
+    // Suppliers (#2988): the master list and the per-spool assignments.
+    suppliers: {
+      label: 'Proveedores',
+      none: 'Sin proveedor',
+      addSupplier: 'Añadir proveedor…',
+      searchPlaceholder: 'Buscar proveedores…',
+      noneLeft: 'Todos los proveedores ya están asignados',
+      createNew: 'Crear "{{name}}"',
+      createFailed: 'Error al crear el proveedor',
+      purchaseSource: 'Comprado aquí',
+      purchaseSourceHelp: 'Indica dónde se compró realmente esta bobina; las demás entradas son fuentes alternativas.',
+      articleNumberPlaceholder: 'N.º de artículo del proveedor',
+      pricePlaceholder: 'Precio ofertado/kg',
+      priceHelp: 'Precio ofertado por este proveedor, para comparar fuentes; el coste por kg de la propia bobina sigue siendo la base de coste.',
+      help: 'Dónde se puede comprar este producto. La entrada resaltada es donde se compró esta bobina.',
+      saveFailed: 'Error al guardar las asignaciones de proveedores',
+      title: 'Proveedores',
+      description: 'Gestione dónde compra el filamento, algo distinto de la marca, que indica quién lo fabricó. Asigne proveedores a las bobinas en el diálogo de bobina; un proveedor que siga asignado a bobinas no se puede eliminar.',
+      search: 'Buscar proveedores...',
+      addNew: 'Añadir nuevo proveedor',
+      namePlaceholder: 'Nombre (p. ej. Filament24)',
+      websitePlaceholder: 'Sitio web / URL de la tienda',
+      customerNumberPlaceholder: 'N.º de cliente',
+      notePlaceholder: 'Nota',
+      nameRequired: 'El nombre es obligatorio',
+      duplicateName: 'Ya existe un proveedor con este nombre',
+      customerNumber: 'N.º de cliente',
+      spools: 'Bobinas',
+      noMatch: 'Ningún proveedor coincide con su búsqueda',
+      empty: 'Aún no hay proveedores',
+      added: 'Proveedor añadido',
+      addFailed: 'Error al añadir el proveedor',
+      updated: 'Proveedor actualizado',
+      updateFailed: 'Error al actualizar el proveedor',
+      deleted: 'Proveedor eliminado',
+      deleteFailed: 'Error al eliminar el proveedor',
+      deleteBlocked: 'No se puede eliminar: sigue asignado a {{count}} bobina(s)',
+      loadFailed: 'Error al cargar los proveedores',
+      deleteTitle: 'Eliminar proveedor',
+      deleteConfirm: '¿Eliminar "{{name}}"?',
+      deleteConfirmReferenced: '"{{name}}" está asignado a {{count}} bobina(s). La eliminación está bloqueada hasta que se quiten esas asignaciones.',
+    },
     // RFID button rename
     clearRfid: 'Borrar etiqueta RFID',
     rfidCleared: 'Etiqueta RFID borrada',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: 'Activité d\'impression',
     filamentTypes: 'Types de filament',
     filamentTrends: 'Tendances filament',
+    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.',
+      error: 'Impossible de charger les statistiques des fournisseurs',
+      supplier: 'Fournisseur',
+      spools: 'Bobines',
+      remaining: 'Restant',
+      consumed: 'Consommé',
+      cost: 'Coût',
+    },
     failureAnalysis: 'Analyse des échecs',
     timeAccuracy: 'Précision du temps',
     successful: 'Succès :',
@@ -4803,6 +4813,47 @@ export default {
     storageLocationNone: 'Aucun emplacement défini',
     lowStockThresholdOverride: 'Seuil bas (cette bobine)',
     lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
+    suppliers: {
+      label: 'Fournisseurs',
+      none: 'Aucun fournisseur',
+      addSupplier: 'Ajouter un fournisseur…',
+      searchPlaceholder: 'Rechercher des fournisseurs…',
+      noneLeft: 'Tous les fournisseurs sont déjà associés',
+      createNew: 'Créer « {{name}} »',
+      createFailed: 'Échec de la création du fournisseur',
+      purchaseSource: 'Acheté ici',
+      purchaseSourceHelp: 'Indique où cette bobine a réellement été achetée ; les autres entrées sont des sources alternatives.',
+      articleNumberPlaceholder: 'Réf. article fournisseur',
+      pricePlaceholder: 'Prix proposé/kg',
+      priceHelp: 'Prix proposé chez ce fournisseur, pour comparer les sources — le coût par kg propre à la bobine reste la base de coût.',
+      help: 'Où ce produit peut être acheté. L\'entrée mise en évidence est celle où cette bobine a été achetée.',
+      saveFailed: 'Échec de l\'enregistrement des fournisseurs associés',
+      title: 'Fournisseurs',
+      description: 'Gérez où vous achetez votre filament — à distinguer de la marque, qui désigne le fabricant. Associez les fournisseurs aux bobines dans la boîte de dialogue de la bobine ; un fournisseur encore associé à des bobines ne peut pas être supprimé.',
+      search: 'Rechercher des fournisseurs...',
+      addNew: 'Ajouter un nouveau fournisseur',
+      namePlaceholder: 'Nom (ex. Filament24)',
+      websitePlaceholder: 'Site web / URL de la boutique',
+      customerNumberPlaceholder: 'N° client',
+      notePlaceholder: 'Note',
+      nameRequired: 'Le nom est requis',
+      duplicateName: 'Un fournisseur portant ce nom existe déjà',
+      customerNumber: 'N° client',
+      spools: 'Bobines',
+      noMatch: 'Aucun fournisseur ne correspond à votre recherche',
+      empty: 'Aucun fournisseur pour le moment',
+      added: 'Fournisseur ajouté',
+      addFailed: 'Échec de l\'ajout du fournisseur',
+      updated: 'Fournisseur mis à jour',
+      updateFailed: 'Échec de la mise à jour du fournisseur',
+      deleted: 'Fournisseur supprimé',
+      deleteFailed: 'Échec de la suppression du fournisseur',
+      deleteBlocked: 'Suppression impossible : encore associé à {{count}} bobine(s)',
+      loadFailed: 'Échec du chargement des fournisseurs',
+      deleteTitle: 'Supprimer le fournisseur',
+      deleteConfirm: 'Supprimer « {{name}} » ?',
+      deleteConfirmReferenced: '« {{name}} » est associé à {{count}} bobine(s). La suppression est bloquée tant que ces associations ne sont pas retirées.',
+    },
     clearRfid: 'Effacer le tag RFID',
     rfidCleared: 'Tag RFID effacé',
     archive: 'Archiver',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: 'Attivita di stampa',
     filamentTypes: 'Tipi di filamento',
     filamentTrends: 'Trend filamento',
+    suppliers: {
+      title: 'Per fornitore',
+      empty: 'Nessun acquisto registrato. Indica su una bobina da quale fornitore è stata acquistata per raggruppare qui consumi e costi.',
+      error: 'Impossibile caricare le statistiche dei fornitori',
+      supplier: 'Fornitore',
+      spools: 'Bobine',
+      remaining: 'Rimanente',
+      consumed: 'Consumato',
+      cost: 'Costo',
+    },
     failureAnalysis: 'Analisi guasti',
     timeAccuracy: 'Accuratezza tempo',
     successful: 'Riuscite:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: 'Nessuna posizione impostata',
     lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
     lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
+    suppliers: {
+      label: 'Fornitori',
+      none: 'Nessun fornitore',
+      addSupplier: 'Aggiungi fornitore…',
+      searchPlaceholder: 'Cerca fornitori…',
+      noneLeft: 'Tutti i fornitori sono già assegnati',
+      createNew: 'Crea "{{name}}"',
+      createFailed: 'Creazione del fornitore non riuscita',
+      purchaseSource: 'Acquistato qui',
+      purchaseSourceHelp: 'Indica dove questa bobina è stata effettivamente acquistata; le altre voci sono fonti alternative.',
+      articleNumberPlaceholder: 'Codice articolo del fornitore',
+      pricePlaceholder: 'Prezzo offerto/kg',
+      priceHelp: 'Prezzo offerto da questo fornitore, per confrontare le fonti — il costo al kg della bobina rimane la base di costo.',
+      help: 'Dove si può acquistare questo prodotto. La voce evidenziata è quella in cui è stata acquistata questa bobina.',
+      saveFailed: 'Salvataggio delle assegnazioni dei fornitori non riuscito',
+      title: 'Fornitori',
+      description: 'Gestisci dove acquisti il filamento — distinto dalla marca, che indica chi lo ha prodotto. Assegna i fornitori alle bobine nella finestra della bobina; un fornitore ancora assegnato a delle bobine non può essere eliminato.',
+      search: 'Cerca fornitori...',
+      addNew: 'Aggiungi nuovo fornitore',
+      namePlaceholder: 'Nome (es. Filament24)',
+      websitePlaceholder: 'Sito web / URL del negozio',
+      customerNumberPlaceholder: 'N. cliente',
+      notePlaceholder: 'Nota',
+      nameRequired: 'Il nome è obbligatorio',
+      duplicateName: 'Esiste già un fornitore con questo nome',
+      customerNumber: 'N. cliente',
+      spools: 'Bobine',
+      noMatch: 'Nessun fornitore corrisponde alla ricerca',
+      empty: 'Nessun fornitore presente',
+      added: 'Fornitore aggiunto',
+      addFailed: 'Aggiunta del fornitore non riuscita',
+      updated: 'Fornitore aggiornato',
+      updateFailed: 'Aggiornamento del fornitore non riuscito',
+      deleted: 'Fornitore eliminato',
+      deleteFailed: 'Eliminazione del fornitore non riuscita',
+      deleteBlocked: 'Impossibile eliminare: ancora assegnato a {{count}} bobina/e',
+      loadFailed: 'Caricamento dei fornitori non riuscito',
+      deleteTitle: 'Elimina fornitore',
+      deleteConfirm: 'Eliminare "{{name}}"?',
+      deleteConfirmReferenced: '"{{name}}" è assegnato a {{count}} bobina/e. L\'eliminazione è bloccata finché queste assegnazioni non vengono rimosse.',
+    },
     clearRfid: 'Cancella tag RFID',
     rfidCleared: 'Tag RFID cancellato',
     archive: 'Archivia',

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

@@ -1669,6 +1669,17 @@ export default {
     printActivity: '印刷アクティビティ',
     filamentTypes: 'フィラメントタイプ',
     filamentTrends: 'フィラメントトレンド',
+    // Consumption/cost grouped by purchase-source supplier (#2988).
+    suppliers: {
+      title: 'サプライヤー別',
+      empty: 'まだ購入記録がありません。スプールにどのサプライヤーから購入したかを設定すると、ここに消費量とコストが集計されます。',
+      error: '仕入先の統計を読み込めませんでした',
+      supplier: 'サプライヤー',
+      spools: 'スプール',
+      remaining: '残量',
+      consumed: '消費量',
+      cost: 'コスト',
+    },
     failureAnalysis: '失敗分析',
     timeAccuracy: '時間精度',
     successful: '成功',
@@ -4814,6 +4825,48 @@ export default {
     storageLocationNone: '保管場所未設定',
     lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
     lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
+    // Suppliers (#2988): the master list and the per-spool assignments.
+    suppliers: {
+      label: 'サプライヤー',
+      none: 'サプライヤーなし',
+      addSupplier: 'サプライヤーを追加…',
+      searchPlaceholder: 'サプライヤーを検索…',
+      noneLeft: 'すべてのサプライヤーが割り当て済みです',
+      createNew: '「{{name}}」を作成',
+      createFailed: 'サプライヤーの作成に失敗しました',
+      purchaseSource: 'ここで購入',
+      purchaseSourceHelp: 'このスプールを実際に購入した場所を示します。その他の項目は代替の購入先です。',
+      articleNumberPlaceholder: 'サプライヤーの品番',
+      pricePlaceholder: '提示価格/kg',
+      priceHelp: 'このサプライヤーでの提示価格です。購入先の比較用であり、スプール自体のkg単価が原価の基準のままです。',
+      help: 'この製品の購入先です。ハイライトされた項目がこのスプールを購入した場所です。',
+      saveFailed: 'サプライヤーの割り当ての保存に失敗しました',
+      title: 'サプライヤー',
+      description: 'フィラメントの購入先を管理します。製造元であるブランドとは別の概念です。スプールダイアログでスプールにサプライヤーを割り当てられます。スプールに割り当てられているサプライヤーは削除できません。',
+      search: 'サプライヤーを検索...',
+      addNew: '新しいサプライヤーを追加',
+      namePlaceholder: '名前(例:Filament24)',
+      websitePlaceholder: 'ウェブサイト / ショップURL',
+      customerNumberPlaceholder: '顧客番号',
+      notePlaceholder: 'メモ',
+      nameRequired: '名前は必須です',
+      duplicateName: 'この名前の仕入先はすでに存在します',
+      customerNumber: '顧客番号',
+      spools: 'スプール',
+      noMatch: '検索に一致するサプライヤーがありません',
+      empty: 'サプライヤーはまだありません',
+      added: 'サプライヤーを追加しました',
+      addFailed: 'サプライヤーの追加に失敗しました',
+      updated: 'サプライヤーを更新しました',
+      updateFailed: 'サプライヤーの更新に失敗しました',
+      deleted: 'サプライヤーを削除しました',
+      deleteFailed: 'サプライヤーの削除に失敗しました',
+      deleteBlocked: '削除できません:まだ{{count}}個のスプールに割り当てられています',
+      loadFailed: 'サプライヤーの読み込みに失敗しました',
+      deleteTitle: 'サプライヤーを削除',
+      deleteConfirm: '「{{name}}」を削除しますか?',
+      deleteConfirmReferenced: '「{{name}}」は{{count}}個のスプールに割り当てられています。割り当てを解除するまで削除できません。',
+    },
     clearRfid: 'RFIDタグをクリア',
     rfidCleared: 'RFIDタグをクリアしました',
     archive: 'アーカイブ',

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

@@ -1603,6 +1603,16 @@ export default {
     printActivity: '인쇄 활동',
     filamentTypes: '필라멘트 종류',
     filamentTrends: '필라멘트 추세',
+    suppliers: {
+      title: '공급업체별',
+      empty: '아직 기록된 구매가 없습니다. 스풀에 구매한 공급업체를 표시하면 여기에서 소비량과 비용이 그룹화됩니다.',
+      error: '공급업체 통계를 불러올 수 없습니다',
+      supplier: '공급업체',
+      spools: '스풀',
+      remaining: '남은 양',
+      consumed: '소비량',
+      cost: '비용',
+    },
     failureAnalysis: '실패 분석',
     timeAccuracy: '시간 정확도',
     successful: '성공:',
@@ -4599,6 +4609,47 @@ export default {
     categoryNone: '미분류',
     lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)',
     lowStockThresholdOverrideHelp: '전역 임계값({{global}}%)을 사용하려면 비워두세요.',
+    suppliers: {
+      label: '공급업체',
+      none: '공급업체 없음',
+      addSupplier: '공급업체 추가…',
+      searchPlaceholder: '공급업체 검색…',
+      noneLeft: '모든 공급업체가 이미 할당되었습니다',
+      createNew: '"{{name}}" 만들기',
+      createFailed: '공급업체 생성에 실패했습니다',
+      purchaseSource: '구매처',
+      purchaseSourceHelp: '이 스풀을 실제로 구매한 곳을 표시합니다. 나머지 항목은 대체 구매처입니다.',
+      articleNumberPlaceholder: '공급업체 품목 번호',
+      pricePlaceholder: '견적 가격/kg',
+      priceHelp: '이 공급업체의 견적 가격으로, 구매처 비교용입니다. 스풀 자체의 kg당 비용이 실제 원가 기준으로 유지됩니다.',
+      help: '이 제품을 구매할 수 있는 곳입니다. 강조 표시된 항목이 이 스풀을 구매한 곳입니다.',
+      saveFailed: '공급업체 할당 저장에 실패했습니다',
+      title: '공급업체',
+      description: '필라멘트 구매처를 관리합니다. 제조사를 뜻하는 브랜드와는 다른 개념입니다. 스풀 대화상자에서 스풀에 공급업체를 할당할 수 있으며, 스풀에 할당된 공급업체는 삭제할 수 없습니다.',
+      search: '공급업체 검색...',
+      addNew: '새 공급업체 추가',
+      namePlaceholder: '이름 (예: Filament24)',
+      websitePlaceholder: '웹사이트 / 쇼핑몰 URL',
+      customerNumberPlaceholder: '고객 번호',
+      notePlaceholder: '메모',
+      nameRequired: '이름은 필수입니다',
+      duplicateName: '같은 이름의 공급업체가 이미 있습니다',
+      customerNumber: '고객 번호',
+      spools: '스풀',
+      noMatch: '검색과 일치하는 공급업체가 없습니다',
+      empty: '아직 공급업체가 없습니다',
+      added: '공급업체가 추가되었습니다',
+      addFailed: '공급업체 추가에 실패했습니다',
+      updated: '공급업체가 수정되었습니다',
+      updateFailed: '공급업체 수정에 실패했습니다',
+      deleted: '공급업체가 삭제되었습니다',
+      deleteFailed: '공급업체 삭제에 실패했습니다',
+      deleteBlocked: '삭제할 수 없습니다: 아직 스풀 {{count}}개에 할당되어 있습니다',
+      loadFailed: '공급업체를 불러오지 못했습니다',
+      deleteTitle: '공급업체 삭제',
+      deleteConfirm: '"{{name}}"을(를) 삭제하시겠습니까?',
+      deleteConfirmReferenced: '"{{name}}"이(가) 스풀 {{count}}개에 할당되어 있습니다. 해당 할당을 제거할 때까지 삭제할 수 없습니다.',
+    },
     clearRfid: 'RFID 태그 초기화',
     rfidCleared: 'RFID 태그가 초기화되었습니다',
     archive: '보관',

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

@@ -1687,6 +1687,17 @@ export default {
     printActivity: 'Afdrukactiviteit',
     filamentTypes: 'Filamenttypen',
     filamentTrends: 'Filamenttrends',
+    // Consumption/cost grouped by purchase-source supplier (#2988).
+    suppliers: {
+      title: 'Per leverancier',
+      empty: 'Nog geen aankopen geregistreerd. Markeer op een spoel bij welke leverancier deze is gekocht om verbruik en kosten hier te groeperen.',
+      error: 'Leveranciersstatistieken konden niet worden geladen',
+      supplier: 'Leverancier',
+      spools: 'Spoelen',
+      remaining: 'Resterend',
+      consumed: 'Verbruikt',
+      cost: 'Kosten',
+    },
     failureAnalysis: 'Foutenanalyse',
     timeAccuracy: 'Tijdnauwkeurigheid',
     successful: 'Geslaagd:',
@@ -4854,6 +4865,48 @@ export default {
     storageLocationNone: 'Geen locatie ingesteld',
     lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)',
     lowStockThresholdOverrideHelp: 'Laat leeg om de globale drempel ({{global}}%) te gebruiken.',
+    // Suppliers (#2988): the master list and the per-spool assignments.
+    suppliers: {
+      label: 'Leveranciers',
+      none: 'Geen leverancier',
+      addSupplier: 'Leverancier toevoegen…',
+      searchPlaceholder: 'Leveranciers zoeken…',
+      noneLeft: 'Alle leveranciers zijn al toegewezen',
+      createNew: '"{{name}}" aanmaken',
+      createFailed: 'Leverancier aanmaken mislukt',
+      purchaseSource: 'Hier gekocht',
+      purchaseSourceHelp: 'Markeert waar deze spoel daadwerkelijk is gekocht; de overige vermeldingen zijn alternatieve bronnen.',
+      articleNumberPlaceholder: 'Artikelnr. leverancier',
+      pricePlaceholder: 'Offerteprijs/kg',
+      priceHelp: 'Offerteprijs bij deze leverancier, om bronnen te vergelijken — de eigen kosten per kg van de spoel blijven de kostenbasis.',
+      help: 'Waar dit product te koop is. De gemarkeerde vermelding is waar deze spoel is gekocht.',
+      saveFailed: 'Leverancierstoewijzingen opslaan mislukt',
+      title: 'Leveranciers',
+      description: 'Beheer waar je filament koopt — niet te verwarren met het merk, dat is wie het heeft gemaakt. Wijs leveranciers toe aan spoelen in het spoeldialoogvenster; een leverancier die nog aan spoelen is toegewezen kan niet worden verwijderd.',
+      search: 'Leveranciers zoeken...',
+      addNew: 'Nieuwe leverancier toevoegen',
+      namePlaceholder: 'Naam (bijv. Filament24)',
+      websitePlaceholder: 'Website / winkel-URL',
+      customerNumberPlaceholder: 'Klantnr.',
+      notePlaceholder: 'Notitie',
+      nameRequired: 'Naam is verplicht',
+      duplicateName: 'Er bestaat al een leverancier met deze naam',
+      customerNumber: 'Klantnr.',
+      spools: 'Spoelen',
+      noMatch: 'Geen leveranciers gevonden voor je zoekopdracht',
+      empty: 'Nog geen leveranciers',
+      added: 'Leverancier toegevoegd',
+      addFailed: 'Leverancier toevoegen mislukt',
+      updated: 'Leverancier bijgewerkt',
+      updateFailed: 'Leverancier bijwerken mislukt',
+      deleted: 'Leverancier verwijderd',
+      deleteFailed: 'Leverancier verwijderen mislukt',
+      deleteBlocked: 'Kan niet verwijderen: nog toegewezen aan {{count}} spoel(en)',
+      loadFailed: 'Leveranciers laden mislukt',
+      deleteTitle: 'Leverancier verwijderen',
+      deleteConfirm: '"{{name}}" verwijderen?',
+      deleteConfirmReferenced: '"{{name}}" is toegewezen aan {{count}} spoel(en). Verwijderen is geblokkeerd totdat die toewijzingen zijn verwijderd.',
+    },
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: 'RFID-tag wissen',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: 'Atividade de Impressão',
     filamentTypes: 'Tipos de Filamento',
     filamentTrends: 'Tendências de Filamento',
+    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.',
+      error: 'Não foi possível carregar as estatísticas de fornecedores',
+      supplier: 'Fornecedor',
+      spools: 'Carretéis',
+      remaining: 'Restante',
+      consumed: 'Consumido',
+      cost: 'Custo',
+    },
     failureAnalysis: 'Análise de Falhas',
     timeAccuracy: 'Precisão do Tempo',
     successful: 'Bem-sucedido:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: 'Sem local definido',
     lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
     lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
+    suppliers: {
+      label: 'Fornecedores',
+      none: 'Sem fornecedor',
+      addSupplier: 'Adicionar fornecedor…',
+      searchPlaceholder: 'Pesquisar fornecedores…',
+      noneLeft: 'Todos os fornecedores já estão atribuídos',
+      createNew: 'Criar "{{name}}"',
+      createFailed: 'Falha ao criar fornecedor',
+      purchaseSource: 'Comprado aqui',
+      purchaseSourceHelp: 'Indica onde este carretel foi realmente comprado; as outras entradas são fontes alternativas.',
+      articleNumberPlaceholder: 'Nº do artigo no fornecedor',
+      pricePlaceholder: 'Preço cotado/kg',
+      priceHelp: 'Preço cotado neste fornecedor, para comparar fontes — o custo por kg do próprio carretel continua sendo a base de custo.',
+      help: 'Onde este produto pode ser comprado. A entrada destacada é onde este carretel foi comprado.',
+      saveFailed: 'Falha ao salvar as atribuições de fornecedor',
+      title: 'Fornecedores',
+      description: 'Gerencie onde você compra filamento — diferente da marca, que é quem o fabricou. Atribua fornecedores aos carretéis no diálogo do carretel; um fornecedor que ainda está atribuído a carretéis não pode ser excluído.',
+      search: 'Pesquisar fornecedores...',
+      addNew: 'Adicionar novo fornecedor',
+      namePlaceholder: 'Nome (ex. Filament24)',
+      websitePlaceholder: 'Site / URL da loja',
+      customerNumberPlaceholder: 'Nº de cliente',
+      notePlaceholder: 'Observação',
+      nameRequired: 'O nome é obrigatório',
+      duplicateName: 'Já existe um fornecedor com esse nome',
+      customerNumber: 'Nº de cliente',
+      spools: 'Carretéis',
+      noMatch: 'Nenhum fornecedor corresponde à sua pesquisa',
+      empty: 'Nenhum fornecedor ainda',
+      added: 'Fornecedor adicionado',
+      addFailed: 'Falha ao adicionar fornecedor',
+      updated: 'Fornecedor atualizado',
+      updateFailed: 'Falha ao atualizar fornecedor',
+      deleted: 'Fornecedor excluído',
+      deleteFailed: 'Falha ao excluir fornecedor',
+      deleteBlocked: 'Não é possível excluir: ainda atribuído a {{count}} carretel(éis)',
+      loadFailed: 'Falha ao carregar fornecedores',
+      deleteTitle: 'Excluir Fornecedor',
+      deleteConfirm: 'Excluir "{{name}}"?',
+      deleteConfirmReferenced: '"{{name}}" está atribuído a {{count}} carretel(éis). A exclusão fica bloqueada até que essas atribuições sejam removidas.',
+    },
     clearRfid: 'Limpar tag RFID',
     rfidCleared: 'Tag RFID limpa',
     archive: 'Arquivar',

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

@@ -1600,6 +1600,16 @@ export default {
     printActivity: "Активность печати",
     filamentTypes: "Типы филамента",
     filamentTrends: "Расход филамента",
+    suppliers: {
+      title: "По поставщикам",
+      empty: "Покупки пока не зарегистрированы. Отметьте на катушке, у какого поставщика она была куплена, чтобы сгруппировать здесь расход и затраты.",
+      error: 'Не удалось загрузить статистику по поставщикам',
+      supplier: "Поставщик",
+      spools: "Катушки",
+      remaining: "Остаток",
+      consumed: "Израсходовано",
+      cost: "Затраты",
+    },
     failureAnalysis: "Анализ неудач",
     timeAccuracy: "Точность оценки времени",
     successful: "Успешно:",
@@ -4590,6 +4600,47 @@ export default {
     storageLocationNone: "Место не указано",
     lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
     lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).",
+    suppliers: {
+      label: "Поставщики",
+      none: "Без поставщика",
+      addSupplier: "Добавить поставщика…",
+      searchPlaceholder: "Поиск поставщиков…",
+      noneLeft: "Все поставщики уже назначены",
+      createNew: "Создать «{{name}}»",
+      createFailed: "Не удалось создать поставщика",
+      purchaseSource: "Куплено здесь",
+      purchaseSourceHelp: "Отмечает, где эта катушка была фактически куплена; остальные записи — альтернативные источники.",
+      articleNumberPlaceholder: "Артикул поставщика",
+      pricePlaceholder: "Предлагаемая цена/кг",
+      priceHelp: "Предлагаемая цена у этого поставщика для сравнения источников — базой затрат остаётся собственная стоимость катушки за кг.",
+      help: "Где можно купить этот товар. Выделенная запись — место покупки этой катушки.",
+      saveFailed: "Не удалось сохранить назначения поставщиков",
+      title: "Поставщики",
+      description: "Управляйте тем, где вы покупаете филамент — в отличие от производителя, который его изготовил. Назначайте поставщиков катушкам в диалоге катушки; поставщика, который всё ещё назначен катушкам, удалить нельзя.",
+      search: "Поиск поставщиков…",
+      addNew: "Добавить поставщика",
+      namePlaceholder: "Название (напр. Filament24)",
+      websitePlaceholder: "Сайт / URL магазина",
+      customerNumberPlaceholder: "Номер клиента",
+      notePlaceholder: "Заметка",
+      nameRequired: "Название обязательно",
+      duplicateName: 'Поставщик с таким названием уже существует',
+      customerNumber: "Номер клиента",
+      spools: "Катушки",
+      noMatch: "Поставщики по вашему запросу не найдены",
+      empty: "Пока нет поставщиков",
+      added: "Поставщик добавлен",
+      addFailed: "Не удалось добавить поставщика",
+      updated: "Поставщик обновлён",
+      updateFailed: "Не удалось обновить поставщика",
+      deleted: "Поставщик удалён",
+      deleteFailed: "Не удалось удалить поставщика",
+      deleteBlocked: "Удаление невозможно: всё ещё назначен катушкам ({{count}})",
+      loadFailed: "Не удалось загрузить поставщиков",
+      deleteTitle: "Удалить поставщика",
+      deleteConfirm: "Удалить «{{name}}»?",
+      deleteConfirmReferenced: "«{{name}}» назначен катушкам ({{count}}). Удаление заблокировано, пока эти назначения не будут сняты.",
+    },
     clearRfid: "Очистить RFID-метку",
     rfidCleared: "RFID-метка очищена",
     archive: "Архивировать",

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

@@ -1687,6 +1687,16 @@ export default {
     printActivity: 'Utskriftsaktivitet',
     filamentTypes: 'Filamenttyper',
     filamentTrends: 'Filamenttrender',
+    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.',
+      error: 'Kunde inte läsa in leverantörsstatistiken',
+      supplier: 'Leverantör',
+      spools: 'Rullar',
+      remaining: 'Kvar',
+      consumed: 'Förbrukat',
+      cost: 'Kostnad',
+    },
     failureAnalysis: 'Felanalys',
     timeAccuracy: 'Tidsnoggrannhet',
     successful: 'Lyckade:',
@@ -4854,6 +4864,47 @@ 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}}%).',
+    suppliers: {
+      label: 'Leverantörer',
+      none: 'Ingen leverantör',
+      addSupplier: 'Lägg till leverantör…',
+      searchPlaceholder: 'Sök leverantörer…',
+      noneLeft: 'Alla leverantörer är redan tilldelade',
+      createNew: 'Skapa ”{{name}}”',
+      createFailed: 'Det gick inte att skapa leverantören',
+      purchaseSource: 'Köpt här',
+      purchaseSourceHelp: 'Markerar var den här rullen faktiskt köptes; övriga poster är alternativa inköpsställen.',
+      articleNumberPlaceholder: 'Leverantörens artikelnr',
+      pricePlaceholder: 'Offererat pris/kg',
+      priceHelp: 'Offererat pris hos den här leverantören, för att jämföra inköpsställen – rullens eget kilopris är fortfarande kostnadsunderlaget.',
+      help: 'Var den här produkten kan köpas. Den markerade posten är där rullen köptes.',
+      saveFailed: 'Det gick inte att spara leverantörstilldelningarna',
+      title: 'Leverantörer',
+      description: 'Hantera var du köper filament – till skillnad från märke, som är vem som tillverkat det. Tilldela leverantörer till rullar i rulldialogen; en leverantör som fortfarande är tilldelad rullar går inte att ta bort.',
+      search: 'Sök leverantörer …',
+      addNew: 'Lägg till ny leverantör',
+      namePlaceholder: 'Namn (t.ex. Filament24)',
+      websitePlaceholder: 'Webbplats/butikslänk',
+      customerNumberPlaceholder: 'Kundnr',
+      notePlaceholder: 'Anteckning',
+      nameRequired: 'Namn krävs',
+      duplicateName: 'Det finns redan en leverantör med det namnet',
+      customerNumber: 'Kundnr',
+      spools: 'Rullar',
+      noMatch: 'Inga leverantörer matchar din sökning',
+      empty: 'Inga leverantörer ännu',
+      added: 'Leverantören har lagts till',
+      addFailed: 'Det gick inte att lägga till leverantören',
+      updated: 'Leverantören har uppdaterats',
+      updateFailed: 'Det gick inte att uppdatera leverantören',
+      deleted: 'Leverantören har tagits bort',
+      deleteFailed: 'Det gick inte att ta bort leverantören',
+      deleteBlocked: 'Går inte att ta bort: fortfarande tilldelad {{count}} rulle/rullar',
+      loadFailed: 'Det gick inte att läsa in leverantörerna',
+      deleteTitle: 'Ta bort leverantör',
+      deleteConfirm: 'Ta bort ”{{name}}”?',
+      deleteConfirmReferenced: '”{{name}}” är tilldelad {{count}} rulle/rullar. Borttagning är blockerad tills de tilldelningarna har tagits bort.',
+    },
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: 'Rensa RFID-tagg',

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

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Baskı Etkinliği',
     filamentTypes: 'Filament Türleri',
     filamentTrends: 'Filament Trendleri',
+    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.',
+      error: 'Tedarikçi istatistikleri yüklenemedi',
+      supplier: 'Tedarikçi',
+      spools: 'Makaralar',
+      remaining: 'Kalan',
+      consumed: 'Tüketilen',
+      cost: 'Maliyet',
+    },
     failureAnalysis: 'Başarısızlık Analizi',
     timeAccuracy: 'Süre Doğruluğu',
     successful: 'Başarılı:',
@@ -4802,6 +4812,47 @@ 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}}).',
+    suppliers: {
+      label: 'Tedarikçiler',
+      none: 'Tedarikçi yok',
+      addSupplier: 'Tedarikçi ekle…',
+      searchPlaceholder: 'Tedarikçi ara…',
+      noneLeft: 'Tüm tedarikçiler zaten atanmış',
+      createNew: '"{{name}}" oluştur',
+      createFailed: 'Tedarikçi oluşturulamadı',
+      purchaseSource: 'Buradan satın alındı',
+      purchaseSourceHelp: 'Bu makaranın gerçekte nereden satın alındığını işaretler; diğer girişler alternatif kaynaklardır.',
+      articleNumberPlaceholder: 'Tedarikçi ürün no.',
+      pricePlaceholder: 'Teklif fiyatı/kg',
+      priceHelp: 'Bu tedarikçideki teklif fiyatı, kaynakları karşılaştırmak içindir — makaranın kendi kg başına maliyeti, maliyet esası olarak kalır.',
+      help: 'Bu ürünün satın alınabileceği yerler. Vurgulanan giriş, bu makaranın satın alındığı yerdir.',
+      saveFailed: 'Tedarikçi atamaları kaydedilemedi',
+      title: 'Tedarikçiler',
+      description: 'Filamenti nereden satın aldığınızı yönetin — bunu üreten firmayı belirten markadan farklıdır. Makara iletişim kutusunda makaralara tedarikçi atayın; hâlâ makaralara atanmış bir tedarikçi silinemez.',
+      search: 'Tedarikçi ara...',
+      addNew: 'Yeni tedarikçi ekle',
+      namePlaceholder: 'Ad (örn. Filament24)',
+      websitePlaceholder: 'Web sitesi / mağaza URL\'si',
+      customerNumberPlaceholder: 'Müşteri no.',
+      notePlaceholder: 'Not',
+      nameRequired: 'Ad gereklidir',
+      duplicateName: 'Bu ada sahip bir tedarikçi zaten var',
+      customerNumber: 'Müşteri no.',
+      spools: 'Makaralar',
+      noMatch: 'Aramanızla eşleşen tedarikçi yok',
+      empty: 'Henüz tedarikçi yok',
+      added: 'Tedarikçi eklendi',
+      addFailed: 'Tedarikçi eklenemedi',
+      updated: 'Tedarikçi güncellendi',
+      updateFailed: 'Tedarikçi güncellenemedi',
+      deleted: 'Tedarikçi silindi',
+      deleteFailed: 'Tedarikçi silinemedi',
+      deleteBlocked: 'Silinemez: hâlâ {{count}} makaraya atanmış',
+      loadFailed: 'Tedarikçiler yüklenemedi',
+      deleteTitle: 'Tedarikçiyi Sil',
+      deleteConfirm: '"{{name}}" silinsin mi?',
+      deleteConfirmReferenced: '"{{name}}" {{count}} makaraya atanmış. Bu atamalar kaldırılana kadar silme engellenir.',
+    },
     clearRfid: 'RFID Etiketini Temizle',
     rfidCleared: 'RFID etiketi temizlendi',
     archive: 'Arşivle',

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

@@ -1686,6 +1686,16 @@ export default {
     printActivity: "Активність друку",
     filamentTypes: "Типи філаментів",
     filamentTrends: "Тенденції філаменту",
+    suppliers: {
+      title: "За постачальником",
+      empty: "Покупок ще не зафіксовано. Позначте на котушці, у якого постачальника її придбано, щоб згрупувати тут споживання та витрати.",
+      error: 'Не вдалося завантажити статистику постачальників',
+      supplier: "Постачальник",
+      spools: "Котушки",
+      remaining: "Залишок",
+      consumed: "Спожито",
+      cost: "Вартість",
+    },
     failureAnalysis: "Аналіз невдач",
     timeAccuracy: "Точність оцінки часу",
     successful: "Успішно:",
@@ -4851,6 +4861,47 @@ export default {
     storageLocationNone: "Місцезнаходження не встановлено",
     lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
     lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).",
+    suppliers: {
+      label: "Постачальники",
+      none: "Без постачальника",
+      addSupplier: "Додати постачальника…",
+      searchPlaceholder: "Пошук постачальників…",
+      noneLeft: "Усі постачальники вже призначені",
+      createNew: "Створити «{{name}}»",
+      createFailed: "Не вдалося створити постачальника",
+      purchaseSource: "Куплено тут",
+      purchaseSourceHelp: "Позначає, де цю котушку було фактично придбано; інші записи — альтернативні джерела.",
+      articleNumberPlaceholder: "Артикул постачальника",
+      pricePlaceholder: "Пропонована ціна/кг",
+      priceHelp: "Пропонована ціна в цього постачальника — для порівняння джерел; власна вартість котушки за кг залишається базою собівартості.",
+      help: "Де можна придбати цей товар. Виділений запис — це місце, де було куплено цю котушку.",
+      saveFailed: "Не вдалося зберегти призначення постачальників",
+      title: "Постачальники",
+      description: "Керуйте тим, де ви купуєте філамент — на відміну від бренду, який вказує виробника. Призначайте постачальників котушкам у діалозі котушки; постачальника, який усе ще призначений котушкам, видалити не можна.",
+      search: "Пошук постачальників...",
+      addNew: "Додати нового постачальника",
+      namePlaceholder: "Назва (напр. Filament24)",
+      websitePlaceholder: "Вебсайт / URL магазину",
+      customerNumberPlaceholder: "Номер клієнта",
+      notePlaceholder: "Нотатка",
+      nameRequired: "Назва є обов’язковою",
+      duplicateName: 'Постачальник із такою назвою вже існує',
+      customerNumber: "Номер клієнта",
+      spools: "Котушки",
+      noMatch: "Жоден постачальник не відповідає вашому запиту",
+      empty: "Постачальників поки немає",
+      added: "Постачальника додано",
+      addFailed: "Не вдалося додати постачальника",
+      updated: "Постачальника оновлено",
+      updateFailed: "Не вдалося оновити постачальника",
+      deleted: "Постачальника видалено",
+      deleteFailed: "Не вдалося видалити постачальника",
+      deleteBlocked: "Неможливо видалити: усе ще призначено {{count}} котушці(-ам)",
+      loadFailed: "Не вдалося завантажити постачальників",
+      deleteTitle: "Видалити постачальника",
+      deleteConfirm: "Видалити «{{name}}»?",
+      deleteConfirmReferenced: "«{{name}}» призначено {{count}} котушці(-ам). Видалення заблоковано, доки ці призначення не буде знято.",
+    },
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: "Очистити тег RFID.",

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: '打印活动',
     filamentTypes: '耗材类型',
     filamentTrends: '耗材趋势',
+    suppliers: {
+      title: '按供应商',
+      empty: '暂无购买记录。在料盘上标记其购买来源供应商后,即可在此按供应商统计消耗和成本。',
+      error: '无法加载供应商统计数据',
+      supplier: '供应商',
+      spools: '料盘',
+      remaining: '剩余',
+      consumed: '已消耗',
+      cost: '成本',
+    },
     failureAnalysis: '失败分析',
     timeAccuracy: '时间准确度',
     successful: '成功:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: '未设置位置',
     lowStockThresholdOverride: '低库存阈值(此料盘)',
     lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
+    suppliers: {
+      label: '供应商',
+      none: '无供应商',
+      addSupplier: '添加供应商…',
+      searchPlaceholder: '搜索供应商…',
+      noneLeft: '所有供应商均已分配',
+      createNew: '创建“{{name}}”',
+      createFailed: '创建供应商失败',
+      purchaseSource: '在此购买',
+      purchaseSourceHelp: '标记此料盘的实际购买来源;其他条目为备选购买渠道。',
+      articleNumberPlaceholder: '供应商货号',
+      pricePlaceholder: '报价/kg',
+      priceHelp: '此供应商的报价,用于比较购买渠道;料盘自身的每公斤成本仍作为成本依据。',
+      help: '此产品的可购买渠道。高亮条目为此料盘的实际购买来源。',
+      saveFailed: '保存供应商分配失败',
+      title: '供应商',
+      description: '管理耗材的购买渠道——不同于品牌,品牌是耗材的制造商。可在料盘对话框中为料盘分配供应商;仍被料盘引用的供应商无法删除。',
+      search: '搜索供应商...',
+      addNew: '添加新供应商',
+      namePlaceholder: '名称(例如 Filament24)',
+      websitePlaceholder: '网站/商店链接',
+      customerNumberPlaceholder: '客户编号',
+      notePlaceholder: '备注',
+      nameRequired: '名称为必填项',
+      duplicateName: '已存在同名供应商',
+      customerNumber: '客户编号',
+      spools: '料盘',
+      noMatch: '没有匹配的供应商',
+      empty: '暂无供应商',
+      added: '供应商已添加',
+      addFailed: '添加供应商失败',
+      updated: '供应商已更新',
+      updateFailed: '更新供应商失败',
+      deleted: '供应商已删除',
+      deleteFailed: '删除供应商失败',
+      deleteBlocked: '无法删除:仍分配给 {{count}} 个料盘',
+      loadFailed: '加载供应商失败',
+      deleteTitle: '删除供应商',
+      deleteConfirm: '确定删除“{{name}}”吗?',
+      deleteConfirmReferenced: '“{{name}}”已分配给 {{count}} 个料盘。请先移除这些分配后才能删除。',
+    },
     clearRfid: '清除 RFID 标签',
     rfidCleared: 'RFID 标签已清除',
     archive: '归档',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: '列印活動',
     filamentTypes: '耗材類型',
     filamentTrends: '耗材趨勢',
+    suppliers: {
+      title: '依供應商',
+      empty: '尚未記錄任何購買。在料盤上標記其購買來源的供應商,即可在此依供應商彙整消耗量與成本。',
+      error: '無法載入供應商統計資料',
+      supplier: '供應商',
+      spools: '料盤',
+      remaining: '剩餘',
+      consumed: '已消耗',
+      cost: '成本',
+    },
     failureAnalysis: '失敗分析',
     timeAccuracy: '時間準確度',
     successful: '成功:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: '未設定位置',
     lowStockThresholdOverride: '低庫存閾值(此料盤)',
     lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
+    suppliers: {
+      label: '供應商',
+      none: '無供應商',
+      addSupplier: '新增供應商…',
+      searchPlaceholder: '搜尋供應商…',
+      noneLeft: '所有供應商皆已指派',
+      createNew: '建立「{{name}}」',
+      createFailed: '建立供應商失敗',
+      purchaseSource: '於此購買',
+      purchaseSourceHelp: '標記此料盤實際購買的來源;其他項目為替代來源。',
+      articleNumberPlaceholder: '供應商商品編號',
+      pricePlaceholder: '報價/公斤',
+      priceHelp: '此供應商的報價,用於比較購買來源;料盤本身的每公斤成本仍為成本基準。',
+      help: '此產品可購買的地點。醒目標示的項目為此料盤的實際購買處。',
+      saveFailed: '儲存供應商指派失敗',
+      title: '供應商',
+      description: '管理您購買耗材的來源——與品牌不同,品牌是製造商。可在料盤對話框中為料盤指派供應商;仍指派給料盤的供應商無法刪除。',
+      search: '搜尋供應商...',
+      addNew: '新增供應商',
+      namePlaceholder: '名稱(例如 Filament24)',
+      websitePlaceholder: '網站/商店網址',
+      customerNumberPlaceholder: '客戶編號',
+      notePlaceholder: '備註',
+      nameRequired: '名稱為必填',
+      duplicateName: '已存在同名供應商',
+      customerNumber: '客戶編號',
+      spools: '料盤',
+      noMatch: '沒有符合搜尋的供應商',
+      empty: '尚無供應商',
+      added: '供應商已新增',
+      addFailed: '新增供應商失敗',
+      updated: '供應商已更新',
+      updateFailed: '更新供應商失敗',
+      deleted: '供應商已刪除',
+      deleteFailed: '刪除供應商失敗',
+      deleteBlocked: '無法刪除:仍指派給 {{count}} 個料盤',
+      loadFailed: '載入供應商失敗',
+      deleteTitle: '刪除供應商',
+      deleteConfirm: '刪除「{{name}}」?',
+      deleteConfirmReferenced: '「{{name}}」已指派給 {{count}} 個料盤。必須先移除這些指派才能刪除。',
+    },
     clearRfid: '清除 RFID 標籤',
     rfidCleared: 'RFID 標籤已清除',
     archive: '歸檔',

+ 96 - 4
frontend/src/pages/InventoryPage.tsx

@@ -7,7 +7,7 @@ import {
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
-  Upload, Download,
+  Upload, Download, Store,
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -22,6 +22,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
 import { LocationsModal } from '../components/LocationsModal';
+import { SuppliersModal } from '../components/SuppliersModal';
 import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -98,6 +99,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: 'suppliers', label: 'Suppliers', visible: false },
   { id: 'pa_k', label: 'PA(K)', visible: true },
   { id: 'tag_id', label: 'Tag ID', visible: false },
   { id: 'data_origin', label: 'Data Origin', visible: false },
@@ -227,6 +229,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   printed_total: () => 'Printed Total',
   printed_since_weight: () => 'Printed Since Weight',
   note: (t) => t('inventory.note'),
+  suppliers: (t) => t('inventory.suppliers.label'),
   pa_k: () => 'PA(K)',
   tag_id: () => 'Tag ID',
   data_origin: () => 'Data Origin',
@@ -367,6 +370,31 @@ 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>
   ),
+  // Supplier chips (#2988): purchase source first and highlighted; the
+  // others read as alternative sources. Tooltip carries the supplier's
+  // article number when set.
+  suppliers: ({ spool }) => {
+    const links = spool.suppliers ?? [];
+    if (links.length === 0) return <span className="text-sm text-bambu-gray">-</span>;
+    const sorted = [...links].sort((a, b) => Number(b.is_purchase_source) - Number(a.is_purchase_source));
+    return (
+      <div className="flex flex-wrap gap-1 max-w-[220px]">
+        {sorted.map((link) => (
+          <span
+            key={link.id}
+            title={link.supplier_article_number || undefined}
+            className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
+              link.is_purchase_source
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'bg-bambu-gray/10 text-bambu-gray'
+            }`}
+          >
+            {link.supplier_name}
+          </span>
+        ))}
+      </div>
+    );
+  },
   pa_k: ({ spool }) => {
     const count = spool.k_profiles?.length ?? 0;
     if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
@@ -513,6 +541,13 @@ 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(),
+  // 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) => {
+    const links = s.suppliers ?? [];
+    const primary = links.find((l) => l.is_purchase_source) ?? links[0];
+    return (primary?.supplier_name || '').toLowerCase();
+  },
   data_origin: (s) => (s.data_origin || '').toLowerCase(),
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   stock: (s) => s.slicer_filament ? 1 : 0,
@@ -595,6 +630,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [csvImportOpen, setCsvImportOpen] = useState(false);
   const [exportingCsv, setExportingCsv] = useState(false);
   const [locationsModalOpen, setLocationsModalOpen] = useState(false);
+  const [suppliersModalOpen, setSuppliersModalOpen] = useState(false);
 
   // Filter state
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -602,6 +638,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [materialFilter, setMaterialFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
   const [categoryFilter, setCategoryFilter] = useState('');
+  // Filter on an assigned supplier (#2988), same shape as category.
+  const [supplierFilter, setSupplierFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [search, setSearch] = useState('');
@@ -636,7 +674,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, spoolFilter, stockFilter, search]);
+  }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, search]);
 
   // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
@@ -1274,6 +1312,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
       }
     }
 
+    // Supplier dropdown (#2988): "everything from supplier X" matches ANY
+    // assignment, purchase source or alternative; `__none__` finds spools
+    // without supplier assignments.
+    if (supplierFilter) {
+      if (supplierFilter === '__none__') {
+        filtered = filtered.filter((s) => (s.suppliers ?? []).length === 0);
+      } else {
+        const supplierId = Number(supplierFilter);
+        filtered = filtered.filter((s) => (s.suppliers ?? []).some((l) => l.supplier_id === supplierId));
+      }
+    }
+
     // Spool name dropdown
     if (spoolFilter) {
       const catalogId = Number(spoolFilter);
@@ -1309,7 +1359,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -1330,6 +1380,17 @@ 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);
+  // #2988: suppliers seen across the inventory, for the filter dropdown.
+  const uniqueSuppliers = useMemo(() => {
+    const byId = new Map<number, string>();
+    for (const s of spools ?? []) {
+      for (const link of s.suppliers ?? []) {
+        byId.set(link.supplier_id, link.supplier_name);
+      }
+    }
+    return [...byId.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
+  }, [spools]);
+  const hasUnsupplied = (spools ?? []).some((s) => (s.suppliers ?? []).length === 0);
   const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => {
     const nameA = (catalogMap[a]?.name || '').toLowerCase();
     const nameB = (catalogMap[b]?.name || '').toLowerCase();
@@ -1340,7 +1401,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 || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
 
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
@@ -1462,6 +1523,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     setMaterialFilter('');
     setBrandFilter('');
     setCategoryFilter('');
+    setSupplierFilter('');
     setSpoolFilter('');
     setStockFilter('all');
     setSearch('');
@@ -1515,6 +1577,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
             <MapPin className="w-4 h-4" />
             {t('locations.manage')}
           </Button>
+          {/* Suppliers (#2988): inventory master data like Locations, so the
+              modal opens from the same toolbar — in both inventory modes. */}
+          <Button variant="secondary" onClick={() => setSuppliersModalOpen(true)}>
+            <Store className="w-4 h-4" />
+            {t('inventory.suppliers.title')}
+          </Button>
           <Button
             variant="secondary"
             disabled={filteredSpools.length === 0}
@@ -1920,6 +1988,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           </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) && (
+          <select
+            value={supplierFilter}
+            onChange={(e) => { setSupplierFilter(e.target.value); resetPage(); }}
+            className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+              supplierFilter
+                ? '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.suppliers.label')}</option>
+            {uniqueSuppliers.map((supplier) => (
+              <option key={supplier.id} value={String(supplier.id)}>{supplier.name}</option>
+            ))}
+            {hasUnsupplied && (
+              <option value="__none__">{t('inventory.suppliers.none')}</option>
+            )}
+          </select>
+        )}
+
         {/* Spool name dropdown chip */}
         {uniqueSpoolCatalogIds.length > 0 && (
           <select
@@ -2520,6 +2610,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         onClose={() => setLocationsModalOpen(false)}
         onPickLocation={(id) => setStorageLocationFilter(String(id))}
       />
+
+      <SuppliersModal open={suppliersModalOpen} onClose={() => setSuppliersModalOpen(false)} />
     </div>
   );
 }

+ 23 - 0
frontend/src/pages/StatsPage.tsx

@@ -38,6 +38,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { api, type ArchiveSlim } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
+import { SupplierStats } from '../components/SupplierStats';
 import { Dashboard, type DashboardWidget } from '../components/Dashboard';
 import { getCurrencySymbol } from '../utils/currency';
 import { formatWeight } from '../utils/weight';
@@ -1066,6 +1067,22 @@ export function StatsPage() {
     queryFn: api.getSettings,
   });
 
+  // The supplier widget aggregates the internal spool table, which is empty
+  // 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).
+  const { data: spoolmanSettings, isPending: spoolmanSettingsPending } = useQuery({
+    queryKey: ['spoolman-settings'],
+    queryFn: api.getSpoolmanSettings,
+    staleTime: 5 * 60 * 1000,
+  });
+  // The rest of the dashboard renders off the archive response, so the card
+  // would otherwise mount — and hit the aggregate endpoint — while the mode
+  // is still unknown. "Not loaded yet" is not "internal mode".
+  const spoolmanModeReady = !spoolmanSettingsPending;
+  const spoolmanMode =
+    spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
+
   // Slim listing (#1894): the filter only needs id + username, and gating it
   // on the admin-level users:read left the dropdown empty for exactly the
   // operators who were granted stats:filter_by_user.
@@ -1190,6 +1207,12 @@ export function StatsPage() {
       component: <FilamentTrendsWidget archives={archives || []} currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
       defaultSize: 4,
     },
+    ...(!spoolmanModeReady || spoolmanMode ? [] : ([{
+      id: 'suppliers',
+      title: t('stats.suppliers.title'),
+      component: <SupplierStats currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
+      defaultSize: 2,
+    }] as DashboardWidget[])),
   ];
 
   return (

+ 7 - 0
frontend/src/utils/inventoryQueries.ts

@@ -7,6 +7,13 @@ export function invalidateInventoryLocations(queryClient: QueryClient) {
   return queryClient.invalidateQueries({ queryKey: inventoryLocationsQueryKey });
 }
 
+/** React Query key for GET /inventory/suppliers (master list + spool counts). */
+export const inventorySuppliersQueryKey = ['inventory-suppliers'] as const;
+
+export function invalidateInventorySuppliers(queryClient: QueryClient) {
+  return queryClient.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
+}
+
 /** Refresh spool list and location counts after inventory mutations. */
 export function invalidateSpoolAndLocationQueries(
   queryClient: QueryClient,

+ 6 - 1
frontend/src/utils/inventorySearch.ts

@@ -25,7 +25,12 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean
     (spool.subtype?.toLowerCase().includes(q) ?? false) ||
     (spool.note?.toLowerCase().includes(q) ?? false) ||
     (spool.slicer_filament_name?.toLowerCase().includes(q) ?? false) ||
-    (spool.storage_location?.toLowerCase().includes(q) ?? false)
+    (spool.storage_location?.toLowerCase().includes(q) ?? false) ||
+    (spool.suppliers?.some(
+      (link) =>
+        link.supplier_name.toLowerCase().includes(q) ||
+        (link.supplier_article_number?.toLowerCase().includes(q) ?? false)
+    ) ?? false)
   );
 }