Selaa lähdekoodia

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

Thomansky 3 päivää sitten
vanhempi
sitoutus
053cfa73ad
46 muutettua tiedostoa jossa 4843 lisäystä ja 54 poistoa
  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 json
 import logging
 import logging
+from datetime import date, datetime, time, timezone
 
 
 import httpx
 import httpx
 from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
 from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
 from fastapi.responses import Response, StreamingResponse
 from fastapi.responses import Response, StreamingResponse
 from pydantic import BaseModel, Field, field_validator
 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.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 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_catalog import SpoolCatalogEntry
 from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 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.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.spool import (
 from backend.app.schemas.spool import (
@@ -45,6 +47,14 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
     normalize_extra_colors,
 )
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 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.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
     DUPLICATE_LOCATION_NAME,
     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.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 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.services.tag_conflict import tag_already_linked
 from backend.app.utils.filament_ids import (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
@@ -83,6 +94,20 @@ _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
 
 
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 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
 # 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.
 # Content-Length can't stream past the cap into memory before we notice.
 _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
 _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
@@ -759,6 +784,182 @@ async def delete_location(
     return {"status": "deleted"}
     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 ─────────────────────────────────────────────────────
 # ── Color Catalog CRUD ─────────────────────────────────────────────────────
 
 
 
 
@@ -1180,7 +1381,7 @@ async def list_spools(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """List all spools, excluding archived by default."""
     """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:
     if not include_archived:
         query = query.where(Spool.archived_at.is_(None))
         query = query.where(Spool.archived_at.is_(None))
     query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
     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)."""
     """Export the active inventory as CSV (same schema the importer accepts)."""
     from datetime import datetime, timezone
     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)
     result = await db.execute(query)
     spools = list(result.scalars().all())
     spools = list(result.scalars().all())
     content = serialize(spools)
     content = serialize(spools)
@@ -1259,7 +1466,20 @@ async def import_spools_csv(
     created = 0
     created = 0
     for row in preview.rows:
     for row in preview.rows:
         if row.status == "valid" and row.spool is not None:
         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
             created += 1
 
 
     if created:
     if created:
@@ -1299,7 +1519,7 @@ async def get_spool_by_tag(
     if not normalized_tray_uuid and not normalized_tag_uid:
     if not normalized_tray_uuid and not normalized_tag_uid:
         raise HTTPException(400, "Provide tray_uuid and/or 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:
     if not include_archived:
         base_query = base_query.where(Spool.archived_at.is_(None))
         base_query = base_query.where(Spool.archived_at.is_(None))
 
 
@@ -1324,7 +1544,7 @@ async def get_spool(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """Get a single spool with k_profiles."""
     """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()
     spool = result.scalar_one_or_none()
     if not spool:
     if not spool:
         raise HTTPException(404, "Spool not found")
         raise HTTPException(404, "Spool not found")
@@ -1344,9 +1564,13 @@ async def create_spool(
         raise HTTPException(status_code=400, detail=str(exc)) from exc
         raise HTTPException(status_code=400, detail=str(exc)) from exc
     spool = Spool(**payload)
     spool = Spool(**payload)
     db.add(spool)
     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.commit()
     await db.refresh(spool)
     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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
     return result.scalar_one()
 
 
@@ -1368,9 +1592,12 @@ async def bulk_create_spools(
         spool = Spool(**payload)
         spool = Spool(**payload)
         db.add(spool)
         db.add(spool)
         spools.append(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()
     await db.commit()
     ids = [s.id for s in spools]
     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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
@@ -1401,7 +1628,7 @@ async def update_spool(
         setattr(spool, field, value)
         setattr(spool, field, value)
 
 
     await db.commit()
     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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
     return result.scalar_one()
 
 
@@ -1440,7 +1667,7 @@ async def archive_spool(
 
 
     spool.archived_at = datetime.now(timezone.utc)
     spool.archived_at = datetime.now(timezone.utc)
     await db.commit()
     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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
     return result.scalar_one()
 
 
@@ -1459,7 +1686,7 @@ async def restore_spool(
 
 
     spool.archived_at = None
     spool.archived_at = None
     await db.commit()
     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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
     return result.scalar_one()
 
 
@@ -1491,7 +1718,7 @@ async def reset_spool_consumed_counter(
 
 
     spool.weight_used_baseline = spool.weight_used or 0
     spool.weight_used_baseline = spool.weight_used or 0
     await db.commit()
     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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return result.scalar_one()
     return result.scalar_one()
 
 
@@ -1689,6 +1916,56 @@ async def replace_k_profiles(
     return new_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])
 @router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
 async def list_filament_presets(
 async def list_filament_presets(
     spool_id: int,
     spool_id: int,
@@ -1769,7 +2046,7 @@ async def list_assignments(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     query = select(SpoolAssignment).options(
     query = select(SpoolAssignment).options(
-        selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+        selectinload(SpoolAssignment.spool).options(*spool_response_loads()),
         selectinload(SpoolAssignment.printer),
         selectinload(SpoolAssignment.printer),
     )
     )
     if printer_id is not None:
     if printer_id is not None:
@@ -1834,7 +2111,7 @@ async def assign_spool(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     # 1. Validate spool exists and is not archived
     # 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()
     spool = result.scalar_one_or_none()
     if not spool:
     if not spool:
         raise HTTPException(404, "Spool not found")
         raise HTTPException(404, "Spool not found")
@@ -2013,7 +2290,7 @@ async def assign_spool(
     result = await db.execute(
     result = await db.execute(
         select(SpoolAssignment)
         select(SpoolAssignment)
         .options(
         .options(
-            selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+            selectinload(SpoolAssignment.spool).options(*spool_response_loads()),
             selectinload(SpoolAssignment.printer),
             selectinload(SpoolAssignment.printer),
         )
         )
         .where(SpoolAssignment.id == assignment.id)
         .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
     ``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).
     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()
     spool = result.scalar_one_or_none()
     if not spool:
     if not spool:
         raise HTTPException(404, "Spool not found")
         raise HTTPException(404, "Spool not found")
@@ -2199,7 +2476,7 @@ async def link_tag_to_spool(
         spool.data_origin = data.data_origin
         spool.data_origin = data.data_origin
 
 
     await db.commit()
     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()
     return result.scalar_one()
 
 
 
 
@@ -2230,6 +2507,94 @@ async def get_spool_usage_history(
     return list(result.scalars().all())
     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])
 @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
 async def get_all_usage_history(
 async def get_all_usage_history(
     limit: int = 100,
     limit: int = 100,
@@ -2717,5 +3082,15 @@ async def create_spool_from_slot(
             "spool_id": spool.id,
             "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()
     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.spool_filament_preset import SpoolmanFilamentPreset
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 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.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
     enrich_spool_dicts_with_location_id,
     maybe_sync_spoolman_locations,
     maybe_sync_spoolman_locations,
@@ -476,6 +478,19 @@ async def list_spools(
         for m in mapped:
         for m in mapped:
             m["k_profiles"] = kp_by_spool.get(m["id"], [])
             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)
     await enrich_spool_dicts_with_location_id(db, mapped)
     return 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))
     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()]
     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])
     await enrich_spool_dicts_with_location_id(db, [mapped])
     return mapped
     return mapped
 
 
@@ -902,6 +923,22 @@ async def update_spool(
     return _map_spoolman_spool(updated)
     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}")
 @router.delete("/spools/{spool_id}")
 async def delete_spool(
 async def delete_spool(
     spool_id: int = Path(..., gt=0),
     spool_id: int = Path(..., gt=0),
@@ -912,6 +949,8 @@ async def delete_spool(
     client = await _get_client(db)
     client = await _get_client(db)
     async with _translate_spoolman_errors():
     async with _translate_spoolman_errors():
         await client.delete_spool(spool_id)
         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"})
     await ws_manager.broadcast({"type": "inventory_changed"})
     return {"status": "deleted"}
     return {"status": "deleted"}
 
 
@@ -1042,6 +1081,7 @@ async def bulk_delete_spools(
         try:
         try:
             async with _translate_spoolman_errors():
             async with _translate_spoolman_errors():
                 await client.delete_spool(sid)
                 await client.delete_spool(sid)
+            await _purge_local_rows_for_spool(db, sid)
             deleted += 1
             deleted += 1
         except HTTPException as exc:
         except HTTPException as exc:
             errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
             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)
             logger.exception("Spoolman bulk-delete failed for spool %s", sid)
             errors.append({"id": sid, "status": 500, "detail": str(exc)})
             errors.append({"id": sid, "status": 500, "detail": str(exc)})
     if deleted:
     if deleted:
+        await db.commit()
         await ws_manager.broadcast({"type": "inventory_changed"})
         await ws_manager.broadcast({"type": "inventory_changed"})
     return {"deleted": deleted, "errors": errors}
     return {"deleted": deleted, "errors": errors}
 
 
@@ -2149,3 +2190,77 @@ async def save_spoolman_k_profiles(
         await db.refresh(obj)
         await db.refresh(obj)
 
 
     return [_k_profile_to_dict(p) for p in saved]
     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.
     # on fresh installs only — this covers databases whose table predates it.
     await _migrate_location_ha_sensor_unique_binding(conn)
     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
     # 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
     # Bambu spool row (#2909). Runs last so the spool catalogue it reads is
     # whatever this database actually holds.
     # 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:
 async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
     """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
     """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_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 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 import User
 from backend.app.models.user_email_pref import UserEmailPreference
 from backend.app.models.user_email_pref import UserEmailPreference
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_otp_code import UserOTPCode
@@ -90,6 +91,9 @@ __all__ = [
     "SpoolAssignment",
     "SpoolAssignment",
     "SpoolCatalogEntry",
     "SpoolCatalogEntry",
     "SpoolUsageHistory",
     "SpoolUsageHistory",
+    "Supplier",
+    "SpoolSupplier",
+    "SpoolmanSpoolSupplier",
     "ColorCatalogEntry",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "SpoolBuddyDevice",
     "SponsorToastState",
     "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")
     assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
     location: Mapped["Location | None"] = relationship(back_populates="spools")
     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.location import Location  # noqa: E402
 from backend.app.models.spool_assignment import SpoolAssignment  # 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_filament_preset import SpoolFilamentPreset  # noqa: E402
 from backend.app.models.spool_k_profile import SpoolKProfile  # 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 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
 # Visual variant applied to a spool's swatch — purely cosmetic, does not
 # affect MQTT/firmware. Kept independent of `subtype` so users can override
 # affect MQTT/firmware. Kept independent of `subtype` so users can override
 # the rendering hint without touching Bambu's categorical filament label.
 # the rendering hint without touching Bambu's categorical filament label.
@@ -241,9 +243,15 @@ class SpoolResponse(SpoolBase):
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
     k_profiles: list[SpoolKProfileResponse] = []
     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:
     class Config:
         from_attributes = True
         from_attributes = True
+        populate_by_name = True
 
 
 
 
 class SpoolAssignmentCreate(BaseModel):
 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.color_catalog import ColorCatalogEntry
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
+from backend.app.models.supplier import Supplier, supplier_name_key
 from backend.app.schemas.spool import SpoolCreate
 from backend.app.schemas.spool import SpoolCreate
 
 
 # Fixed CSV header, in output order. Round-trips cleanly: export writes these
 # Fixed CSV header, in output order. Round-trips cleanly: export writes these
@@ -54,6 +55,16 @@ CSV_COLUMNS = [
     "storage_location",
     "storage_location",
     "category",
     "category",
     "low_stock_threshold_pct",
     "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
 # 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.
     # same CSV doesn't silently duplicate the inventory.
     duplicate_of_existing: bool = False
     duplicate_of_existing: bool = False
     spool: dict | None = None
     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):
 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]]:
 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.
     """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.
     # issuing a SELECT per row.
     catalog = await _load_color_catalog(db)
     catalog = await _load_color_catalog(db)
     existing_keys = await _load_existing_spool_keys(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:
     def cell(row: list[str], field: str) -> str:
         idx = col_index.get(field)
         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.
             # dict so the ORM object carries it.
             spool_data["last_used"] = last_used
             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(
         rows.append(
             ImportRowResult(
             ImportRowResult(
                 row_number=row_number,
                 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,
                 cross_material_color=cross_material_color,
                 duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
                 duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
                 spool=spool_data,
                 spool=spool_data,
+                supplier_ids=supplier_ids,
+                purchase_supplier_id=purchase_supplier_id,
             )
             )
         )
         )
         valid += 1
         valid += 1
@@ -537,6 +594,13 @@ def _cell_value(spool: Spool, col: str) -> str:
     if col == "remaining":
     if col == "remaining":
         # Derived for display: label_weight - weight_used, clamped at 0.
         # 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))))
         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)
     value = getattr(spool, col, None)
     if value is None:
     if value is None:
         return ""
         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.
     # when creating SpoolAssignment runs synchronously outside the greenlet.
     spool.k_profiles = []
     spool.k_profiles = []
     spool.assignments = []
     spool.assignments = []
+    spool.supplier_links = []
     db.add(spool)
     db.add(spool)
     await db.flush()
     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(
     logger.info(
         "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
         "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
         spool.id,
         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 endpoint creates the requested number of spools
 - Bulk create with quantity=1 (single spool)
 - Bulk create with quantity=1 (single spool)
 - Bulk create returns spools with k_profiles loaded
 - Bulk create returns spools with k_profiles loaded
+- Bulk create copies the inherited supplier links onto every copy
 """
 """
 
 
 from unittest.mock import AsyncMock, MagicMock
 from unittest.mock import AsyncMock, MagicMock
@@ -12,6 +13,8 @@ from unittest.mock import AsyncMock, MagicMock
 import pytest
 import pytest
 from pydantic import ValidationError
 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
 from backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate
 
 
 # ── Schema Validation ──────────────────────────────────────────────────────
 # ── Schema Validation ──────────────────────────────────────────────────────
@@ -96,6 +99,40 @@ def _make_mock_spool(spool_id):
     return spool
     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:
 class TestBulkCreateEndpoint:
     """Tests for the bulk_create_spools endpoint logic."""
     """Tests for the bulk_create_spools endpoint logic."""
 
 
@@ -109,15 +146,7 @@ class TestBulkCreateEndpoint:
             quantity=3,
             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)
         result = await bulk_create_spools(data=data, db=db, _=None)
 
 
@@ -135,14 +164,7 @@ class TestBulkCreateEndpoint:
             quantity=1,
             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)
         result = await bulk_create_spools(data=data, db=db, _=None)
 
 
@@ -165,19 +187,15 @@ class TestBulkCreateEndpoint:
             quantity=3,
             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)
         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.material == "ABS"
             assert spool_obj.brand == "Bambu Lab"
             assert spool_obj.brand == "Bambu Lab"
             assert spool_obj.color_name == "Black"
             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([]),
     getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
     unassignSpool: vi.fn().mockResolvedValue({}),
     unassignSpool: vi.fn().mockResolvedValue({}),
     unassignSpoolmanSlot: 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 {
   ApiError: class ApiError extends Error {
     status: number;
     status: number;
@@ -1367,6 +1372,105 @@ describe('SpoolFormModal copy mode', () => {
     const [payload] = vi.mocked(api.createSpool).mock.calls[0];
     const [payload] = vi.mocked(api.createSpool).mock.calls[0];
     expect((payload as Record<string, unknown>).weight_used).toBe(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
 // 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: ['inventory-spools'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-locations'] });
       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.useRealTimers();
       vi.unstubAllGlobals();
       vi.unstubAllGlobals();

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

@@ -67,11 +67,12 @@ describe('InventoryPage — header layout', () => {
       expect(group.querySelector('button')).toBeInTheDocument();
       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.
     // whatever the viewport is.
     expect(group.className).toContain('flex-wrap');
     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.
     // 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[];
   k_profiles?: SpoolKProfile[];
   storage_location?: string | null;
   storage_location?: string | null;
   location_id?: number | 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 {
 export interface SpoolmanBulkCreateResult {
@@ -6662,6 +6717,36 @@ export const api = {
     request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
     request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
   resetSpoolCatalog: () =>
   resetSpoolCatalog: () =>
     request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
     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: () =>
   getLocations: () =>
     request<StorageLocation[]>('/inventory/locations'),
     request<StorageLocation[]>('/inventory/locations'),
   createLocation: (data: { name: string; identifier?: string | null }) =>
   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 { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
+import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { normaliseFlow } from '../utils/nozzleFlow';
 import { normaliseFlow } from '../utils/nozzleFlow';
@@ -81,6 +82,13 @@ export function SpoolFormModal({
   // through core_weight.
   // through core_weight.
   const [coreWeightTouched, setCoreWeightTouched] = useState(false);
   const [coreWeightTouched, setCoreWeightTouched] = useState(false);
   const [locationIdTouched, setLocationIdTouched] = 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 [quickAdd, setQuickAdd] = useState(false);
   const [quantity, setQuantity] = useState(1);
   const [quantity, setQuantity] = useState(1);
 
 
@@ -446,10 +454,23 @@ export function SpoolFormModal({
         } else {
         } else {
           setSelectedProfiles(new Map());
           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 {
       } else {
         setFormData(defaultFormData);
         setFormData(defaultFormData);
         setPresetInputValue('');
         setPresetInputValue('');
         setSelectedProfiles(new Map());
         setSelectedProfiles(new Map());
+        setSupplierLinks([]);
       }
       }
       // Reset on every open, not just the create path (#1905). The modal keeps
       // 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
       // 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).
       // inheriting. Only Spoolman spools report the flag (#2908).
       setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
       setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
       setLocationIdTouched(false);
       setLocationIdTouched(false);
+      setSupplierLinksTouched(false);
     }
     }
   }, [isOpen, spool, mode, isCopying]);
   }, [isOpen, spool, mode, isCopying]);
 
 
@@ -582,6 +604,8 @@ export function SpoolFormModal({
       if (newSpool?.id) {
       if (newSpool?.id) {
         const ok = await savePrinterProfiles(newSpool.id);
         const ok = await savePrinterProfiles(newSpool.id);
         if (!ok) return;
         if (!ok) return;
+        const suppliersOk = await saveSupplierLinks(newSpool.id);
+        if (!suppliersOk) return;
       }
       }
       await refreshSpoolQueries();
       await refreshSpoolQueries();
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
@@ -625,6 +649,12 @@ export function SpoolFormModal({
           await savePrinterProfiles(s.id);
           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();
       await refreshSpoolQueries();
       if (onSpoolsCreated) onSpoolsCreated(createdSpools);
       if (onSpoolsCreated) onSpoolsCreated(createdSpools);
       if (spoolmanResult && spoolmanResult.failed_count > 0) {
       if (spoolmanResult && spoolmanResult.failed_count > 0) {
@@ -658,6 +688,8 @@ export function SpoolFormModal({
       if (spool?.id) {
       if (spool?.id) {
         const ok = await savePrinterProfiles(spool.id);
         const ok = await savePrinterProfiles(spool.id);
         if (!ok) return;
         if (!ok) return;
+        const suppliersOk = await saveSupplierLinks(spool.id);
+        if (!suppliersOk) return;
       }
       }
       await refreshSpoolQueries();
       await refreshSpoolQueries();
       showToast(t('inventory.spoolUpdated'), 'success');
       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
   // Save everything the Printers tab holds: one K profile per hotend and the
   // per-printer-model preset overrides. Returns false if either write failed,
   // 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.
   // which keeps the modal open so the user does not lose what they picked.
@@ -1117,6 +1184,21 @@ export function SpoolFormModal({
                 />
                 />
               </div>
               </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) */}
               {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
               {isEditing && spool && !spoolmanMode && (
               {isEditing && spool && !spoolmanMode && (
                 <div>
                 <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 { useToast } from '../contexts/ToastContext';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api, ApiError } from '../api/client';
 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
 // The only auth-failure close code /api/v1/ws emits (websocket.py
 // _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
 // _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
@@ -408,6 +408,7 @@ export function useWebSocket() {
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('spoolman-inventory-spools');
         debouncedInvalidate('spoolman-inventory-spools');
         debouncedInvalidate(inventoryLocationsQueryKey[0]);
         debouncedInvalidate(inventoryLocationsQueryKey[0]);
+        debouncedInvalidate(inventorySuppliersQueryKey[0]);
         break;
         break;
 
 
       case 'spool_assignment_changed':
       case 'spool_assignment_changed':

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

@@ -1670,6 +1670,17 @@ export default {
     printActivity: 'Druckaktivität',
     printActivity: 'Druckaktivität',
     filamentTypes: 'Filamenttypen',
     filamentTypes: 'Filamenttypen',
     filamentTrends: 'Filamenttrends',
     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',
     failureAnalysis: 'Fehleranalyse',
     timeAccuracy: 'Zeitgenauigkeit',
     timeAccuracy: 'Zeitgenauigkeit',
     successful: 'Erfolgreich:',
     successful: 'Erfolgreich:',
@@ -4814,6 +4825,48 @@ export default {
     storageLocationNone: 'Kein Lagerort',
     storageLocationNone: 'Kein Lagerort',
     lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
     lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
     lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
     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")
     // RFID button rename (was "Tag löschen")
     clearRfid: 'RFID-Tag löschen',
     clearRfid: 'RFID-Tag löschen',
     rfidCleared: 'RFID-Tag gelöscht',
     rfidCleared: 'RFID-Tag gelöscht',

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

@@ -1687,6 +1687,17 @@ export default {
     printActivity: 'Print Activity',
     printActivity: 'Print Activity',
     filamentTypes: 'Filament Types',
     filamentTypes: 'Filament Types',
     filamentTrends: 'Filament Trends',
     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',
     failureAnalysis: 'Failure Analysis',
     timeAccuracy: 'Time Accuracy',
     timeAccuracy: 'Time Accuracy',
     successful: 'Successful:',
     successful: 'Successful:',
@@ -4854,6 +4865,48 @@ export default {
     storageLocationNone: 'No location set',
     storageLocationNone: 'No location set',
     lowStockThresholdOverride: 'Low-stock threshold (this spool)',
     lowStockThresholdOverride: 'Low-stock threshold (this spool)',
     lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
     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
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: 'Clear RFID Tag',
     clearRfid: 'Clear RFID Tag',

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

@@ -1670,6 +1670,17 @@ export default {
     printActivity: 'Actividad de impresión',
     printActivity: 'Actividad de impresión',
     filamentTypes: 'Tipos de filamento',
     filamentTypes: 'Tipos de filamento',
     filamentTrends: 'Tendencias del 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',
     failureAnalysis: 'Análisis de fallos',
     timeAccuracy: 'Precisión temporal',
     timeAccuracy: 'Precisión temporal',
     successful: 'Con éxito:',
     successful: 'Con éxito:',
@@ -4817,6 +4828,48 @@ export default {
     storageLocationNone: 'Sin ubicación establecida',
     storageLocationNone: 'Sin ubicación establecida',
     lowStockThresholdOverride: 'Umbral de existencias bajas (esta bobina)',
     lowStockThresholdOverride: 'Umbral de existencias bajas (esta bobina)',
     lowStockThresholdOverrideHelp: 'Déjelo en blanco para usar el umbral global ({{global}}%).',
     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
     // RFID button rename
     clearRfid: 'Borrar etiqueta RFID',
     clearRfid: 'Borrar etiqueta RFID',
     rfidCleared: 'Etiqueta RFID borrada',
     rfidCleared: 'Etiqueta RFID borrada',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: 'Activité d\'impression',
     printActivity: 'Activité d\'impression',
     filamentTypes: 'Types de filament',
     filamentTypes: 'Types de filament',
     filamentTrends: 'Tendances 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',
     failureAnalysis: 'Analyse des échecs',
     timeAccuracy: 'Précision du temps',
     timeAccuracy: 'Précision du temps',
     successful: 'Succès :',
     successful: 'Succès :',
@@ -4803,6 +4813,47 @@ export default {
     storageLocationNone: 'Aucun emplacement défini',
     storageLocationNone: 'Aucun emplacement défini',
     lowStockThresholdOverride: 'Seuil bas (cette bobine)',
     lowStockThresholdOverride: 'Seuil bas (cette bobine)',
     lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
     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',
     clearRfid: 'Effacer le tag RFID',
     rfidCleared: 'Tag RFID effacé',
     rfidCleared: 'Tag RFID effacé',
     archive: 'Archiver',
     archive: 'Archiver',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: 'Attivita di stampa',
     printActivity: 'Attivita di stampa',
     filamentTypes: 'Tipi di filamento',
     filamentTypes: 'Tipi di filamento',
     filamentTrends: 'Trend 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',
     failureAnalysis: 'Analisi guasti',
     timeAccuracy: 'Accuratezza tempo',
     timeAccuracy: 'Accuratezza tempo',
     successful: 'Riuscite:',
     successful: 'Riuscite:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: 'Nessuna posizione impostata',
     storageLocationNone: 'Nessuna posizione impostata',
     lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
     lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
     lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
     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',
     clearRfid: 'Cancella tag RFID',
     rfidCleared: 'Tag RFID cancellato',
     rfidCleared: 'Tag RFID cancellato',
     archive: 'Archivia',
     archive: 'Archivia',

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

@@ -1669,6 +1669,17 @@ export default {
     printActivity: '印刷アクティビティ',
     printActivity: '印刷アクティビティ',
     filamentTypes: 'フィラメントタイプ',
     filamentTypes: 'フィラメントタイプ',
     filamentTrends: 'フィラメントトレンド',
     filamentTrends: 'フィラメントトレンド',
+    // Consumption/cost grouped by purchase-source supplier (#2988).
+    suppliers: {
+      title: 'サプライヤー別',
+      empty: 'まだ購入記録がありません。スプールにどのサプライヤーから購入したかを設定すると、ここに消費量とコストが集計されます。',
+      error: '仕入先の統計を読み込めませんでした',
+      supplier: 'サプライヤー',
+      spools: 'スプール',
+      remaining: '残量',
+      consumed: '消費量',
+      cost: 'コスト',
+    },
     failureAnalysis: '失敗分析',
     failureAnalysis: '失敗分析',
     timeAccuracy: '時間精度',
     timeAccuracy: '時間精度',
     successful: '成功',
     successful: '成功',
@@ -4814,6 +4825,48 @@ export default {
     storageLocationNone: '保管場所未設定',
     storageLocationNone: '保管場所未設定',
     lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
     lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
     lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
     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タグをクリア',
     clearRfid: 'RFIDタグをクリア',
     rfidCleared: 'RFIDタグをクリアしました',
     rfidCleared: 'RFIDタグをクリアしました',
     archive: 'アーカイブ',
     archive: 'アーカイブ',

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

@@ -1603,6 +1603,16 @@ export default {
     printActivity: '인쇄 활동',
     printActivity: '인쇄 활동',
     filamentTypes: '필라멘트 종류',
     filamentTypes: '필라멘트 종류',
     filamentTrends: '필라멘트 추세',
     filamentTrends: '필라멘트 추세',
+    suppliers: {
+      title: '공급업체별',
+      empty: '아직 기록된 구매가 없습니다. 스풀에 구매한 공급업체를 표시하면 여기에서 소비량과 비용이 그룹화됩니다.',
+      error: '공급업체 통계를 불러올 수 없습니다',
+      supplier: '공급업체',
+      spools: '스풀',
+      remaining: '남은 양',
+      consumed: '소비량',
+      cost: '비용',
+    },
     failureAnalysis: '실패 분석',
     failureAnalysis: '실패 분석',
     timeAccuracy: '시간 정확도',
     timeAccuracy: '시간 정확도',
     successful: '성공:',
     successful: '성공:',
@@ -4599,6 +4609,47 @@ export default {
     categoryNone: '미분류',
     categoryNone: '미분류',
     lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)',
     lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)',
     lowStockThresholdOverrideHelp: '전역 임계값({{global}}%)을 사용하려면 비워두세요.',
     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 태그 초기화',
     clearRfid: 'RFID 태그 초기화',
     rfidCleared: 'RFID 태그가 초기화되었습니다',
     rfidCleared: 'RFID 태그가 초기화되었습니다',
     archive: '보관',
     archive: '보관',

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

@@ -1687,6 +1687,17 @@ export default {
     printActivity: 'Afdrukactiviteit',
     printActivity: 'Afdrukactiviteit',
     filamentTypes: 'Filamenttypen',
     filamentTypes: 'Filamenttypen',
     filamentTrends: 'Filamenttrends',
     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',
     failureAnalysis: 'Foutenanalyse',
     timeAccuracy: 'Tijdnauwkeurigheid',
     timeAccuracy: 'Tijdnauwkeurigheid',
     successful: 'Geslaagd:',
     successful: 'Geslaagd:',
@@ -4854,6 +4865,48 @@ export default {
     storageLocationNone: 'Geen locatie ingesteld',
     storageLocationNone: 'Geen locatie ingesteld',
     lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)',
     lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)',
     lowStockThresholdOverrideHelp: 'Laat leeg om de globale drempel ({{global}}%) te gebruiken.',
     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
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: 'RFID-tag wissen',
     clearRfid: 'RFID-tag wissen',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: 'Atividade de Impressão',
     printActivity: 'Atividade de Impressão',
     filamentTypes: 'Tipos de Filamento',
     filamentTypes: 'Tipos de Filamento',
     filamentTrends: 'Tendências 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',
     failureAnalysis: 'Análise de Falhas',
     timeAccuracy: 'Precisão do Tempo',
     timeAccuracy: 'Precisão do Tempo',
     successful: 'Bem-sucedido:',
     successful: 'Bem-sucedido:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: 'Sem local definido',
     storageLocationNone: 'Sem local definido',
     lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
     lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
     lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
     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',
     clearRfid: 'Limpar tag RFID',
     rfidCleared: 'Tag RFID limpa',
     rfidCleared: 'Tag RFID limpa',
     archive: 'Arquivar',
     archive: 'Arquivar',

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

@@ -1600,6 +1600,16 @@ export default {
     printActivity: "Активность печати",
     printActivity: "Активность печати",
     filamentTypes: "Типы филамента",
     filamentTypes: "Типы филамента",
     filamentTrends: "Расход филамента",
     filamentTrends: "Расход филамента",
+    suppliers: {
+      title: "По поставщикам",
+      empty: "Покупки пока не зарегистрированы. Отметьте на катушке, у какого поставщика она была куплена, чтобы сгруппировать здесь расход и затраты.",
+      error: 'Не удалось загрузить статистику по поставщикам',
+      supplier: "Поставщик",
+      spools: "Катушки",
+      remaining: "Остаток",
+      consumed: "Израсходовано",
+      cost: "Затраты",
+    },
     failureAnalysis: "Анализ неудач",
     failureAnalysis: "Анализ неудач",
     timeAccuracy: "Точность оценки времени",
     timeAccuracy: "Точность оценки времени",
     successful: "Успешно:",
     successful: "Успешно:",
@@ -4590,6 +4600,47 @@ export default {
     storageLocationNone: "Место не указано",
     storageLocationNone: "Место не указано",
     lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
     lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
     lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).",
     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-метку",
     clearRfid: "Очистить RFID-метку",
     rfidCleared: "RFID-метка очищена",
     rfidCleared: "RFID-метка очищена",
     archive: "Архивировать",
     archive: "Архивировать",

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

@@ -1687,6 +1687,16 @@ export default {
     printActivity: 'Utskriftsaktivitet',
     printActivity: 'Utskriftsaktivitet',
     filamentTypes: 'Filamenttyper',
     filamentTypes: 'Filamenttyper',
     filamentTrends: 'Filamenttrender',
     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',
     failureAnalysis: 'Felanalys',
     timeAccuracy: 'Tidsnoggrannhet',
     timeAccuracy: 'Tidsnoggrannhet',
     successful: 'Lyckade:',
     successful: 'Lyckade:',
@@ -4854,6 +4864,47 @@ errors: {
     storageLocationNone: 'Ingen lagringsplats inställd',
     storageLocationNone: 'Ingen lagringsplats inställd',
     lowStockThresholdOverride: 'Lågt lagertröskelvärde (denna rulle)',
     lowStockThresholdOverride: 'Lågt lagertröskelvärde (denna rulle)',
     lowStockThresholdOverrideHelp: 'Lämna tomt för att använda det globala tröskelvärdet ({{global}}%).',
     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
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: 'Rensa RFID-tagg',
     clearRfid: 'Rensa RFID-tagg',

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

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Baskı Etkinliği',
     printActivity: 'Baskı Etkinliği',
     filamentTypes: 'Filament Türleri',
     filamentTypes: 'Filament Türleri',
     filamentTrends: 'Filament Trendleri',
     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',
     failureAnalysis: 'Başarısızlık Analizi',
     timeAccuracy: 'Süre Doğruluğu',
     timeAccuracy: 'Süre Doğruluğu',
     successful: 'Başarılı:',
     successful: 'Başarılı:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: 'Konum ayarlanmamış',
     storageLocationNone: 'Konum ayarlanmamış',
     lowStockThresholdOverride: 'Düşük stok eşiği (bu makara)',
     lowStockThresholdOverride: 'Düşük stok eşiği (bu makara)',
     lowStockThresholdOverrideHelp: 'Global eşiği kullanmak için boş bırakın (%{{global}}).',
     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',
     clearRfid: 'RFID Etiketini Temizle',
     rfidCleared: 'RFID etiketi temizlendi',
     rfidCleared: 'RFID etiketi temizlendi',
     archive: 'Arşivle',
     archive: 'Arşivle',

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

@@ -1686,6 +1686,16 @@ export default {
     printActivity: "Активність друку",
     printActivity: "Активність друку",
     filamentTypes: "Типи філаментів",
     filamentTypes: "Типи філаментів",
     filamentTrends: "Тенденції філаменту",
     filamentTrends: "Тенденції філаменту",
+    suppliers: {
+      title: "За постачальником",
+      empty: "Покупок ще не зафіксовано. Позначте на котушці, у якого постачальника її придбано, щоб згрупувати тут споживання та витрати.",
+      error: 'Не вдалося завантажити статистику постачальників',
+      supplier: "Постачальник",
+      spools: "Котушки",
+      remaining: "Залишок",
+      consumed: "Спожито",
+      cost: "Вартість",
+    },
     failureAnalysis: "Аналіз невдач",
     failureAnalysis: "Аналіз невдач",
     timeAccuracy: "Точність оцінки часу",
     timeAccuracy: "Точність оцінки часу",
     successful: "Успішно:",
     successful: "Успішно:",
@@ -4851,6 +4861,47 @@ export default {
     storageLocationNone: "Місцезнаходження не встановлено",
     storageLocationNone: "Місцезнаходження не встановлено",
     lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
     lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
     lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).",
     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
     // RFID button rename (was "Delete Tag" — confusing because it sounds like a
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     // taxonomy delete; this clears the RFID tag/UUID off the spool record)
     clearRfid: "Очистити тег RFID.",
     clearRfid: "Очистити тег RFID.",

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: '打印活动',
     printActivity: '打印活动',
     filamentTypes: '耗材类型',
     filamentTypes: '耗材类型',
     filamentTrends: '耗材趋势',
     filamentTrends: '耗材趋势',
+    suppliers: {
+      title: '按供应商',
+      empty: '暂无购买记录。在料盘上标记其购买来源供应商后,即可在此按供应商统计消耗和成本。',
+      error: '无法加载供应商统计数据',
+      supplier: '供应商',
+      spools: '料盘',
+      remaining: '剩余',
+      consumed: '已消耗',
+      cost: '成本',
+    },
     failureAnalysis: '失败分析',
     failureAnalysis: '失败分析',
     timeAccuracy: '时间准确度',
     timeAccuracy: '时间准确度',
     successful: '成功:',
     successful: '成功:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: '未设置位置',
     storageLocationNone: '未设置位置',
     lowStockThresholdOverride: '低库存阈值(此料盘)',
     lowStockThresholdOverride: '低库存阈值(此料盘)',
     lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
     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 标签',
     clearRfid: '清除 RFID 标签',
     rfidCleared: 'RFID 标签已清除',
     rfidCleared: 'RFID 标签已清除',
     archive: '归档',
     archive: '归档',

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

@@ -1670,6 +1670,16 @@ export default {
     printActivity: '列印活動',
     printActivity: '列印活動',
     filamentTypes: '耗材類型',
     filamentTypes: '耗材類型',
     filamentTrends: '耗材趨勢',
     filamentTrends: '耗材趨勢',
+    suppliers: {
+      title: '依供應商',
+      empty: '尚未記錄任何購買。在料盤上標記其購買來源的供應商,即可在此依供應商彙整消耗量與成本。',
+      error: '無法載入供應商統計資料',
+      supplier: '供應商',
+      spools: '料盤',
+      remaining: '剩餘',
+      consumed: '已消耗',
+      cost: '成本',
+    },
     failureAnalysis: '失敗分析',
     failureAnalysis: '失敗分析',
     timeAccuracy: '時間準確度',
     timeAccuracy: '時間準確度',
     successful: '成功:',
     successful: '成功:',
@@ -4802,6 +4812,47 @@ export default {
     storageLocationNone: '未設定位置',
     storageLocationNone: '未設定位置',
     lowStockThresholdOverride: '低庫存閾值(此料盤)',
     lowStockThresholdOverride: '低庫存閾值(此料盤)',
     lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
     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 標籤',
     clearRfid: '清除 RFID 標籤',
     rfidCleared: 'RFID 標籤已清除',
     rfidCleared: 'RFID 標籤已清除',
     archive: '歸檔',
     archive: '歸檔',

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

@@ -7,7 +7,7 @@ import {
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
-  Upload, Download,
+  Upload, Download, Store,
 } from 'lucide-react';
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -22,6 +22,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
 import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
 import { LocationsModal } from '../components/LocationsModal';
 import { LocationsModal } from '../components/LocationsModal';
+import { SuppliersModal } from '../components/SuppliersModal';
 import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
 import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -98,6 +99,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'printed_total', label: 'Printed Total', visible: false },
   { id: 'printed_total', label: 'Printed Total', visible: false },
   { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
   { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
   { id: 'note', label: 'Note', visible: false },
   { id: 'note', label: 'Note', visible: false },
+  { id: 'suppliers', label: 'Suppliers', visible: false },
   { id: 'pa_k', label: 'PA(K)', visible: true },
   { id: 'pa_k', label: 'PA(K)', visible: true },
   { id: 'tag_id', label: 'Tag ID', visible: false },
   { id: 'tag_id', label: 'Tag ID', visible: false },
   { id: 'data_origin', label: 'Data Origin', 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_total: () => 'Printed Total',
   printed_since_weight: () => 'Printed Since Weight',
   printed_since_weight: () => 'Printed Since Weight',
   note: (t) => t('inventory.note'),
   note: (t) => t('inventory.note'),
+  suppliers: (t) => t('inventory.suppliers.label'),
   pa_k: () => 'PA(K)',
   pa_k: () => 'PA(K)',
   tag_id: () => 'Tag ID',
   tag_id: () => 'Tag ID',
   data_origin: () => 'Data Origin',
   data_origin: () => 'Data Origin',
@@ -367,6 +370,31 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   note: ({ spool }) => (
   note: ({ spool }) => (
     <span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
     <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 }) => {
   pa_k: ({ spool }) => {
     const count = spool.k_profiles?.length ?? 0;
     const count = spool.k_profiles?.length ?? 0;
     if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
     if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
@@ -513,6 +541,13 @@ const columnSortValues: Record<
   used: (s) => s.weight_used,
   used: (s) => s.weight_used,
   remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
   remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
   note: (s) => (s.note || '').toLowerCase(),
   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(),
   data_origin: (s) => (s.data_origin || '').toLowerCase(),
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   stock: (s) => s.slicer_filament ? 1 : 0,
   stock: (s) => s.slicer_filament ? 1 : 0,
@@ -595,6 +630,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [csvImportOpen, setCsvImportOpen] = useState(false);
   const [csvImportOpen, setCsvImportOpen] = useState(false);
   const [exportingCsv, setExportingCsv] = useState(false);
   const [exportingCsv, setExportingCsv] = useState(false);
   const [locationsModalOpen, setLocationsModalOpen] = useState(false);
   const [locationsModalOpen, setLocationsModalOpen] = useState(false);
+  const [suppliersModalOpen, setSuppliersModalOpen] = useState(false);
 
 
   // Filter state
   // Filter state
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -602,6 +638,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [materialFilter, setMaterialFilter] = useState('');
   const [materialFilter, setMaterialFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
   const [categoryFilter, setCategoryFilter] = useState('');
   const [categoryFilter, setCategoryFilter] = useState('');
+  // Filter on an assigned supplier (#2988), same shape as category.
+  const [supplierFilter, setSupplierFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
@@ -636,7 +674,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   // honest vs. what the user is actually looking at.
   // honest vs. what the user is actually looking at.
   useEffect(() => {
   useEffect(() => {
     setSelectedIds(new Set());
     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)
   // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
   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
     // Spool name dropdown
     if (spoolFilter) {
     if (spoolFilter) {
       const catalogId = Number(spoolFilter);
       const catalogId = Number(spoolFilter);
@@ -1309,7 +1359,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     }
     }
 
 
     return filtered;
     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
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
   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 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 uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort();
   const hasUncategorized = (spools ?? []).some((s) => !s.category);
   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 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 nameA = (catalogMap[a]?.name || '').toLowerCase();
     const nameB = (catalogMap[b]?.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());
   const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.location_id && !s.storage_location?.trim());
 
 
   // Check if any filters are non-default
   // 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[]) => {
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
     setColumnConfig(config);
@@ -1462,6 +1523,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     setMaterialFilter('');
     setMaterialFilter('');
     setBrandFilter('');
     setBrandFilter('');
     setCategoryFilter('');
     setCategoryFilter('');
+    setSupplierFilter('');
     setSpoolFilter('');
     setSpoolFilter('');
     setStockFilter('all');
     setStockFilter('all');
     setSearch('');
     setSearch('');
@@ -1515,6 +1577,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
             <MapPin className="w-4 h-4" />
             <MapPin className="w-4 h-4" />
             {t('locations.manage')}
             {t('locations.manage')}
           </Button>
           </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
           <Button
             variant="secondary"
             variant="secondary"
             disabled={filteredSpools.length === 0}
             disabled={filteredSpools.length === 0}
@@ -1920,6 +1988,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           </select>
           </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 */}
         {/* Spool name dropdown chip */}
         {uniqueSpoolCatalogIds.length > 0 && (
         {uniqueSpoolCatalogIds.length > 0 && (
           <select
           <select
@@ -2520,6 +2610,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         onClose={() => setLocationsModalOpen(false)}
         onClose={() => setLocationsModalOpen(false)}
         onPickLocation={(id) => setStorageLocationFilter(String(id))}
         onPickLocation={(id) => setStorageLocationFilter(String(id))}
       />
       />
+
+      <SuppliersModal open={suppliersModalOpen} onClose={() => setSuppliersModalOpen(false)} />
     </div>
     </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 { api, type ArchiveSlim } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { FilamentTrends } from '../components/FilamentTrends';
+import { SupplierStats } from '../components/SupplierStats';
 import { Dashboard, type DashboardWidget } from '../components/Dashboard';
 import { Dashboard, type DashboardWidget } from '../components/Dashboard';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
 import { formatWeight } from '../utils/weight';
 import { formatWeight } from '../utils/weight';
@@ -1066,6 +1067,22 @@ export function StatsPage() {
     queryFn: api.getSettings,
     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
   // 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
   // on the admin-level users:read left the dropdown empty for exactly the
   // operators who were granted stats:filter_by_user.
   // 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} />,
       component: <FilamentTrendsWidget archives={archives || []} currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
       defaultSize: 4,
       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 (
   return (

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

@@ -7,6 +7,13 @@ export function invalidateInventoryLocations(queryClient: QueryClient) {
   return queryClient.invalidateQueries({ queryKey: inventoryLocationsQueryKey });
   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. */
 /** Refresh spool list and location counts after inventory mutations. */
 export function invalidateSpoolAndLocationQueries(
 export function invalidateSpoolAndLocationQueries(
   queryClient: QueryClient,
   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.subtype?.toLowerCase().includes(q) ?? false) ||
     (spool.note?.toLowerCase().includes(q) ?? false) ||
     (spool.note?.toLowerCase().includes(q) ?? false) ||
     (spool.slicer_filament_name?.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)
   );
   );
 }
 }