From 053cfa73ad5b6a11a4b1f0d648aaddabd5be8969 Mon Sep 17 00:00:00 2001 From: Thomansky <73141171+Thomansky@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:14:07 +0200 Subject: [PATCH] Suppliers as a managed list with per-spool assignments (#2996) --- backend/app/api/routes/inventory.py | 411 +++++++- backend/app/api/routes/spoolman_inventory.py | 115 +++ backend/app/core/database.py | 205 ++++ backend/app/models/__init__.py | 4 + backend/app/models/spool.py | 6 + backend/app/models/supplier.py | 133 +++ backend/app/schemas/spool.py | 8 + backend/app/schemas/supplier.py | 112 +++ backend/app/services/spool_csv.py | 64 ++ backend/app/services/spool_tag_matcher.py | 8 + backend/app/services/supplier_links.py | 106 ++ .../tests/integration/test_suppliers_api.py | 926 ++++++++++++++++++ backend/tests/unit/test_bulk_spool_create.py | 72 +- .../unit/test_supplier_tables_migration.py | 277 ++++++ .../components/SpoolFormModal.test.tsx | 104 ++ .../components/SupplierSection.test.tsx | 154 +++ .../components/SupplierStats.test.tsx | 70 ++ .../components/SuppliersModal.test.tsx | 163 +++ .../src/__tests__/hooks/useWebSocket.test.ts | 3 + .../pages/InventoryPageHeaderLayout.test.tsx | 7 +- .../pages/StatsPageSuppliers.test.tsx | 144 +++ frontend/src/api/client.ts | 85 ++ frontend/src/components/SpoolFormModal.tsx | 82 ++ frontend/src/components/SupplierStats.tsx | 82 ++ frontend/src/components/SuppliersModal.tsx | 386 ++++++++ .../components/spool-form/SupplierSection.tsx | 255 +++++ frontend/src/hooks/useWebSocket.ts | 3 +- frontend/src/i18n/locales/de.ts | 53 + frontend/src/i18n/locales/en.ts | 53 + frontend/src/i18n/locales/es.ts | 53 + frontend/src/i18n/locales/fr.ts | 51 + frontend/src/i18n/locales/it.ts | 51 + frontend/src/i18n/locales/ja.ts | 53 + frontend/src/i18n/locales/ko.ts | 51 + frontend/src/i18n/locales/nl.ts | 53 + frontend/src/i18n/locales/pt-BR.ts | 51 + frontend/src/i18n/locales/ru.ts | 51 + frontend/src/i18n/locales/sv.ts | 51 + frontend/src/i18n/locales/tr.ts | 51 + frontend/src/i18n/locales/uk.ts | 51 + frontend/src/i18n/locales/zh-CN.ts | 51 + frontend/src/i18n/locales/zh-TW.ts | 51 + frontend/src/pages/InventoryPage.tsx | 100 +- frontend/src/pages/StatsPage.tsx | 23 + frontend/src/utils/inventoryQueries.ts | 7 + frontend/src/utils/inventorySearch.ts | 7 +- 46 files changed, 4843 insertions(+), 54 deletions(-) create mode 100644 backend/app/models/supplier.py create mode 100644 backend/app/schemas/supplier.py create mode 100644 backend/app/services/supplier_links.py create mode 100644 backend/tests/integration/test_suppliers_api.py create mode 100644 backend/tests/unit/test_supplier_tables_migration.py create mode 100644 frontend/src/__tests__/components/SupplierSection.test.tsx create mode 100644 frontend/src/__tests__/components/SupplierStats.test.tsx create mode 100644 frontend/src/__tests__/components/SuppliersModal.test.tsx create mode 100644 frontend/src/__tests__/pages/StatsPageSuppliers.test.tsx create mode 100644 frontend/src/components/SupplierStats.tsx create mode 100644 frontend/src/components/SuppliersModal.tsx create mode 100644 frontend/src/components/spool-form/SupplierSection.tsx diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 4f7353c8e..c0e42b3e1 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -1,11 +1,12 @@ import json import logging +from datetime import date, datetime, time, timezone import httpx from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel, Field, field_validator -from sqlalchemy import delete, func, select +from sqlalchemy import case, delete, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -28,6 +29,7 @@ from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.spool_catalog import SpoolCatalogEntry from backend.app.models.spool_filament_preset import SpoolFilamentPreset from backend.app.models.spool_k_profile import SpoolKProfile +from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier, supplier_name_key from backend.app.models.user import User from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate from backend.app.schemas.spool import ( @@ -45,6 +47,14 @@ from backend.app.schemas.spool import ( normalize_extra_colors, ) from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse +from backend.app.schemas.supplier import ( + SpoolSupplierLinkInput, + SpoolSupplierResponse, + SupplierCreate, + SupplierResponse, + SupplierStats, + SupplierUpdate, +) from backend.app.services.ams_slot_presence import spool_present from backend.app.services.location_service import ( DUPLICATE_LOCATION_NAME, @@ -67,6 +77,7 @@ from backend.app.services.spool_csv import ( ) from backend.app.services.spool_filament_preset import resolve_spool_preset from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client +from backend.app.services.supplier_links import apply_supplier_inheritance, apply_supplier_inheritance_to_batch from backend.app.services.tag_conflict import tag_already_linked from backend.app.utils.filament_ids import ( GENERIC_FILAMENT_IDS, @@ -83,6 +94,20 @@ _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values()) router = APIRouter(prefix="/inventory", tags=["inventory"]) + +def spool_response_loads(): + """Loader options for every query that answers with a ``SpoolResponse``. + + Both relationships the schema reads carry the default loader, so the ~50 + other ``select(Spool)`` call sites across the usage tracker, AMS sync, + labels and backup pay nothing for them (#2988). + """ + return ( + selectinload(Spool.k_profiles), + selectinload(Spool.supplier_links).selectinload(SpoolSupplier.supplier), + ) + + # Bounded read size for the CSV import body so a chunked upload with no # Content-Length can't stream past the cap into memory before we notice. _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024 @@ -759,6 +784,182 @@ async def delete_location( return {"status": "deleted"} +# ── Supplier master list (#2988) ─────────────────────────────────────────── +# +# Inventory master data that spools reference, exactly like Locations — so it +# lives here, gated by the inventory permissions, not behind its own +# permission set or a Settings page. Suppliers are *where filament is bought*, +# distinct from ``Spool.brand`` (who made it). + + +DUPLICATE_SUPPLIER_NAME = "A supplier with this name already exists" + + +async def _supplier_by_name(db: AsyncSession, name: str, *, exclude_id: int | None = None) -> Supplier | None: + """Case-insensitive name lookup behind the duplicate guard (#2988). + + Matches on the stored ``name_key``, so the comparison is the Python fold + the CSV import also uses — ``func.lower()`` would have folded ASCII only + on SQLite and let an umlaut'd case variant past the check. + """ + query = select(Supplier).where(Supplier.name_key == supplier_name_key(name)) + if exclude_id is not None: + query = query.where(Supplier.id != exclude_id) + return (await db.execute(query)).scalars().first() + + +async def _supplier_reference_counts(db: AsyncSession) -> dict[int, int]: + """Spools referencing each supplier, across BOTH inventories. + + The delete guard has to see Spoolman-mode assignments too — a supplier + used only by Spoolman spools must not be deletable just because the + built-in inventory has no reference to it. + """ + counts: dict[int, int] = {} + for model in (SpoolSupplier, SpoolmanSpoolSupplier): + result = await db.execute(select(model.supplier_id, func.count(model.id)).group_by(model.supplier_id)) + for supplier_id, count in result.all(): + counts[supplier_id] = counts.get(supplier_id, 0) + count + return counts + + +async def _prune_orphaned_spoolman_supplier_rows(db: AsyncSession) -> int: + """Drop twin rows whose Spoolman spool no longer exists (#2988). + + ``_purge_local_rows_for_spool`` covers the deletes Bambuddy performs, but + Spoolman is a separate application with its own UI: a spool deleted there + — or a Spoolman instance that was rebuilt or replaced — leaves + ``spoolman_spool_suppliers`` rows behind that keep the supplier's + reference count non-zero, and nothing in Bambuddy can show or remove the + phantom reference. Without this, that 409 is permanent. + + Reconciled on the delete attempt rather than on every listing: it costs + one Spoolman call, and only the route that is about to refuse needs the + answer. Archived spools count as live — archiving is a soft delete and the + assignment has to survive it. Returns the number of rows removed; 0 when + Spoolman is off or unreachable, which leaves the 409 standing rather than + dropping rows on the strength of a failed lookup. + """ + settings = await _load_settings_map(db) + if not _spoolman_is_enabled(settings): + return 0 + local_ids = set((await db.execute(select(SpoolmanSpoolSupplier.spoolman_spool_id).distinct())).scalars().all()) + if not local_ids: + return 0 + client = await _ensure_spoolman_client(settings) + if not client: + return 0 + try: + spools = await client.get_all_spools(allow_archived=True) + except Exception: + logger.warning("Failed to fetch Spoolman spools to reconcile supplier assignments", exc_info=True) + return 0 + stale = local_ids - {s.get("id") for s in spools if isinstance(s, dict)} + if not stale: + return 0 + await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(stale))) + await db.commit() + logger.info("Dropped supplier assignments for %d Spoolman spool(s) that no longer exist", len(stale)) + return len(stale) + + +@router.get("/suppliers", response_model=list[SupplierResponse]) +async def list_suppliers( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """List all suppliers with their spool-usage counts.""" + # name_key, not name: the list should read the way it looks, so "extrudr" + # sorts next to "Extrudr" rather than after "Zultrat". + result = await db.execute(select(Supplier).order_by(Supplier.name_key)) + suppliers = result.scalars().all() + counts = await _supplier_reference_counts(db) + responses = [] + for supplier in suppliers: + response = SupplierResponse.model_validate(supplier) + response.spool_count = counts.get(supplier.id, 0) + responses.append(response) + return responses + + +@router.post("/suppliers", response_model=SupplierResponse, status_code=201) +async def create_supplier( + data: SupplierCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Create a supplier (mirrors create_location, duplicate name included).""" + if await _supplier_by_name(db, data.name): + raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) + supplier = Supplier(**data.model_dump()) + db.add(supplier) + try: + await db.commit() + except IntegrityError as exc: + # The unique index behind the check above, for the concurrent case. + await db.rollback() + raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) from exc + await db.refresh(supplier) + await ws_manager.broadcast({"type": "inventory_changed"}) + return SupplierResponse.model_validate(supplier) + + +@router.patch("/suppliers/{supplier_id}", response_model=SupplierResponse) +async def update_supplier( + supplier_id: int, + data: SupplierUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Update a supplier.""" + result = await db.execute(select(Supplier).where(Supplier.id == supplier_id)) + supplier = result.scalar_one_or_none() + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + fields = data.model_dump(exclude_unset=True) + if "name" in fields and await _supplier_by_name(db, fields["name"], exclude_id=supplier_id): + raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) + for field, value in fields.items(): + setattr(supplier, field, value) + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail=DUPLICATE_SUPPLIER_NAME) from exc + await db.refresh(supplier) + await ws_manager.broadcast({"type": "inventory_changed"}) + + response = SupplierResponse.model_validate(supplier) + response.spool_count = (await _supplier_reference_counts(db)).get(supplier.id, 0) + return response + + +@router.delete("/suppliers/{supplier_id}") +async def delete_supplier( + supplier_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Delete a supplier when no spools reference it (mirrors delete_location).""" + result = await db.execute(select(Supplier).where(Supplier.id == supplier_id)) + supplier = result.scalar_one_or_none() + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + if (await _supplier_reference_counts(db)).get(supplier_id, 0) > 0: + # Last chance before refusing: the reference may be a Spoolman spool + # that was deleted in Spoolman itself, which Bambuddy never hears about. + await _prune_orphaned_spoolman_supplier_rows(db) + if (await _supplier_reference_counts(db)).get(supplier_id, 0) > 0: + raise HTTPException(status_code=409, detail="Supplier has spools assigned and cannot be deleted") + + await db.delete(supplier) + await db.commit() + await ws_manager.broadcast({"type": "inventory_changed"}) + return {"status": "deleted"} + + # ── Color Catalog CRUD ───────────────────────────────────────────────────── @@ -1180,7 +1381,7 @@ async def list_spools( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), ): """List all spools, excluding archived by default.""" - query = select(Spool).options(selectinload(Spool.k_profiles)) + query = select(Spool).options(*spool_response_loads()) if not include_archived: query = query.where(Spool.archived_at.is_(None)) query = query.order_by(Spool.material, Spool.brand, Spool.color_name) @@ -1201,7 +1402,13 @@ async def export_spools_csv( """Export the active inventory as CSV (same schema the importer accepts).""" from datetime import datetime, timezone - query = select(Spool).where(Spool.archived_at.is_(None)).order_by(Spool.material, Spool.brand, Spool.color_name) + query = ( + select(Spool) + # The supplier columns (#2988) read the assignments off each row. + .options(selectinload(Spool.supplier_links).selectinload(SpoolSupplier.supplier)) + .where(Spool.archived_at.is_(None)) + .order_by(Spool.material, Spool.brand, Spool.color_name) + ) result = await db.execute(query) spools = list(result.scalars().all()) content = serialize(spools) @@ -1259,7 +1466,20 @@ async def import_spools_csv( created = 0 for row in preview.rows: if row.status == "valid" and row.spool is not None: - db.add(Spool(**row.spool)) + spool = Spool(**row.spool) + db.add(spool) + # Supplier assignments resolved by name during parsing (#2988). + # Flush first so the spool has an id to hang the links on. + if row.supplier_ids: + await db.flush() + for supplier_id in row.supplier_ids: + db.add( + SpoolSupplier( + spool_id=spool.id, + supplier_id=supplier_id, + is_purchase_source=supplier_id == row.purchase_supplier_id, + ) + ) created += 1 if created: @@ -1299,7 +1519,7 @@ async def get_spool_by_tag( if not normalized_tray_uuid and not normalized_tag_uid: raise HTTPException(400, "Provide tray_uuid and/or tag_uid") - base_query = select(Spool).options(selectinload(Spool.k_profiles)) + base_query = select(Spool).options(*spool_response_loads()) if not include_archived: base_query = base_query.where(Spool.archived_at.is_(None)) @@ -1324,7 +1544,7 @@ async def get_spool( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), ): """Get a single spool with k_profiles.""" - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) spool = result.scalar_one_or_none() if not spool: raise HTTPException(404, "Spool not found") @@ -1344,9 +1564,13 @@ async def create_spool( raise HTTPException(status_code=400, detail=str(exc)) from exc spool = Spool(**payload) db.add(spool) + await db.flush() + # A new spool of a product that already carries supplier assignments + # inherits the source list (#2988). + await apply_supplier_inheritance(db, spool) await db.commit() await db.refresh(spool) - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool.id)) await ws_manager.broadcast({"type": "inventory_changed"}) return result.scalar_one() @@ -1368,9 +1592,12 @@ async def bulk_create_spools( spool = Spool(**payload) db.add(spool) spools.append(spool) + await db.flush() + # Every copy gets its own link rows, from one donor lookup (#2988). + await apply_supplier_inheritance_to_batch(db, spools) await db.commit() ids = [s.id for s in spools] - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids))) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id.in_(ids))) await ws_manager.broadcast({"type": "inventory_changed"}) return list(result.scalars().all()) @@ -1401,7 +1628,7 @@ async def update_spool( setattr(spool, field, value) await db.commit() - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) await ws_manager.broadcast({"type": "inventory_changed"}) return result.scalar_one() @@ -1440,7 +1667,7 @@ async def archive_spool( spool.archived_at = datetime.now(timezone.utc) await db.commit() - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) await ws_manager.broadcast({"type": "inventory_changed"}) return result.scalar_one() @@ -1459,7 +1686,7 @@ async def restore_spool( spool.archived_at = None await db.commit() - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) await ws_manager.broadcast({"type": "inventory_changed"}) return result.scalar_one() @@ -1491,7 +1718,7 @@ async def reset_spool_consumed_counter( spool.weight_used_baseline = spool.weight_used or 0 await db.commit() - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) await ws_manager.broadcast({"type": "inventory_changed"}) return result.scalar_one() @@ -1689,6 +1916,56 @@ async def replace_k_profiles( return new_profiles +@router.put("/spools/{spool_id}/suppliers", response_model=list[SpoolSupplierResponse]) +async def replace_spool_suppliers( + spool_id: int, + links: list[SpoolSupplierLinkInput], + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Replace a spool's supplier assignments (#2988, batch save). + + Same replace-all shape as the k-profiles endpoint. At most one assignment + may be the purchase source — the record of where this concrete spool was + actually bought; the rest read as alternative sources. + """ + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "Spool not found") + + supplier_ids = [link.supplier_id for link in links] + if len(set(supplier_ids)) != len(supplier_ids): + raise HTTPException(400, "Duplicate supplier in assignment list") + if sum(1 for link in links if link.is_purchase_source) > 1: + raise HTTPException(400, "Only one assignment can be the purchase source") + if supplier_ids: + found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids))) + missing = set(supplier_ids) - {row[0] for row in found.all()} + if missing: + raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}") + + existing = await db.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == spool_id)) + for old in existing.scalars().all(): + await db.delete(old) + await db.flush() + + new_links = [] + for link in links: + row = SpoolSupplier(spool_id=spool_id, **link.model_dump()) + db.add(row) + new_links.append(row) + + await db.commit() + refreshed = await db.execute( + select(SpoolSupplier) + .options(selectinload(SpoolSupplier.supplier)) + .where(SpoolSupplier.id.in_([row.id for row in new_links])) + .order_by(SpoolSupplier.id) + ) + await ws_manager.broadcast({"type": "inventory_changed"}) + return list(refreshed.scalars().all()) + + @router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse]) async def list_filament_presets( spool_id: int, @@ -1769,7 +2046,7 @@ async def list_assignments( from backend.app.services.printer_manager import printer_manager query = select(SpoolAssignment).options( - selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles), + selectinload(SpoolAssignment.spool).options(*spool_response_loads()), selectinload(SpoolAssignment.printer), ) if printer_id is not None: @@ -1834,7 +2111,7 @@ async def assign_spool( from backend.app.services.printer_manager import printer_manager # 1. Validate spool exists and is not archived - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == data.spool_id)) spool = result.scalar_one_or_none() if not spool: raise HTTPException(404, "Spool not found") @@ -2013,7 +2290,7 @@ async def assign_spool( result = await db.execute( select(SpoolAssignment) .options( - selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles), + selectinload(SpoolAssignment.spool).options(*spool_response_loads()), selectinload(SpoolAssignment.printer), ) .where(SpoolAssignment.id == assignment.id) @@ -2118,7 +2395,7 @@ async def link_tag_to_spool( ``tag_already_linked`` 409, which names that spool so a caller can offer to move the tag instead of only reporting that it is taken (#3110). """ - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) spool = result.scalar_one_or_none() if not spool: raise HTTPException(404, "Spool not found") @@ -2199,7 +2476,7 @@ async def link_tag_to_spool( spool.data_origin = data.data_origin await db.commit() - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == spool_id)) return result.scalar_one() @@ -2230,6 +2507,94 @@ async def get_spool_usage_history( return list(result.scalars().all()) +@router.get("/stats/suppliers", response_model=list[SupplierStats]) +async def get_supplier_stats( + date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"), + date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Aggregate the inventory by purchase-source supplier (#2988). + + Groups by the supplier a spool was actually bought from (the + ``is_purchase_source`` assignment), so "how much did we run through + supplier X" reads directly. Stock comes from active spools; consumption + and cost from the recorded usage history, archived spools included — + their consumption happened. Sorted by consumption, heaviest first, then by name. + + ``date_from`` / ``date_to`` scope the usage half only, so the widget can + honour the dashboard timeframe like every other one on that page. Stock is + point-in-time by nature and is never windowed. + """ + from backend.app.models.spool_usage_history import SpoolUsageHistory + + purchase_link = (SpoolSupplier.spool_id == Spool.id) & SpoolSupplier.is_purchase_source.is_(True) + usage_window = [] + if date_from: + usage_window.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc)) + if date_to: + usage_window.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc)) + + remaining = Spool.label_weight - Spool.weight_used + inventory_rows = await db.execute( + select( + SpoolSupplier.supplier_id, + func.count(Spool.id), + # Clamp each spool before summing: an over-consumed spool counts as + # empty, not as negative stock that eats into its neighbours. + func.sum(case((remaining > 0, remaining), else_=0.0)), + ) + .select_from(Spool) + .join(SpoolSupplier, purchase_link) + .where(Spool.archived_at.is_(None)) + .group_by(SpoolSupplier.supplier_id) + ) + + usage_rows = await db.execute( + select( + SpoolSupplier.supplier_id, + func.sum(SpoolUsageHistory.weight_used), + func.sum(SpoolUsageHistory.cost), + ) + .select_from(SpoolUsageHistory) + .join(Spool, SpoolUsageHistory.spool_id == Spool.id) + .join(SpoolSupplier, purchase_link) + .where(*usage_window) + .group_by(SpoolSupplier.supplier_id) + ) + + names = dict((await db.execute(select(Supplier.id, Supplier.name))).all()) + + stats: dict[int, SupplierStats] = {} + for supplier_id, count, remaining_g in inventory_rows.all(): + stats[supplier_id] = SupplierStats( + supplier_id=supplier_id, + supplier_name=names.get(supplier_id, f"#{supplier_id}"), + spool_count=count, + remaining_g=float(remaining_g or 0), + consumed_g=0.0, + cost=0.0, + ) + for supplier_id, consumed, cost in usage_rows.all(): + entry = stats.get(supplier_id) + if entry is None: + entry = SupplierStats( + supplier_id=supplier_id, + supplier_name=names.get(supplier_id, f"#{supplier_id}"), + spool_count=0, + remaining_g=0.0, + consumed_g=0.0, + cost=0.0, + ) + stats[supplier_id] = entry + entry.consumed_g = float(consumed or 0) + entry.cost = float(cost or 0) + + # Name breaks ties: a narrow date range leaves most rows at 0 g consumed, + # and those would otherwise come back in GROUP BY order. + return sorted(stats.values(), key=lambda s: (-s.consumed_g, supplier_name_key(s.supplier_name))) + + @router.get("/usage", response_model=list[SpoolUsageHistoryResponse]) async def get_all_usage_history( limit: int = 100, @@ -2717,5 +3082,15 @@ async def create_spool_from_slot( "spool_id": spool.id, } ) - result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id)) + # populate_existing because `spool` is the same identity-mapped instance + # create_spool_from_tray built: it pre-initialises `supplier_links` to [] + # so the flush can't lazy-load it, and SQLAlchemy will not overwrite an + # already-loaded collection on a plain re-select — the inherited + # assignments (#2988) would be in the table but missing from the response. + result = await db.execute( + select(Spool) + .options(*spool_response_loads()) + .where(Spool.id == spool.id) + .execution_options(populate_existing=True) + ) return result.scalar_one() diff --git a/backend/app/api/routes/spoolman_inventory.py b/backend/app/api/routes/spoolman_inventory.py index a58753de4..061077e09 100644 --- a/backend/app/api/routes/spoolman_inventory.py +++ b/backend/app/api/routes/spoolman_inventory.py @@ -41,9 +41,11 @@ from backend.app.models.settings import Settings from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset from backend.app.models.spoolman_k_profile import SpoolmanKProfile from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment +from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier from backend.app.models.user import User from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched +from backend.app.schemas.supplier import SpoolSupplierLinkInput from backend.app.services.location_service import ( enrich_spool_dicts_with_location_id, maybe_sync_spoolman_locations, @@ -476,6 +478,19 @@ async def list_spools( for m in mapped: m["k_profiles"] = kp_by_spool.get(m["id"], []) + # Supplier assignments (#2988) live Bambuddy-side even for Spoolman + # spools, so the list carries them in both modes identically. + link_result = await db.execute( + select(SpoolmanSpoolSupplier) + .options(selectinload(SpoolmanSpoolSupplier.supplier)) + .where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(spool_ids)) + ) + links_by_spool: dict[int, list[dict]] = {} + for link in link_result.scalars().all(): + links_by_spool.setdefault(link.spoolman_spool_id, []).append(_supplier_link_to_dict(link)) + for m in mapped: + m["suppliers"] = links_by_spool.get(m["id"], []) + await enrich_spool_dicts_with_location_id(db, mapped) return mapped @@ -498,6 +513,12 @@ async def get_spool( kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id)) mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()] + link_result = await db.execute( + select(SpoolmanSpoolSupplier) + .options(selectinload(SpoolmanSpoolSupplier.supplier)) + .where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id) + ) + mapped["suppliers"] = [_supplier_link_to_dict(link) for link in link_result.scalars().all()] await enrich_spool_dicts_with_location_id(db, [mapped]) return mapped @@ -902,6 +923,22 @@ async def update_spool( return _map_spoolman_spool(updated) +async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None: + """Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind. + + Spoolman owns the spool; Bambuddy owns the K profiles, the filament preset + overrides and the supplier assignments, each keyed by the remote id with no + foreign key that could cascade. The K-profile and preset leaks are inert, + but a leaked ``spoolman_spool_suppliers`` row keeps the supplier's + reference count non-zero, so deleting that supplier answers 409 forever + with no way for the user to find the phantom reference (#2988). + + The caller commits. + """ + for model in (SpoolmanKProfile, SpoolmanFilamentPreset, SpoolmanSpoolSupplier): + await db.execute(delete(model).where(model.spoolman_spool_id == spool_id)) + + @router.delete("/spools/{spool_id}") async def delete_spool( spool_id: int = Path(..., gt=0), @@ -912,6 +949,8 @@ async def delete_spool( client = await _get_client(db) async with _translate_spoolman_errors(): await client.delete_spool(spool_id) + await _purge_local_rows_for_spool(db, spool_id) + await db.commit() await ws_manager.broadcast({"type": "inventory_changed"}) return {"status": "deleted"} @@ -1042,6 +1081,7 @@ async def bulk_delete_spools( try: async with _translate_spoolman_errors(): await client.delete_spool(sid) + await _purge_local_rows_for_spool(db, sid) deleted += 1 except HTTPException as exc: errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail}) @@ -1049,6 +1089,7 @@ async def bulk_delete_spools( logger.exception("Spoolman bulk-delete failed for spool %s", sid) errors.append({"id": sid, "status": 500, "detail": str(exc)}) if deleted: + await db.commit() await ws_manager.broadcast({"type": "inventory_changed"}) return {"deleted": deleted, "errors": errors} @@ -2149,3 +2190,77 @@ async def save_spoolman_k_profiles( await db.refresh(obj) return [_k_profile_to_dict(p) for p in saved] + + +def _supplier_link_to_dict(link: SpoolmanSpoolSupplier) -> dict: + """Same shape as ``SpoolSupplierResponse`` so the frontend renders both + inventories with one component.""" + return { + "id": link.id, + "supplier_id": link.supplier_id, + "supplier_name": link.supplier_name, + "supplier_article_number": link.supplier_article_number, + "quoted_price_per_kg": link.quoted_price_per_kg, + "is_purchase_source": link.is_purchase_source, + } + + +@router.get("/spools/{spool_id}/suppliers") +async def get_spoolman_spool_suppliers( + spool_id: int = Path(..., gt=0), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +) -> list[dict]: + """Supplier assignments for a Spoolman spool (#2988, Bambuddy-side rows).""" + await _get_client(db) + result = await db.execute( + select(SpoolmanSpoolSupplier) + .options(selectinload(SpoolmanSpoolSupplier.supplier)) + .where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id) + ) + return [_supplier_link_to_dict(link) for link in result.scalars().all()] + + +@router.put("/spools/{spool_id}/suppliers") +async def save_spoolman_spool_suppliers( + spool_id: int = Path(..., gt=0), + links: list[SpoolSupplierLinkInput] = Body(...), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> list[dict]: + """Replace a Spoolman spool's supplier assignments (#2988). + + Mirror of the built-in inventory's replace-all endpoint — Spoolman owns + the spool, Bambuddy owns the assignment (``SpoolmanKProfile`` precedent), + so the rows are local and the spool is only verified to exist remotely. + """ + client = await _get_client(db) + async with _translate_spoolman_errors(): + await client.get_spool(spool_id) + + supplier_ids = [link.supplier_id for link in links] + if len(set(supplier_ids)) != len(supplier_ids): + raise HTTPException(400, "Duplicate supplier in assignment list") + if sum(1 for link in links if link.is_purchase_source) > 1: + raise HTTPException(400, "Only one assignment can be the purchase source") + if supplier_ids: + found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids))) + missing = set(supplier_ids) - {row[0] for row in found.all()} + if missing: + raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}") + + await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)) + saved: list[SpoolmanSpoolSupplier] = [] + for link in links: + row = SpoolmanSpoolSupplier(spoolman_spool_id=spool_id, **link.model_dump()) + db.add(row) + saved.append(row) + await db.commit() + refreshed = await db.execute( + select(SpoolmanSpoolSupplier) + .options(selectinload(SpoolmanSpoolSupplier.supplier)) + .where(SpoolmanSpoolSupplier.id.in_([row.id for row in saved])) + .order_by(SpoolmanSpoolSupplier.id) + ) + await ws_manager.broadcast({"type": "inventory_changed"}) + return [_supplier_link_to_dict(link) for link in refreshed.scalars().all()] diff --git a/backend/app/core/database.py b/backend/app/core/database.py index a51e9042c..4bf02b442 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -5076,6 +5076,10 @@ async def run_migrations(conn): # on fresh installs only — this covers databases whose table predates it. await _migrate_location_ha_sensor_unique_binding(conn) + # Migration: supplier master list + spool assignments (#2988). + # create_all() covers fresh installs; this covers upgrades. + await _migrate_create_supplier_tables(conn) + # Migration: repair the tare of spools the RFID auto-add gave the wrong # Bambu spool row (#2909). Runs last so the spool catalogue it reads is # whatever this database actually holds. @@ -5145,6 +5149,207 @@ async def _migrate_confirm_prompt_body_template(conn) -> None: ) +async def _migrate_create_supplier_tables(conn) -> None: + """Create the supplier tables on databases that predate #2988. + + ``Base.metadata.create_all()`` covers fresh installs; upgrades get the + tables here, following the ``_migrate_create_finance_tables`` shape. + ``spool_suppliers`` deliberately has NO ON DELETE CASCADE on the supplier + side — the API refuses to delete a referenced supplier (409) so + assignments can never silently orphan. + """ + if is_sqlite(): + statements = [ + """ + CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY, + name VARCHAR(200) NOT NULL, + name_key VARCHAR(200) NOT NULL, + website VARCHAR(500), + customer_number VARCHAR(100), + note VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS spool_suppliers ( + id INTEGER PRIMARY KEY, + spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + supplier_article_number VARCHAR(100), + quoted_price_per_kg FLOAT, + is_purchase_source BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers ( + id INTEGER PRIMARY KEY, + spoolman_spool_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + supplier_article_number VARCHAR(100), + quoted_price_per_kg FLOAT, + is_purchase_source BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id) + ) + """, + ] + else: + statements = [ + """ + CREATE TABLE IF NOT EXISTS suppliers ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + name_key VARCHAR(200) NOT NULL, + website VARCHAR(500), + customer_number VARCHAR(100), + note VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS spool_suppliers ( + id SERIAL PRIMARY KEY, + spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + supplier_article_number VARCHAR(100), + quoted_price_per_kg FLOAT, + is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers ( + id SERIAL PRIMARY KEY, + spoolman_spool_id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL REFERENCES suppliers(id), + supplier_article_number VARCHAR(100), + quoted_price_per_kg FLOAT, + is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id) + ) + """, + ] + for statement in statements: + await _safe_execute(conn, statement) + # The model declares index=True on these; fresh installs get them from + # create_all(), migrated databases need them spelled out. + await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_suppliers_name ON suppliers (name)") + await _migrate_supplier_name_key(conn) + await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_spool_id ON spool_suppliers (spool_id)") + await _safe_execute( + conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_supplier_id ON spool_suppliers (supplier_id)" + ) + await _safe_execute( + conn, + "CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_spoolman_spool_id" + " ON spoolman_spool_suppliers (spoolman_spool_id)", + ) + await _safe_execute( + conn, + "CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_supplier_id ON spoolman_spool_suppliers (supplier_id)", + ) + + +async def _migrate_supplier_name_key(conn) -> None: + """Backfill ``suppliers.name_key`` and collapse case-insensitive duplicates (#2988). + + The unique index cannot simply be created: a database written by an + earlier build of this branch is allowed to hold two suppliers whose names + differ only in case, and ``CREATE UNIQUE INDEX`` refuses to build over + them. ``_safe_execute`` re-raises that IntegrityError, which would abort + startup with no route to recovery from the UI — the same trap + ``_migrate_location_ha_sensor_unique_binding`` clears first. + + Colliding rows are merged rather than deleted, because a supplier is + referenced: the oldest row wins (it is the one assignments and the CSV + import already resolved to), its empty fields are filled from the + duplicate, every assignment is re-pointed to it, and only then is the + duplicate dropped. An assignment the surviving row already holds for the + same spool is dropped instead of re-pointed — the (spool, supplier) pair + is unique. + """ + from sqlalchemy import text + + from backend.app.models.supplier import supplier_name_key + + # Only a table written by an earlier build of this branch lacks the column + # (the CREATE TABLE above declares it NOT NULL). ADD COLUMN cannot carry + # NOT NULL without a default, so it is added nullable, backfilled below and + # tightened afterwards where the database can do that in place. + await _safe_execute(conn, "ALTER TABLE suppliers ADD COLUMN name_key VARCHAR(200)") + + async with conn.begin_nested(): + rows = ( + await conn.execute( + text("SELECT id, name, name_key, website, customer_number, note FROM suppliers ORDER BY id") + ) + ).fetchall() + kept: dict[str, int] = {} + for row in rows: + key = supplier_name_key(row.name or "") + winner_id = kept.get(key) + if winner_id is None: + kept[key] = row.id + if row.name_key != key: + await conn.execute( + text("UPDATE suppliers SET name_key = :key WHERE id = :id"), + {"key": key, "id": row.id}, + ) + continue + params = {"keep": winner_id, "drop": row.id} + await conn.execute( + text( + "DELETE FROM spool_suppliers WHERE supplier_id = :drop AND spool_id IN " + "(SELECT spool_id FROM spool_suppliers WHERE supplier_id = :keep)" + ), + params, + ) + await conn.execute(text("UPDATE spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params) + await conn.execute( + text( + "DELETE FROM spoolman_spool_suppliers WHERE supplier_id = :drop AND spoolman_spool_id IN " + "(SELECT spoolman_spool_id FROM spoolman_spool_suppliers WHERE supplier_id = :keep)" + ), + params, + ) + await conn.execute( + text("UPDATE spoolman_spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params + ) + await conn.execute( + text( + "UPDATE suppliers SET website = COALESCE(website, :website), " + "customer_number = COALESCE(customer_number, :customer_number), " + "note = COALESCE(note, :note) WHERE id = :keep" + ), + { + "website": row.website, + "customer_number": row.customer_number, + "note": row.note, + "keep": winner_id, + }, + ) + await conn.execute(text("DELETE FROM suppliers WHERE id = :drop"), {"drop": row.id}) + logger.info("Merged duplicate supplier %r (id=%s) into id=%s", row.name, row.id, winner_id) + + # Superseded by ix_suppliers_name_key: lower(name) folds ASCII only, so it + # never enforced the rule for non-ASCII names in the first place. + await _safe_execute(conn, "DROP INDEX IF EXISTS uq_suppliers_name_lower") + await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_suppliers_name_key ON suppliers (name_key)") + # NULLs never collide in a unique index, so the guarantee belongs in the + # schema, as create_all() declares it on fresh installs. Every row has a + # key by now. SQLite cannot alter a column in place; there the ORM hook on + # Supplier.name is what writes it. + if not is_sqlite(): + await _safe_execute(conn, "ALTER TABLE suppliers ALTER COLUMN name_key SET NOT NULL") + + async def _migrate_rename_ha_sensor_alert_template(conn) -> None: """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824). diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0326432a0..492033ca8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -39,6 +39,7 @@ from backend.app.models.spool_filament_preset import SpoolFilamentPreset, Spoolm from backend.app.models.spool_k_profile import SpoolKProfile from backend.app.models.spool_usage_history import SpoolUsageHistory from backend.app.models.spoolbuddy_device import SpoolBuddyDevice +from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier from backend.app.models.user import User from backend.app.models.user_email_pref import UserEmailPreference from backend.app.models.user_otp_code import UserOTPCode @@ -90,6 +91,9 @@ __all__ = [ "SpoolAssignment", "SpoolCatalogEntry", "SpoolUsageHistory", + "Supplier", + "SpoolSupplier", + "SpoolmanSpoolSupplier", "ColorCatalogEntry", "SpoolBuddyDevice", "SponsorToastState", diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index 294c55ee3..46abdcfae 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -83,9 +83,15 @@ class Spool(Base): ) assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan") location: Mapped["Location | None"] = relationship(back_populates="spools") + # Supplier assignments (#2988): where this product can be bought, with + # per-assignment article number / price and a purchase-source marker. + # Default loader like every other relationship here — the handful of + # routes that embed them ask for selectinload() at the query site. + supplier_links: Mapped[list["SpoolSupplier"]] = relationship(back_populates="spool", cascade="all, delete-orphan") from backend.app.models.location import Location # noqa: E402 from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402 from backend.app.models.spool_filament_preset import SpoolFilamentPreset # noqa: E402 from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402 +from backend.app.models.supplier import SpoolSupplier # noqa: E402 diff --git a/backend/app/models/supplier.py b/backend/app/models/supplier.py new file mode 100644 index 000000000..895cd3b5f --- /dev/null +++ b/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 "" diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index 95047d22a..1def1a589 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -2,6 +2,8 @@ from datetime import datetime from pydantic import BaseModel, Field, field_validator +from backend.app.schemas.supplier import SpoolSupplierResponse + # Visual variant applied to a spool's swatch — purely cosmetic, does not # affect MQTT/firmware. Kept independent of `subtype` so users can override # the rendering hint without touching Bambu's categorical filament label. @@ -241,9 +243,15 @@ class SpoolResponse(SpoolBase): created_at: datetime updated_at: datetime k_profiles: list[SpoolKProfileResponse] = [] + # Supplier assignments (#2988): where this product can be bought, with + # per-assignment article number / price and a purchase-source marker. + # Reads the ORM relationship `supplier_links`, which every route + # answering with this schema loads explicitly (see spool_response_loads). + suppliers: list[SpoolSupplierResponse] = Field(default=[], validation_alias="supplier_links") class Config: from_attributes = True + populate_by_name = True class SpoolAssignmentCreate(BaseModel): diff --git a/backend/app/schemas/supplier.py b/backend/app/schemas/supplier.py new file mode 100644 index 000000000..15f69f199 --- /dev/null +++ b/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 diff --git a/backend/app/services/spool_csv.py b/backend/app/services/spool_csv.py index cc6f4a84a..3696668e1 100644 --- a/backend/app/services/spool_csv.py +++ b/backend/app/services/spool_csv.py @@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.color_catalog import ColorCatalogEntry from backend.app.models.spool import Spool +from backend.app.models.supplier import Supplier, supplier_name_key from backend.app.schemas.spool import SpoolCreate # Fixed CSV header, in output order. Round-trips cleanly: export writes these @@ -54,6 +55,16 @@ CSV_COLUMNS = [ "storage_location", "category", "low_stock_threshold_pct", + # Supplier assignments (#2988): `suppliers` is the "; "-joined names of + # all assigned suppliers, `purchase_supplier` the one this spool was + # actually bought from (or empty). Import matches names against the + # existing supplier list — trimmed, case-insensitive — and NEVER creates + # suppliers; an unknown name is a dry-run warning, not a row error, and + # that assignment is dropped. Article number and quoted price stay out of + # the CSV: they belong to the assignment, not the spool, and would break + # the round-trip. + "suppliers", + "purchase_supplier", ] # Upload ceiling for the import endpoint. A spool inventory CSV is a few KB @@ -105,6 +116,11 @@ class ImportRowResult(BaseModel): # same CSV doesn't silently duplicate the inventory. duplicate_of_existing: bool = False spool: dict | None = None + # Resolved supplier assignments (#2988): ids matched by name from the + # `suppliers` / `purchase_supplier` columns. Unknown names are dropped + # with a preview warning — the import never creates suppliers. + supplier_ids: list[int] = [] + purchase_supplier_id: int | None = None class ImportPreview(BaseModel): @@ -193,6 +209,19 @@ def _spool_key(material: str | None, brand: str | None, color_name: str | None) ) +async def _load_supplier_map(db: AsyncSession) -> dict[str, int]: + """Existing suppliers keyed by ``Supplier.name_key`` for CSV matching. + + Import resolves the `suppliers` / `purchase_supplier` columns against + this map and never creates suppliers — the master list is curated in the + UI, and a typo in a CSV must not silently mint a new supplier. The key is + unambiguous because it is the same column the unique index is on, so two + names that land on one entry here cannot both exist as rows. + """ + result = await db.execute(select(Supplier.id, Supplier.name_key)) + return {name_key: supplier_id for supplier_id, name_key in result.all()} + + async def _load_existing_spool_keys(db: AsyncSession) -> set[tuple[str, str, str]]: """Load material+brand+color_name keys of active spools for the dup warning. @@ -306,6 +335,8 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie # issuing a SELECT per row. catalog = await _load_color_catalog(db) existing_keys = await _load_existing_spool_keys(db) + supplier_map = await _load_supplier_map(db) + unknown_suppliers: set[str] = set() def cell(row: list[str], field: str) -> str: idx = col_index.get(field) @@ -460,6 +491,30 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie # dict so the ORM object carries it. spool_data["last_used"] = last_used + # Supplier assignments (#2988): match names against the existing list, + # never create. Unknown names are warnings, not row errors — the row + # imports and the unmatched assignment is dropped. The purchase source + # counts as an assignment even when the `suppliers` cell omits it. + supplier_ids: list[int] = [] + purchase_supplier_id: int | None = None + names = [n.strip() for n in cell(raw_row, "suppliers").split(";") if n.strip()] + purchase_name = cell(raw_row, "purchase_supplier").strip() + if purchase_name and supplier_name_key(purchase_name) not in {supplier_name_key(n) for n in names}: + names.append(purchase_name) + for name in names: + key = supplier_name_key(name) + supplier_id = supplier_map.get(key) + if supplier_id is None: + # Once per name, not once per row: a 500-row export against an + # empty supplier list is one missing supplier, not 500 problems. + if key not in unknown_suppliers: + unknown_suppliers.add(key) + warnings.append(f"Unknown supplier '{name}' — assignments dropped") + elif supplier_id not in supplier_ids: + supplier_ids.append(supplier_id) + if purchase_name: + purchase_supplier_id = supplier_map.get(supplier_name_key(purchase_name)) + rows.append( ImportRowResult( row_number=row_number, @@ -472,6 +527,8 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie cross_material_color=cross_material_color, duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys, spool=spool_data, + supplier_ids=supplier_ids, + purchase_supplier_id=purchase_supplier_id, ) ) valid += 1 @@ -537,6 +594,13 @@ def _cell_value(spool: Spool, col: str) -> str: if col == "remaining": # Derived for display: label_weight - weight_used, clamped at 0. return str(max(0, round((spool.label_weight or 0) - (spool.weight_used or 0)))) + if col == "suppliers": + # Derived (#2988): "; "-joined supplier names, no decoration — the + # purchase source has its own column so import can match plain names. + # The export query loads supplier_links explicitly. + return "; ".join(link.supplier_name for link in spool.supplier_links) + if col == "purchase_supplier": + return next((link.supplier_name for link in spool.supplier_links if link.is_purchase_source), "") value = getattr(spool, col, None) if value is None: return "" diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py index a11121a5f..4ccd07a23 100644 --- a/backend/app/services/spool_tag_matcher.py +++ b/backend/app/services/spool_tag_matcher.py @@ -255,9 +255,17 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool: # when creating SpoolAssignment runs synchronously outside the greenlet. spool.k_profiles = [] spool.assignments = [] + spool.supplier_links = [] db.add(spool) await db.flush() + # A new spool of a product that already carries supplier assignments + # inherits the source list (#2988) — a scanned refill arrives knowing + # where it can be bought. + from backend.app.services.supplier_links import apply_supplier_inheritance + + await apply_supplier_inheritance(db, spool) + logger.info( "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)", spool.id, diff --git a/backend/app/services/supplier_links.py b/backend/app/services/supplier_links.py new file mode 100644 index 000000000..6feef9a53 --- /dev/null +++ b/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)) diff --git a/backend/tests/integration/test_suppliers_api.py b/backend/tests/integration/test_suppliers_api.py new file mode 100644 index 000000000..2ab0abaa2 --- /dev/null +++ b/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] diff --git a/backend/tests/unit/test_bulk_spool_create.py b/backend/tests/unit/test_bulk_spool_create.py index 1cb367850..b9dbe7357 100644 --- a/backend/tests/unit/test_bulk_spool_create.py +++ b/backend/tests/unit/test_bulk_spool_create.py @@ -5,6 +5,7 @@ Tests: - Bulk create endpoint creates the requested number of spools - Bulk create with quantity=1 (single spool) - Bulk create returns spools with k_profiles loaded +- Bulk create copies the inherited supplier links onto every copy """ from unittest.mock import AsyncMock, MagicMock @@ -12,6 +13,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from pydantic import ValidationError +from backend.app.models.spool import Spool +from backend.app.models.supplier import SpoolSupplier from backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate # ── Schema Validation ────────────────────────────────────────────────────── @@ -96,6 +99,40 @@ def _make_mock_spool(spool_id): return spool +def _result(rows): + """A stand-in for the Result of a query that selected ``rows``.""" + result = MagicMock() + result.scalars.return_value.all.return_value = list(rows) + result.scalars.return_value.first.return_value = rows[0] if rows else None + result.first.return_value = (rows[0],) if rows else None + return result + + +def _make_db(refetched, *, donor_id=None, donor_links=()): + """Session double that answers each of the bulk path's queries separately. + + The endpoint runs three different selects — the supplier donor lookup, + that donor's link rows (#2988), and the re-fetch of the created spools — + so one canned result for all of them hands Spool rows to the supplier + code and invents a donor that was never seeded. Route on what each select + asks for instead. Returns the session and the list ``db.add`` lands in. + """ + added = [] + db = AsyncMock() + db.add = added.append + + async def execute(statement, *_args, **_kwargs): + described = statement.column_descriptions[0] + if described["entity"] is SpoolSupplier: + return _result(donor_links) + if described["name"] == "id": + return _result([] if donor_id is None else [donor_id]) + return _result(refetched) + + db.execute = AsyncMock(side_effect=execute) + return db, added + + class TestBulkCreateEndpoint: """Tests for the bulk_create_spools endpoint logic.""" @@ -109,15 +146,7 @@ class TestBulkCreateEndpoint: quantity=3, ) - db = AsyncMock() - added_objects = [] - db.add = lambda obj: added_objects.append(obj) - - # Mock the re-fetch query - mock_result = MagicMock() - mock_spools = [_make_mock_spool(i + 1) for i in range(3)] - mock_result.scalars.return_value.all.return_value = mock_spools - db.execute = AsyncMock(return_value=mock_result) + db, added_objects = _make_db([_make_mock_spool(i + 1) for i in range(3)]) result = await bulk_create_spools(data=data, db=db, _=None) @@ -135,14 +164,7 @@ class TestBulkCreateEndpoint: quantity=1, ) - db = AsyncMock() - added_objects = [] - db.add = lambda obj: added_objects.append(obj) - - mock_result = MagicMock() - mock_spools = [_make_mock_spool(1)] - mock_result.scalars.return_value.all.return_value = mock_spools - db.execute = AsyncMock(return_value=mock_result) + db, added_objects = _make_db([_make_mock_spool(1)]) result = await bulk_create_spools(data=data, db=db, _=None) @@ -165,19 +187,15 @@ class TestBulkCreateEndpoint: quantity=3, ) - db = AsyncMock() - added_objects = [] - db.add = lambda obj: added_objects.append(obj) - - mock_result = MagicMock() - mock_spools = [_make_mock_spool(i + 1) for i in range(3)] - mock_result.scalars.return_value.all.return_value = mock_spools - db.execute = AsyncMock(return_value=mock_result) + db, added_objects = _make_db([_make_mock_spool(i + 1) for i in range(3)]) await bulk_create_spools(data=data, db=db, _=None) - # All added Spool objects should have the same material/brand/color - for spool_obj in added_objects: + # The spools, not everything the session was handed: the bulk path also + # adds the inherited supplier links, and a SpoolSupplier has no material. + spools = [obj for obj in added_objects if isinstance(obj, Spool)] + assert len(spools) == 3 + for spool_obj in spools: assert spool_obj.material == "ABS" assert spool_obj.brand == "Bambu Lab" assert spool_obj.color_name == "Black" diff --git a/backend/tests/unit/test_supplier_tables_migration.py b/backend/tests/unit/test_supplier_tables_migration.py new file mode 100644 index 000000000..13026a4c7 --- /dev/null +++ b/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")] diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 7685d8ca3..3c098474b 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -47,6 +47,11 @@ vi.mock('../../api/client', () => ({ getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]), unassignSpool: vi.fn().mockResolvedValue({}), unassignSpoolmanSlot: vi.fn().mockResolvedValue({}), + // Suppliers (#2988) — the SupplierSection inside the form loads these. + getSuppliers: vi.fn().mockResolvedValue([]), + createSupplier: vi.fn().mockResolvedValue({ id: 1, name: 'S' }), + setSpoolSuppliers: vi.fn().mockResolvedValue([]), + setSpoolmanSpoolSuppliers: vi.fn().mockResolvedValue([]), }, ApiError: class ApiError extends Error { status: number; @@ -1367,6 +1372,105 @@ describe('SpoolFormModal copy mode', () => { const [payload] = vi.mocked(api.createSpool).mock.calls[0]; expect((payload as Record).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( + + ); + + 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( + + ); + + 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(); + + 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 "#" affordance in the modal header (#1385) is only meaningful when diff --git a/frontend/src/__tests__/components/SupplierSection.test.tsx b/frontend/src/__tests__/components/SupplierSection.test.tsx new file mode 100644 index 000000000..fe929116d --- /dev/null +++ b/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(initial); + return ( + { + 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({ui}), client }; +} + +describe('SupplierSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + (api.getSuppliers as ReturnType).mockResolvedValue(suppliers); + }); + + it('adds a supplier from the dropdown as a chip', async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderSection(); + + 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).mockResolvedValue({ + id: 3, name: 'NewShop', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '', + }); + const user = userEvent.setup(); + renderSection(); + + 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).mockRejectedValue(new Error('boom')); + const user = userEvent.setup(); + renderSection(); + + 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(); + await user.click(screen.getByRole('button', { name: /Add supplier/ })); + await screen.findByRole('button', { name: 'Filament24' }); + + (api.getSuppliers as ReturnType).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( + , + ); + + 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( + , + ); + + await user.click(screen.getByRole('button', { name: /Remove/i })); + expect(onChange).toHaveBeenLastCalledWith([]); + expect(screen.queryByText('Filament24')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/components/SupplierStats.test.tsx b/frontend/src/__tests__/components/SupplierStats.test.tsx new file mode 100644 index 000000000..ca3984993 --- /dev/null +++ b/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( + + + , + ); +} + +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( + + + , + ); + 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(); + }); +}); diff --git a/frontend/src/__tests__/components/SuppliersModal.test.tsx b/frontend/src/__tests__/components/SuppliersModal.test.tsx new file mode 100644 index 000000000..32141334e --- /dev/null +++ b/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(); + 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( + + {}} /> + , + ); + return { ...result, client }; +} + +describe('SuppliersModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + (api.getSuppliers as ReturnType).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).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) + .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).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).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).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(); + }); +}); diff --git a/frontend/src/__tests__/hooks/useWebSocket.test.ts b/frontend/src/__tests__/hooks/useWebSocket.test.ts index a5003a485..d14be149f 100644 --- a/frontend/src/__tests__/hooks/useWebSocket.test.ts +++ b/frontend/src/__tests__/hooks/useWebSocket.test.ts @@ -465,6 +465,9 @@ describe('useWebSocket hook', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-inventory-spools'] }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-locations'] }); + // #2988: without this key the supplier broadcast reached nothing, so a + // supplier created in one tab never showed up in another. + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] }); vi.useRealTimers(); vi.unstubAllGlobals(); diff --git a/frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx b/frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx index 283371515..b62d339f0 100644 --- a/frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx +++ b/frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx @@ -67,11 +67,12 @@ describe('InventoryPage — header layout', () => { expect(group.querySelector('button')).toBeInTheDocument(); }); - // Without this the group is as wide as all five buttons laid end to end, + // Without this the group is as wide as all its buttons laid end to end, // whatever the viewport is. expect(group.className).toContain('flex-wrap'); - // All five actions stay in that one group -- wrapping them is the fix, + // All six actions (CSV import/export, Locations, Suppliers (#2988), + // labels, add) stay in that one group -- wrapping them is the fix, // hiding any of them is not. - expect(group.querySelectorAll('button')).toHaveLength(5); + expect(group.querySelectorAll('button')).toHaveLength(6); }); }); diff --git a/frontend/src/__tests__/pages/StatsPageSuppliers.test.tsx b/frontend/src/__tests__/pages/StatsPageSuppliers.test.tsx new file mode 100644 index 000000000..dc1bf0802 --- /dev/null +++ b/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) { + 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((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(); + + 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(); + + // 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(); + + // 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(); + + await waitFor(() => { + expect(screen.getByText('Filament Trends')).toBeInTheDocument(); + }); + expect(screen.queryByText('By Supplier')).toBeNull(); + + open(); + await waitFor(() => { + expect(screen.getByText('By Supplier')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bae4cc986..4358ceb87 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3661,6 +3661,61 @@ export interface InventorySpool { k_profiles?: SpoolKProfile[]; storage_location?: string | null; location_id?: number | null; + // Supplier assignments (#2988). Absent in Spoolman mode — Spoolman's + // vendor is the manufacturer, not the seller, so there is no mapping. + suppliers?: SpoolSupplierLink[]; +} + +// ── Suppliers (#2988) ────────────────────────────────────────────────────── + +/** Where filament is bought — distinct from brand (who made it). */ +export interface Supplier { + id: number; + name: string; + website: string | null; + customer_number: string | null; + note: string | null; + /** Spools referencing this supplier; a referenced supplier cannot be deleted. */ + spool_count: number; + created_at: string; + updated_at: string; +} + +export interface SupplierInput { + name: string; + website?: string | null; + customer_number?: string | null; + note?: string | null; +} + +/** One spool-to-supplier assignment as written by the spool dialog. */ +export interface SpoolSupplierLinkInput { + supplier_id: number; + /** The supplier's own article number — NOT the internal material number. */ + supplier_article_number?: string | null; + /** Quoted price for comparison — never the cost basis (spool.cost_per_kg). */ + quoted_price_per_kg?: number | null; + /** Marks where this concrete spool was actually bought. */ + is_purchase_source?: boolean; +} + +export interface SpoolSupplierLink { + id: number; + supplier_id: number; + supplier_name: string; + supplier_article_number: string | null; + quoted_price_per_kg: number | null; + is_purchase_source: boolean; +} + +/** Per-supplier inventory aggregate (#2988), purchase-source spools only. */ +export interface SupplierStats { + supplier_id: number; + supplier_name: string; + spool_count: number; + remaining_g: number; + consumed_g: number; + cost: number; } export interface SpoolmanBulkCreateResult { @@ -6662,6 +6717,36 @@ export const api = { request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }), resetSpoolCatalog: () => request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }), + // ── Suppliers (#2988) — inventory master data, Locations pattern ───────── + getSuppliers: () => + request('/inventory/suppliers'), + createSupplier: (data: SupplierInput) => + request('/inventory/suppliers', { method: 'POST', body: JSON.stringify(data) }), + updateSupplier: (id: number, data: Partial) => + request(`/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(`/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(`/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(`/inventory/stats/suppliers${qs ? `?${qs}` : ''}`); + }, getLocations: () => request('/inventory/locations'), createLocation: (data: { name: string; identifier?: string | null }) => diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 807ecb6df..39109758c 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -20,6 +20,7 @@ import { MATERIALS } from './spool-form/constants'; import { FilamentSection } from './spool-form/FilamentSection'; import { ColorSection } from './spool-form/ColorSection'; import { AdditionalSection } from './spool-form/AdditionalSection'; +import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection'; import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker'; import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection'; import { normaliseFlow } from '../utils/nozzleFlow'; @@ -81,6 +82,13 @@ export function SpoolFormModal({ // through core_weight. const [coreWeightTouched, setCoreWeightTouched] = useState(false); const [locationIdTouched, setLocationIdTouched] = useState(false); + // Supplier assignments (#2988). Held outside SpoolFormData — they are + // relational and saved through their own replace-all endpoint. An untouched + // create does not send them, so it keeps the backend's inherited + // assignments instead of wiping them with an empty list; a copy always + // sends them, see saveSupplierLinks. + const [supplierLinks, setSupplierLinks] = useState([]); + const [supplierLinksTouched, setSupplierLinksTouched] = useState(false); const [quickAdd, setQuickAdd] = useState(false); const [quantity, setQuantity] = useState(1); @@ -446,10 +454,23 @@ export function SpoolFormModal({ } else { setSelectedProfiles(new Map()); } + // Supplier assignments (#2988) — copied on copy: they describe where + // the product is bought, which is what a copy shares. + setSupplierLinks( + (spool.suppliers ?? []).map((link) => ({ + supplier_id: link.supplier_id, + supplier_name: link.supplier_name, + supplier_article_number: link.supplier_article_number ?? '', + quoted_price_per_kg: link.quoted_price_per_kg, + // Where a COPY was bought is unknown — only a real edit keeps it. + is_purchase_source: isCopying ? false : link.is_purchase_source, + })) + ); } else { setFormData(defaultFormData); setPresetInputValue(''); setSelectedProfiles(new Map()); + setSupplierLinks([]); } // Reset on every open, not just the create path (#1905). The modal keeps // its state while closed, and the Quick Add toggle only renders in create @@ -471,6 +492,7 @@ export function SpoolFormModal({ // inheriting. Only Spoolman spools report the flag (#2908). setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false); setLocationIdTouched(false); + setSupplierLinksTouched(false); } }, [isOpen, spool, mode, isCopying]); @@ -582,6 +604,8 @@ export function SpoolFormModal({ if (newSpool?.id) { const ok = await savePrinterProfiles(newSpool.id); if (!ok) return; + const suppliersOk = await saveSupplierLinks(newSpool.id); + if (!suppliersOk) return; } await refreshSpoolQueries(); if (onSpoolsCreated) onSpoolsCreated([newSpool]); @@ -625,6 +649,12 @@ export function SpoolFormModal({ await savePrinterProfiles(s.id); } } + // Every copy of a bulk add shares the same supplier assignments (#2988). + if (shouldSaveSupplierLinks) { + for (const s of createdSpools) { + await saveSupplierLinks(s.id); + } + } await refreshSpoolQueries(); if (onSpoolsCreated) onSpoolsCreated(createdSpools); if (spoolmanResult && spoolmanResult.failed_count > 0) { @@ -658,6 +688,8 @@ export function SpoolFormModal({ if (spool?.id) { const ok = await savePrinterProfiles(spool.id); if (!ok) return; + const suppliersOk = await saveSupplierLinks(spool.id); + if (!suppliersOk) return; } await refreshSpoolQueries(); showToast(t('inventory.spoolUpdated'), 'success'); @@ -774,6 +806,41 @@ export function SpoolFormModal({ }, }); + // Supplier assignments (#2988): replace-all save. + // + // Skipped on an untouched create so the backend's inheritance can fill the + // new spool in — an empty list would wipe what it just attached. A COPY is + // the opposite case and always saves: the dialog seeded the chips from the + // spool being copied and showed them, so they have to be what the copy + // gets. Inheritance cannot stand in for that — it keys on the (material, + // subtype, brand, color_name) tuple and so resolves to the NEWEST spool of + // the product rather than the one on screen, and in Spoolman mode there is + // no inheritance at all. + const shouldSaveSupplierLinks = supplierLinksTouched || isCopying; + + const saveSupplierLinks = async (spoolId: number): Promise => { + if (!shouldSaveSupplierLinks) return true; + // Spoolman parity (#2988): the assignment rows live Bambuddy-side either + // way; only the endpoint differs (twin table keyed by the remote id). + const save = spoolmanMode ? api.setSpoolmanSpoolSuppliers : api.setSpoolSuppliers; + try { + await save( + spoolId, + supplierLinks.map((link) => ({ + supplier_id: link.supplier_id, + supplier_article_number: link.supplier_article_number.trim() || null, + quoted_price_per_kg: link.quoted_price_per_kg, + is_purchase_source: link.is_purchase_source, + })), + ); + return true; + } catch (err) { + console.error('SpoolFormModal.saveSupplierLinks failed:', err); + showToast(t('inventory.suppliers.saveFailed'), 'error'); + return false; + } + }; + // Save everything the Printers tab holds: one K profile per hotend and the // per-printer-model preset overrides. Returns false if either write failed, // which keeps the modal open so the user does not lose what they picked. @@ -1117,6 +1184,21 @@ export function SpoolFormModal({ /> + {/* 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. */} +
+ { + setSupplierLinks(next); + setSupplierLinksTouched(true); + }} + currencySymbol={currencySymbol} + /> +
+ {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */} {isEditing && spool && !spoolmanMode && (
diff --git a/frontend/src/components/SupplierStats.tsx b/frontend/src/components/SupplierStats.tsx new file mode 100644 index 000000000..7b38d96e8 --- /dev/null +++ b/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 ( +
+ +
+ ); + } + + // 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 ( +

+ + {t('stats.suppliers.error')} +

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

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

; + } + + return ( +
+ + + + + + + + + + + + {data.map((row) => ( + + + + + + + + ))} + +
{t('stats.suppliers.supplier')}{t('stats.suppliers.spools')}{t('stats.suppliers.remaining')}{t('stats.suppliers.consumed')}{t('stats.suppliers.cost')}
{row.supplier_name}{row.spool_count}{formatGrams(row.remaining_g)}{formatGrams(row.consumed_g)} + {currency} {row.cost.toFixed(2)} +
+
+ ); +} diff --git a/frontend/src/components/SuppliersModal.tsx b/frontend/src/components/SuppliersModal.tsx new file mode 100644 index 000000000..d358b794d --- /dev/null +++ b/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(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(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 = ( +
+ setFormName(e.target.value)} + /> +
+ setFormWebsite(e.target.value)} + /> + setFormCustomerNumber(e.target.value)} + /> +
+ setFormNote(e.target.value)} + /> +
+ ); + + return ( +
+
+
+
+
+ +

+ {t('inventory.suppliers.title')} +

+ ({suppliers.length}) +
+
+ + +
+
+ +
+

{t('inventory.suppliers.description')}

+ +
+ + setSearch(e.target.value)} + /> +
+ + {showAddForm && ( +
+

{t('inventory.suppliers.addNew')}

+ {formFields} +
+ + +
+
+ )} + + {loading ? ( +
+ + {t('common.loading')} +
+ ) : isError ? ( + // A failed load must not read as "no suppliers yet" — that invites + // the user to create one that already exists. +
+ + {t('inventory.suppliers.loadFailed')} +
+ ) : ( +
+ + + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((supplier) => ( + + {editingId === supplier.id ? ( + <> + + + + ) : ( + <> + + + + + + )} + + )) + )} + +
{t('common.name')} + {t('inventory.suppliers.customerNumber')} + + {t('inventory.suppliers.spools')} +
+ {search ? t('inventory.suppliers.noMatch') : t('inventory.suppliers.empty')} +
{formFields} +
+ + +
+
+
+ {supplier.name} + {supplier.website && ( + + + + )} +
+ {supplier.note &&
{supplier.note}
} +
+ {supplier.customer_number || '-'} + {supplier.spool_count} +
+ + +
+
+
+ )} +
+
+ + {deleteSupplier && ( + 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)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/spool-form/SupplierSection.tsx b/frontend/src/components/spool-form/SupplierSection.tsx new file mode 100644 index 000000000..e1ad3f1d9 --- /dev/null +++ b/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(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) => { + 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 ( +
+ + + {links.length > 0 && ( +
+ {links.map((link) => ( +
+
+ + + {link.supplier_name} + + + +
+
+ updateLink(link.supplier_id, { supplier_article_number: e.target.value })} + /> +
+ + {currencySymbol} + + + updateLink(link.supplier_id, { + quoted_price_per_kg: e.target.value === '' ? null : parseFloat(e.target.value), + }) + } + /> +
+
+
+ ))} +
+ )} + +
+ + {open && ( +
+
+ setSearch(e.target.value)} + /> +
+
+ {isLoading ? ( +
+ + {t('common.loading')} +
+ ) : isError ? ( + // An empty list here would invite the user to create a + // supplier that already exists, so say the load failed. +
+ + {t('inventory.suppliers.loadFailed')} +
+ ) : ( + <> + {candidates.map((supplier) => ( + + ))} + {candidates.length === 0 && !search.trim() && ( +
{t('inventory.suppliers.noneLeft')}
+ )} + {search.trim() && !exactMatch && ( + + )} + + )} +
+
+ )} +
+

{t('inventory.suppliers.help')}

+
+ ); +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 8f6ff19a2..d435502d9 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useToast } from '../contexts/ToastContext'; import { useTranslation } from 'react-i18next'; import { api, ApiError } from '../api/client'; -import { inventoryLocationsQueryKey } from '../utils/inventoryQueries'; +import { inventoryLocationsQueryKey, inventorySuppliersQueryKey } from '../utils/inventoryQueries'; // The only auth-failure close code /api/v1/ws emits (websocket.py // _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid / @@ -408,6 +408,7 @@ export function useWebSocket() { debouncedInvalidate('inventory-spools'); debouncedInvalidate('spoolman-inventory-spools'); debouncedInvalidate(inventoryLocationsQueryKey[0]); + debouncedInvalidate(inventorySuppliersQueryKey[0]); break; case 'spool_assignment_changed': diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 68f6fd348..1a0ccf2b0 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1670,6 +1670,17 @@ export default { printActivity: 'Druckaktivität', filamentTypes: 'Filamenttypen', filamentTrends: 'Filamenttrends', + // Consumption/cost grouped by purchase-source supplier (#2988) + suppliers: { + title: 'Nach Lieferant', + empty: 'Noch keine Käufe erfasst. Markieren Sie an einer Spule, bei welchem Lieferanten sie gekauft wurde, um Verbrauch und Kosten hier zu gruppieren.', + error: 'Lieferantenstatistik konnte nicht geladen werden', + supplier: 'Lieferant', + spools: 'Spulen', + remaining: 'Verbleibend', + consumed: 'Verbraucht', + cost: 'Kosten', + }, failureAnalysis: 'Fehleranalyse', timeAccuracy: 'Zeitgenauigkeit', successful: 'Erfolgreich:', @@ -4814,6 +4825,48 @@ export default { storageLocationNone: 'Kein Lagerort', lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)', lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.', + // Suppliers (#2988): the master list and the per-spool assignments. + suppliers: { + label: 'Lieferanten', + none: 'Kein Lieferant', + addSupplier: 'Lieferant hinzufügen…', + searchPlaceholder: 'Lieferanten durchsuchen…', + noneLeft: 'Alle Lieferanten sind bereits zugewiesen', + createNew: '"{{name}}" anlegen', + createFailed: 'Lieferant konnte nicht angelegt werden', + purchaseSource: 'Hier gekauft', + purchaseSourceHelp: 'Markiert, wo diese Spule tatsächlich gekauft wurde; die übrigen Einträge sind alternative Bezugsquellen.', + articleNumberPlaceholder: 'Artikelnr. des Lieferanten', + pricePlaceholder: 'Angebotspreis/kg', + priceHelp: 'Angebotspreis bei diesem Lieferanten, zum Vergleich der Bezugsquellen — der eigene Preis pro kg der Spule bleibt die Kostenbasis.', + help: 'Wo dieses Produkt gekauft werden kann. Der hervorgehobene Eintrag ist die Bezugsquelle dieser Spule.', + saveFailed: 'Lieferantenzuordnungen konnten nicht gespeichert werden', + title: 'Lieferanten', + description: 'Verwalten Sie, wo Sie Filament kaufen — nicht zu verwechseln mit der Marke, also dem Hersteller. Weisen Sie Spulen im Spulendialog Lieferanten zu; ein Lieferant, der noch Spulen zugewiesen ist, kann nicht gelöscht werden.', + search: 'Lieferanten durchsuchen...', + addNew: 'Neuen Lieferanten hinzufügen', + namePlaceholder: 'Name (z. B. Filament24)', + websitePlaceholder: 'Website / Shop-URL', + customerNumberPlaceholder: 'Kundennr.', + notePlaceholder: 'Notiz', + nameRequired: 'Name ist erforderlich', + duplicateName: 'Ein Lieferant mit diesem Namen existiert bereits', + customerNumber: 'Kundennr.', + spools: 'Spulen', + noMatch: 'Keine Lieferanten entsprechen Ihrer Suche', + empty: 'Noch keine Lieferanten', + added: 'Lieferant hinzugefügt', + addFailed: 'Lieferant konnte nicht hinzugefügt werden', + updated: 'Lieferant aktualisiert', + updateFailed: 'Lieferant konnte nicht aktualisiert werden', + deleted: 'Lieferant gelöscht', + deleteFailed: 'Lieferant konnte nicht gelöscht werden', + deleteBlocked: 'Löschen nicht möglich: noch {{count}} Spule(n) zugewiesen', + loadFailed: 'Lieferanten konnten nicht geladen werden', + deleteTitle: 'Lieferant löschen', + deleteConfirm: '"{{name}}" löschen?', + deleteConfirmReferenced: '"{{name}}" ist {{count}} Spule(n) zugewiesen. Das Löschen ist blockiert, bis diese Zuordnungen entfernt wurden.', + }, // RFID button rename (was "Tag löschen") clearRfid: 'RFID-Tag löschen', rfidCleared: 'RFID-Tag gelöscht', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 17e066df4..3d6e4d4ef 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1687,6 +1687,17 @@ export default { printActivity: 'Print Activity', filamentTypes: 'Filament Types', filamentTrends: 'Filament Trends', + // Consumption/cost grouped by purchase-source supplier (#2988). + suppliers: { + title: 'By Supplier', + empty: 'No purchases recorded yet. Mark on a spool which supplier it was bought from to group consumption and costs here.', + error: 'Could not load supplier statistics', + supplier: 'Supplier', + spools: 'Spools', + remaining: 'Remaining', + consumed: 'Consumed', + cost: 'Cost', + }, failureAnalysis: 'Failure Analysis', timeAccuracy: 'Time Accuracy', successful: 'Successful:', @@ -4854,6 +4865,48 @@ export default { storageLocationNone: 'No location set', lowStockThresholdOverride: 'Low-stock threshold (this spool)', lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).', + // Suppliers (#2988): the master list and the per-spool assignments. + suppliers: { + label: 'Suppliers', + none: 'No supplier', + addSupplier: 'Add supplier…', + searchPlaceholder: 'Search suppliers…', + noneLeft: 'All suppliers are already assigned', + createNew: 'Create "{{name}}"', + createFailed: 'Failed to create supplier', + purchaseSource: 'Bought here', + purchaseSourceHelp: 'Marks where this spool was actually purchased; the other entries are alternative sources.', + articleNumberPlaceholder: 'Supplier article no.', + pricePlaceholder: 'Quoted price/kg', + priceHelp: 'Quoted price at this supplier, for comparing sources — the spool\'s own cost per kg stays the cost basis.', + help: 'Where this product can be bought. The highlighted entry is where this spool was purchased.', + saveFailed: 'Failed to save supplier assignments', + title: 'Suppliers', + description: 'Manage where you buy filament — distinct from brand, which is who made it. Assign suppliers to spools in the spool dialog; a supplier that is still assigned to spools cannot be deleted.', + search: 'Search suppliers...', + addNew: 'Add new supplier', + namePlaceholder: 'Name (e.g. Filament24)', + websitePlaceholder: 'Website / shop URL', + customerNumberPlaceholder: 'Customer no.', + notePlaceholder: 'Note', + nameRequired: 'Name is required', + duplicateName: 'A supplier with this name already exists', + customerNumber: 'Customer no.', + spools: 'Spools', + noMatch: 'No suppliers match your search', + empty: 'No suppliers yet', + added: 'Supplier added', + addFailed: 'Failed to add supplier', + updated: 'Supplier updated', + updateFailed: 'Failed to update supplier', + deleted: 'Supplier deleted', + deleteFailed: 'Failed to delete supplier', + deleteBlocked: 'Cannot delete: still assigned to {{count}} spool(s)', + loadFailed: 'Failed to load suppliers', + deleteTitle: 'Delete Supplier', + deleteConfirm: 'Delete "{{name}}"?', + deleteConfirmReferenced: '"{{name}}" is assigned to {{count}} spool(s). Deleting is blocked until those assignments are removed.', + }, // RFID button rename (was "Delete Tag" — confusing because it sounds like a // taxonomy delete; this clears the RFID tag/UUID off the spool record) clearRfid: 'Clear RFID Tag', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 3a262eba4..6e1cfbace 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -1670,6 +1670,17 @@ export default { printActivity: 'Actividad de impresión', filamentTypes: 'Tipos de filamento', filamentTrends: 'Tendencias del filamento', + // Consumption/cost grouped by purchase-source supplier (#2988). + suppliers: { + title: 'Por proveedor', + empty: 'Aún no hay compras registradas. Marque en una bobina a qué proveedor se compró para agrupar aquí el consumo y los costes.', + error: 'No se pudieron cargar las estadísticas de proveedores', + supplier: 'Proveedor', + spools: 'Bobinas', + remaining: 'Restante', + consumed: 'Consumido', + cost: 'Coste', + }, failureAnalysis: 'Análisis de fallos', timeAccuracy: 'Precisión temporal', successful: 'Con éxito:', @@ -4817,6 +4828,48 @@ export default { storageLocationNone: 'Sin ubicación establecida', lowStockThresholdOverride: 'Umbral de existencias bajas (esta bobina)', lowStockThresholdOverrideHelp: 'Déjelo en blanco para usar el umbral global ({{global}}%).', + // Suppliers (#2988): the master list and the per-spool assignments. + suppliers: { + label: 'Proveedores', + none: 'Sin proveedor', + addSupplier: 'Añadir proveedor…', + searchPlaceholder: 'Buscar proveedores…', + noneLeft: 'Todos los proveedores ya están asignados', + createNew: 'Crear "{{name}}"', + createFailed: 'Error al crear el proveedor', + purchaseSource: 'Comprado aquí', + purchaseSourceHelp: 'Indica dónde se compró realmente esta bobina; las demás entradas son fuentes alternativas.', + articleNumberPlaceholder: 'N.º de artículo del proveedor', + pricePlaceholder: 'Precio ofertado/kg', + priceHelp: 'Precio ofertado por este proveedor, para comparar fuentes; el coste por kg de la propia bobina sigue siendo la base de coste.', + help: 'Dónde se puede comprar este producto. La entrada resaltada es donde se compró esta bobina.', + saveFailed: 'Error al guardar las asignaciones de proveedores', + title: 'Proveedores', + description: 'Gestione dónde compra el filamento, algo distinto de la marca, que indica quién lo fabricó. Asigne proveedores a las bobinas en el diálogo de bobina; un proveedor que siga asignado a bobinas no se puede eliminar.', + search: 'Buscar proveedores...', + addNew: 'Añadir nuevo proveedor', + namePlaceholder: 'Nombre (p. ej. Filament24)', + websitePlaceholder: 'Sitio web / URL de la tienda', + customerNumberPlaceholder: 'N.º de cliente', + notePlaceholder: 'Nota', + nameRequired: 'El nombre es obligatorio', + duplicateName: 'Ya existe un proveedor con este nombre', + customerNumber: 'N.º de cliente', + spools: 'Bobinas', + noMatch: 'Ningún proveedor coincide con su búsqueda', + empty: 'Aún no hay proveedores', + added: 'Proveedor añadido', + addFailed: 'Error al añadir el proveedor', + updated: 'Proveedor actualizado', + updateFailed: 'Error al actualizar el proveedor', + deleted: 'Proveedor eliminado', + deleteFailed: 'Error al eliminar el proveedor', + deleteBlocked: 'No se puede eliminar: sigue asignado a {{count}} bobina(s)', + loadFailed: 'Error al cargar los proveedores', + deleteTitle: 'Eliminar proveedor', + deleteConfirm: '¿Eliminar "{{name}}"?', + deleteConfirmReferenced: '"{{name}}" está asignado a {{count}} bobina(s). La eliminación está bloqueada hasta que se quiten esas asignaciones.', + }, // RFID button rename clearRfid: 'Borrar etiqueta RFID', rfidCleared: 'Etiqueta RFID borrada', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 369a81c9b..e0b4a3e6f 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1670,6 +1670,16 @@ export default { printActivity: 'Activité d\'impression', filamentTypes: 'Types de filament', filamentTrends: 'Tendances filament', + suppliers: { + title: 'Par fournisseur', + empty: 'Aucun achat enregistré pour le moment. Indiquez sur une bobine auprès de quel fournisseur elle a été achetée pour regrouper ici la consommation et les coûts.', + error: 'Impossible de charger les statistiques des fournisseurs', + supplier: 'Fournisseur', + spools: 'Bobines', + remaining: 'Restant', + consumed: 'Consommé', + cost: 'Coût', + }, failureAnalysis: 'Analyse des échecs', timeAccuracy: 'Précision du temps', successful: 'Succès :', @@ -4803,6 +4813,47 @@ export default { storageLocationNone: 'Aucun emplacement défini', lowStockThresholdOverride: 'Seuil bas (cette bobine)', lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).', + suppliers: { + label: 'Fournisseurs', + none: 'Aucun fournisseur', + addSupplier: 'Ajouter un fournisseur…', + searchPlaceholder: 'Rechercher des fournisseurs…', + noneLeft: 'Tous les fournisseurs sont déjà associés', + createNew: 'Créer « {{name}} »', + createFailed: 'Échec de la création du fournisseur', + purchaseSource: 'Acheté ici', + purchaseSourceHelp: 'Indique où cette bobine a réellement été achetée ; les autres entrées sont des sources alternatives.', + articleNumberPlaceholder: 'Réf. article fournisseur', + pricePlaceholder: 'Prix proposé/kg', + priceHelp: 'Prix proposé chez ce fournisseur, pour comparer les sources — le coût par kg propre à la bobine reste la base de coût.', + help: 'Où ce produit peut être acheté. L\'entrée mise en évidence est celle où cette bobine a été achetée.', + saveFailed: 'Échec de l\'enregistrement des fournisseurs associés', + title: 'Fournisseurs', + description: 'Gérez où vous achetez votre filament — à distinguer de la marque, qui désigne le fabricant. Associez les fournisseurs aux bobines dans la boîte de dialogue de la bobine ; un fournisseur encore associé à des bobines ne peut pas être supprimé.', + search: 'Rechercher des fournisseurs...', + addNew: 'Ajouter un nouveau fournisseur', + namePlaceholder: 'Nom (ex. Filament24)', + websitePlaceholder: 'Site web / URL de la boutique', + customerNumberPlaceholder: 'N° client', + notePlaceholder: 'Note', + nameRequired: 'Le nom est requis', + duplicateName: 'Un fournisseur portant ce nom existe déjà', + customerNumber: 'N° client', + spools: 'Bobines', + noMatch: 'Aucun fournisseur ne correspond à votre recherche', + empty: 'Aucun fournisseur pour le moment', + added: 'Fournisseur ajouté', + addFailed: 'Échec de l\'ajout du fournisseur', + updated: 'Fournisseur mis à jour', + updateFailed: 'Échec de la mise à jour du fournisseur', + deleted: 'Fournisseur supprimé', + deleteFailed: 'Échec de la suppression du fournisseur', + deleteBlocked: 'Suppression impossible : encore associé à {{count}} bobine(s)', + loadFailed: 'Échec du chargement des fournisseurs', + deleteTitle: 'Supprimer le fournisseur', + deleteConfirm: 'Supprimer « {{name}} » ?', + deleteConfirmReferenced: '« {{name}} » est associé à {{count}} bobine(s). La suppression est bloquée tant que ces associations ne sont pas retirées.', + }, clearRfid: 'Effacer le tag RFID', rfidCleared: 'Tag RFID effacé', archive: 'Archiver', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 050fdefc3..9267b2e5b 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1670,6 +1670,16 @@ export default { printActivity: 'Attivita di stampa', filamentTypes: 'Tipi di filamento', filamentTrends: 'Trend filamento', + suppliers: { + title: 'Per fornitore', + empty: 'Nessun acquisto registrato. Indica su una bobina da quale fornitore è stata acquistata per raggruppare qui consumi e costi.', + error: 'Impossibile caricare le statistiche dei fornitori', + supplier: 'Fornitore', + spools: 'Bobine', + remaining: 'Rimanente', + consumed: 'Consumato', + cost: 'Costo', + }, failureAnalysis: 'Analisi guasti', timeAccuracy: 'Accuratezza tempo', successful: 'Riuscite:', @@ -4802,6 +4812,47 @@ export default { storageLocationNone: 'Nessuna posizione impostata', lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)', lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).', + suppliers: { + label: 'Fornitori', + none: 'Nessun fornitore', + addSupplier: 'Aggiungi fornitore…', + searchPlaceholder: 'Cerca fornitori…', + noneLeft: 'Tutti i fornitori sono già assegnati', + createNew: 'Crea "{{name}}"', + createFailed: 'Creazione del fornitore non riuscita', + purchaseSource: 'Acquistato qui', + purchaseSourceHelp: 'Indica dove questa bobina è stata effettivamente acquistata; le altre voci sono fonti alternative.', + articleNumberPlaceholder: 'Codice articolo del fornitore', + pricePlaceholder: 'Prezzo offerto/kg', + priceHelp: 'Prezzo offerto da questo fornitore, per confrontare le fonti — il costo al kg della bobina rimane la base di costo.', + help: 'Dove si può acquistare questo prodotto. La voce evidenziata è quella in cui è stata acquistata questa bobina.', + saveFailed: 'Salvataggio delle assegnazioni dei fornitori non riuscito', + title: 'Fornitori', + description: 'Gestisci dove acquisti il filamento — distinto dalla marca, che indica chi lo ha prodotto. Assegna i fornitori alle bobine nella finestra della bobina; un fornitore ancora assegnato a delle bobine non può essere eliminato.', + search: 'Cerca fornitori...', + addNew: 'Aggiungi nuovo fornitore', + namePlaceholder: 'Nome (es. Filament24)', + websitePlaceholder: 'Sito web / URL del negozio', + customerNumberPlaceholder: 'N. cliente', + notePlaceholder: 'Nota', + nameRequired: 'Il nome è obbligatorio', + duplicateName: 'Esiste già un fornitore con questo nome', + customerNumber: 'N. cliente', + spools: 'Bobine', + noMatch: 'Nessun fornitore corrisponde alla ricerca', + empty: 'Nessun fornitore presente', + added: 'Fornitore aggiunto', + addFailed: 'Aggiunta del fornitore non riuscita', + updated: 'Fornitore aggiornato', + updateFailed: 'Aggiornamento del fornitore non riuscito', + deleted: 'Fornitore eliminato', + deleteFailed: 'Eliminazione del fornitore non riuscita', + deleteBlocked: 'Impossibile eliminare: ancora assegnato a {{count}} bobina/e', + loadFailed: 'Caricamento dei fornitori non riuscito', + deleteTitle: 'Elimina fornitore', + deleteConfirm: 'Eliminare "{{name}}"?', + deleteConfirmReferenced: '"{{name}}" è assegnato a {{count}} bobina/e. L\'eliminazione è bloccata finché queste assegnazioni non vengono rimosse.', + }, clearRfid: 'Cancella tag RFID', rfidCleared: 'Tag RFID cancellato', archive: 'Archivia', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 8da8d71ab..70cc6579c 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1669,6 +1669,17 @@ export default { printActivity: '印刷アクティビティ', filamentTypes: 'フィラメントタイプ', filamentTrends: 'フィラメントトレンド', + // Consumption/cost grouped by purchase-source supplier (#2988). + suppliers: { + title: 'サプライヤー別', + empty: 'まだ購入記録がありません。スプールにどのサプライヤーから購入したかを設定すると、ここに消費量とコストが集計されます。', + error: '仕入先の統計を読み込めませんでした', + supplier: 'サプライヤー', + spools: 'スプール', + remaining: '残量', + consumed: '消費量', + cost: 'コスト', + }, failureAnalysis: '失敗分析', timeAccuracy: '時間精度', successful: '成功', @@ -4814,6 +4825,48 @@ export default { storageLocationNone: '保管場所未設定', lowStockThresholdOverride: '在庫低下のしきい値(このスプール)', lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。', + // Suppliers (#2988): the master list and the per-spool assignments. + suppliers: { + label: 'サプライヤー', + none: 'サプライヤーなし', + addSupplier: 'サプライヤーを追加…', + searchPlaceholder: 'サプライヤーを検索…', + noneLeft: 'すべてのサプライヤーが割り当て済みです', + createNew: '「{{name}}」を作成', + createFailed: 'サプライヤーの作成に失敗しました', + purchaseSource: 'ここで購入', + purchaseSourceHelp: 'このスプールを実際に購入した場所を示します。その他の項目は代替の購入先です。', + articleNumberPlaceholder: 'サプライヤーの品番', + pricePlaceholder: '提示価格/kg', + priceHelp: 'このサプライヤーでの提示価格です。購入先の比較用であり、スプール自体のkg単価が原価の基準のままです。', + help: 'この製品の購入先です。ハイライトされた項目がこのスプールを購入した場所です。', + saveFailed: 'サプライヤーの割り当ての保存に失敗しました', + title: 'サプライヤー', + description: 'フィラメントの購入先を管理します。製造元であるブランドとは別の概念です。スプールダイアログでスプールにサプライヤーを割り当てられます。スプールに割り当てられているサプライヤーは削除できません。', + search: 'サプライヤーを検索...', + addNew: '新しいサプライヤーを追加', + namePlaceholder: '名前(例:Filament24)', + websitePlaceholder: 'ウェブサイト / ショップURL', + customerNumberPlaceholder: '顧客番号', + notePlaceholder: 'メモ', + nameRequired: '名前は必須です', + duplicateName: 'この名前の仕入先はすでに存在します', + customerNumber: '顧客番号', + spools: 'スプール', + noMatch: '検索に一致するサプライヤーがありません', + empty: 'サプライヤーはまだありません', + added: 'サプライヤーを追加しました', + addFailed: 'サプライヤーの追加に失敗しました', + updated: 'サプライヤーを更新しました', + updateFailed: 'サプライヤーの更新に失敗しました', + deleted: 'サプライヤーを削除しました', + deleteFailed: 'サプライヤーの削除に失敗しました', + deleteBlocked: '削除できません:まだ{{count}}個のスプールに割り当てられています', + loadFailed: 'サプライヤーの読み込みに失敗しました', + deleteTitle: 'サプライヤーを削除', + deleteConfirm: '「{{name}}」を削除しますか?', + deleteConfirmReferenced: '「{{name}}」は{{count}}個のスプールに割り当てられています。割り当てを解除するまで削除できません。', + }, clearRfid: 'RFIDタグをクリア', rfidCleared: 'RFIDタグをクリアしました', archive: 'アーカイブ', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 95bc872ac..499607d37 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -1603,6 +1603,16 @@ export default { printActivity: '인쇄 활동', filamentTypes: '필라멘트 종류', filamentTrends: '필라멘트 추세', + suppliers: { + title: '공급업체별', + empty: '아직 기록된 구매가 없습니다. 스풀에 구매한 공급업체를 표시하면 여기에서 소비량과 비용이 그룹화됩니다.', + error: '공급업체 통계를 불러올 수 없습니다', + supplier: '공급업체', + spools: '스풀', + remaining: '남은 양', + consumed: '소비량', + cost: '비용', + }, failureAnalysis: '실패 분석', timeAccuracy: '시간 정확도', successful: '성공:', @@ -4599,6 +4609,47 @@ export default { categoryNone: '미분류', lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)', lowStockThresholdOverrideHelp: '전역 임계값({{global}}%)을 사용하려면 비워두세요.', + suppliers: { + label: '공급업체', + none: '공급업체 없음', + addSupplier: '공급업체 추가…', + searchPlaceholder: '공급업체 검색…', + noneLeft: '모든 공급업체가 이미 할당되었습니다', + createNew: '"{{name}}" 만들기', + createFailed: '공급업체 생성에 실패했습니다', + purchaseSource: '구매처', + purchaseSourceHelp: '이 스풀을 실제로 구매한 곳을 표시합니다. 나머지 항목은 대체 구매처입니다.', + articleNumberPlaceholder: '공급업체 품목 번호', + pricePlaceholder: '견적 가격/kg', + priceHelp: '이 공급업체의 견적 가격으로, 구매처 비교용입니다. 스풀 자체의 kg당 비용이 실제 원가 기준으로 유지됩니다.', + help: '이 제품을 구매할 수 있는 곳입니다. 강조 표시된 항목이 이 스풀을 구매한 곳입니다.', + saveFailed: '공급업체 할당 저장에 실패했습니다', + title: '공급업체', + description: '필라멘트 구매처를 관리합니다. 제조사를 뜻하는 브랜드와는 다른 개념입니다. 스풀 대화상자에서 스풀에 공급업체를 할당할 수 있으며, 스풀에 할당된 공급업체는 삭제할 수 없습니다.', + search: '공급업체 검색...', + addNew: '새 공급업체 추가', + namePlaceholder: '이름 (예: Filament24)', + websitePlaceholder: '웹사이트 / 쇼핑몰 URL', + customerNumberPlaceholder: '고객 번호', + notePlaceholder: '메모', + nameRequired: '이름은 필수입니다', + duplicateName: '같은 이름의 공급업체가 이미 있습니다', + customerNumber: '고객 번호', + spools: '스풀', + noMatch: '검색과 일치하는 공급업체가 없습니다', + empty: '아직 공급업체가 없습니다', + added: '공급업체가 추가되었습니다', + addFailed: '공급업체 추가에 실패했습니다', + updated: '공급업체가 수정되었습니다', + updateFailed: '공급업체 수정에 실패했습니다', + deleted: '공급업체가 삭제되었습니다', + deleteFailed: '공급업체 삭제에 실패했습니다', + deleteBlocked: '삭제할 수 없습니다: 아직 스풀 {{count}}개에 할당되어 있습니다', + loadFailed: '공급업체를 불러오지 못했습니다', + deleteTitle: '공급업체 삭제', + deleteConfirm: '"{{name}}"을(를) 삭제하시겠습니까?', + deleteConfirmReferenced: '"{{name}}"이(가) 스풀 {{count}}개에 할당되어 있습니다. 해당 할당을 제거할 때까지 삭제할 수 없습니다.', + }, clearRfid: 'RFID 태그 초기화', rfidCleared: 'RFID 태그가 초기화되었습니다', archive: '보관', diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index ac156b2ab..823c8105f 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/frontend/src/i18n/locales/nl.ts @@ -1687,6 +1687,17 @@ export default { printActivity: 'Afdrukactiviteit', filamentTypes: 'Filamenttypen', filamentTrends: 'Filamenttrends', + // Consumption/cost grouped by purchase-source supplier (#2988). + suppliers: { + title: 'Per leverancier', + empty: 'Nog geen aankopen geregistreerd. Markeer op een spoel bij welke leverancier deze is gekocht om verbruik en kosten hier te groeperen.', + error: 'Leveranciersstatistieken konden niet worden geladen', + supplier: 'Leverancier', + spools: 'Spoelen', + remaining: 'Resterend', + consumed: 'Verbruikt', + cost: 'Kosten', + }, failureAnalysis: 'Foutenanalyse', timeAccuracy: 'Tijdnauwkeurigheid', successful: 'Geslaagd:', @@ -4854,6 +4865,48 @@ export default { storageLocationNone: 'Geen locatie ingesteld', lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)', lowStockThresholdOverrideHelp: 'Laat leeg om de globale drempel ({{global}}%) te gebruiken.', + // Suppliers (#2988): the master list and the per-spool assignments. + suppliers: { + label: 'Leveranciers', + none: 'Geen leverancier', + addSupplier: 'Leverancier toevoegen…', + searchPlaceholder: 'Leveranciers zoeken…', + noneLeft: 'Alle leveranciers zijn al toegewezen', + createNew: '"{{name}}" aanmaken', + createFailed: 'Leverancier aanmaken mislukt', + purchaseSource: 'Hier gekocht', + purchaseSourceHelp: 'Markeert waar deze spoel daadwerkelijk is gekocht; de overige vermeldingen zijn alternatieve bronnen.', + articleNumberPlaceholder: 'Artikelnr. leverancier', + pricePlaceholder: 'Offerteprijs/kg', + priceHelp: 'Offerteprijs bij deze leverancier, om bronnen te vergelijken — de eigen kosten per kg van de spoel blijven de kostenbasis.', + help: 'Waar dit product te koop is. De gemarkeerde vermelding is waar deze spoel is gekocht.', + saveFailed: 'Leverancierstoewijzingen opslaan mislukt', + title: 'Leveranciers', + description: 'Beheer waar je filament koopt — niet te verwarren met het merk, dat is wie het heeft gemaakt. Wijs leveranciers toe aan spoelen in het spoeldialoogvenster; een leverancier die nog aan spoelen is toegewezen kan niet worden verwijderd.', + search: 'Leveranciers zoeken...', + addNew: 'Nieuwe leverancier toevoegen', + namePlaceholder: 'Naam (bijv. Filament24)', + websitePlaceholder: 'Website / winkel-URL', + customerNumberPlaceholder: 'Klantnr.', + notePlaceholder: 'Notitie', + nameRequired: 'Naam is verplicht', + duplicateName: 'Er bestaat al een leverancier met deze naam', + customerNumber: 'Klantnr.', + spools: 'Spoelen', + noMatch: 'Geen leveranciers gevonden voor je zoekopdracht', + empty: 'Nog geen leveranciers', + added: 'Leverancier toegevoegd', + addFailed: 'Leverancier toevoegen mislukt', + updated: 'Leverancier bijgewerkt', + updateFailed: 'Leverancier bijwerken mislukt', + deleted: 'Leverancier verwijderd', + deleteFailed: 'Leverancier verwijderen mislukt', + deleteBlocked: 'Kan niet verwijderen: nog toegewezen aan {{count}} spoel(en)', + loadFailed: 'Leveranciers laden mislukt', + deleteTitle: 'Leverancier verwijderen', + deleteConfirm: '"{{name}}" verwijderen?', + deleteConfirmReferenced: '"{{name}}" is toegewezen aan {{count}} spoel(en). Verwijderen is geblokkeerd totdat die toewijzingen zijn verwijderd.', + }, // RFID button rename (was "Delete Tag" — confusing because it sounds like a // taxonomy delete; this clears the RFID tag/UUID off the spool record) clearRfid: 'RFID-tag wissen', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 8ffb3c445..efa78c259 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -1670,6 +1670,16 @@ export default { printActivity: 'Atividade de Impressão', filamentTypes: 'Tipos de Filamento', filamentTrends: 'Tendências de Filamento', + suppliers: { + title: 'Por Fornecedor', + empty: 'Nenhuma compra registrada ainda. Marque em um carretel de qual fornecedor ele foi comprado para agrupar consumo e custos aqui.', + error: 'Não foi possível carregar as estatísticas de fornecedores', + supplier: 'Fornecedor', + spools: 'Carretéis', + remaining: 'Restante', + consumed: 'Consumido', + cost: 'Custo', + }, failureAnalysis: 'Análise de Falhas', timeAccuracy: 'Precisão do Tempo', successful: 'Bem-sucedido:', @@ -4802,6 +4812,47 @@ export default { storageLocationNone: 'Sem local definido', lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)', lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).', + suppliers: { + label: 'Fornecedores', + none: 'Sem fornecedor', + addSupplier: 'Adicionar fornecedor…', + searchPlaceholder: 'Pesquisar fornecedores…', + noneLeft: 'Todos os fornecedores já estão atribuídos', + createNew: 'Criar "{{name}}"', + createFailed: 'Falha ao criar fornecedor', + purchaseSource: 'Comprado aqui', + purchaseSourceHelp: 'Indica onde este carretel foi realmente comprado; as outras entradas são fontes alternativas.', + articleNumberPlaceholder: 'Nº do artigo no fornecedor', + pricePlaceholder: 'Preço cotado/kg', + priceHelp: 'Preço cotado neste fornecedor, para comparar fontes — o custo por kg do próprio carretel continua sendo a base de custo.', + help: 'Onde este produto pode ser comprado. A entrada destacada é onde este carretel foi comprado.', + saveFailed: 'Falha ao salvar as atribuições de fornecedor', + title: 'Fornecedores', + description: 'Gerencie onde você compra filamento — diferente da marca, que é quem o fabricou. Atribua fornecedores aos carretéis no diálogo do carretel; um fornecedor que ainda está atribuído a carretéis não pode ser excluído.', + search: 'Pesquisar fornecedores...', + addNew: 'Adicionar novo fornecedor', + namePlaceholder: 'Nome (ex. Filament24)', + websitePlaceholder: 'Site / URL da loja', + customerNumberPlaceholder: 'Nº de cliente', + notePlaceholder: 'Observação', + nameRequired: 'O nome é obrigatório', + duplicateName: 'Já existe um fornecedor com esse nome', + customerNumber: 'Nº de cliente', + spools: 'Carretéis', + noMatch: 'Nenhum fornecedor corresponde à sua pesquisa', + empty: 'Nenhum fornecedor ainda', + added: 'Fornecedor adicionado', + addFailed: 'Falha ao adicionar fornecedor', + updated: 'Fornecedor atualizado', + updateFailed: 'Falha ao atualizar fornecedor', + deleted: 'Fornecedor excluído', + deleteFailed: 'Falha ao excluir fornecedor', + deleteBlocked: 'Não é possível excluir: ainda atribuído a {{count}} carretel(éis)', + loadFailed: 'Falha ao carregar fornecedores', + deleteTitle: 'Excluir Fornecedor', + deleteConfirm: 'Excluir "{{name}}"?', + deleteConfirmReferenced: '"{{name}}" está atribuído a {{count}} carretel(éis). A exclusão fica bloqueada até que essas atribuições sejam removidas.', + }, clearRfid: 'Limpar tag RFID', rfidCleared: 'Tag RFID limpa', archive: 'Arquivar', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 1017c2dda..df1e827f1 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -1600,6 +1600,16 @@ export default { printActivity: "Активность печати", filamentTypes: "Типы филамента", filamentTrends: "Расход филамента", + suppliers: { + title: "По поставщикам", + empty: "Покупки пока не зарегистрированы. Отметьте на катушке, у какого поставщика она была куплена, чтобы сгруппировать здесь расход и затраты.", + error: 'Не удалось загрузить статистику по поставщикам', + supplier: "Поставщик", + spools: "Катушки", + remaining: "Остаток", + consumed: "Израсходовано", + cost: "Затраты", + }, failureAnalysis: "Анализ неудач", timeAccuracy: "Точность оценки времени", successful: "Успешно:", @@ -4590,6 +4600,47 @@ export default { storageLocationNone: "Место не указано", lowStockThresholdOverride: "Порог малого остатка (эта катушка)", lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).", + suppliers: { + label: "Поставщики", + none: "Без поставщика", + addSupplier: "Добавить поставщика…", + searchPlaceholder: "Поиск поставщиков…", + noneLeft: "Все поставщики уже назначены", + createNew: "Создать «{{name}}»", + createFailed: "Не удалось создать поставщика", + purchaseSource: "Куплено здесь", + purchaseSourceHelp: "Отмечает, где эта катушка была фактически куплена; остальные записи — альтернативные источники.", + articleNumberPlaceholder: "Артикул поставщика", + pricePlaceholder: "Предлагаемая цена/кг", + priceHelp: "Предлагаемая цена у этого поставщика для сравнения источников — базой затрат остаётся собственная стоимость катушки за кг.", + help: "Где можно купить этот товар. Выделенная запись — место покупки этой катушки.", + saveFailed: "Не удалось сохранить назначения поставщиков", + title: "Поставщики", + description: "Управляйте тем, где вы покупаете филамент — в отличие от производителя, который его изготовил. Назначайте поставщиков катушкам в диалоге катушки; поставщика, который всё ещё назначен катушкам, удалить нельзя.", + search: "Поиск поставщиков…", + addNew: "Добавить поставщика", + namePlaceholder: "Название (напр. Filament24)", + websitePlaceholder: "Сайт / URL магазина", + customerNumberPlaceholder: "Номер клиента", + notePlaceholder: "Заметка", + nameRequired: "Название обязательно", + duplicateName: 'Поставщик с таким названием уже существует', + customerNumber: "Номер клиента", + spools: "Катушки", + noMatch: "Поставщики по вашему запросу не найдены", + empty: "Пока нет поставщиков", + added: "Поставщик добавлен", + addFailed: "Не удалось добавить поставщика", + updated: "Поставщик обновлён", + updateFailed: "Не удалось обновить поставщика", + deleted: "Поставщик удалён", + deleteFailed: "Не удалось удалить поставщика", + deleteBlocked: "Удаление невозможно: всё ещё назначен катушкам ({{count}})", + loadFailed: "Не удалось загрузить поставщиков", + deleteTitle: "Удалить поставщика", + deleteConfirm: "Удалить «{{name}}»?", + deleteConfirmReferenced: "«{{name}}» назначен катушкам ({{count}}). Удаление заблокировано, пока эти назначения не будут сняты.", + }, clearRfid: "Очистить RFID-метку", rfidCleared: "RFID-метка очищена", archive: "Архивировать", diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts index 19acfda2a..a413d56ed 100644 --- a/frontend/src/i18n/locales/sv.ts +++ b/frontend/src/i18n/locales/sv.ts @@ -1687,6 +1687,16 @@ export default { printActivity: 'Utskriftsaktivitet', filamentTypes: 'Filamenttyper', filamentTrends: 'Filamenttrender', + suppliers: { + title: 'Per leverantör', + empty: 'Inga inköp registrerade ännu. Ange på en rulle vilken leverantör den köptes från för att gruppera förbrukning och kostnader här.', + error: 'Kunde inte läsa in leverantörsstatistiken', + supplier: 'Leverantör', + spools: 'Rullar', + remaining: 'Kvar', + consumed: 'Förbrukat', + cost: 'Kostnad', + }, failureAnalysis: 'Felanalys', timeAccuracy: 'Tidsnoggrannhet', successful: 'Lyckade:', @@ -4854,6 +4864,47 @@ errors: { storageLocationNone: 'Ingen lagringsplats inställd', lowStockThresholdOverride: 'Lågt lagertröskelvärde (denna rulle)', lowStockThresholdOverrideHelp: 'Lämna tomt för att använda det globala tröskelvärdet ({{global}}%).', + suppliers: { + label: 'Leverantörer', + none: 'Ingen leverantör', + addSupplier: 'Lägg till leverantör…', + searchPlaceholder: 'Sök leverantörer…', + noneLeft: 'Alla leverantörer är redan tilldelade', + createNew: 'Skapa ”{{name}}”', + createFailed: 'Det gick inte att skapa leverantören', + purchaseSource: 'Köpt här', + purchaseSourceHelp: 'Markerar var den här rullen faktiskt köptes; övriga poster är alternativa inköpsställen.', + articleNumberPlaceholder: 'Leverantörens artikelnr', + pricePlaceholder: 'Offererat pris/kg', + priceHelp: 'Offererat pris hos den här leverantören, för att jämföra inköpsställen – rullens eget kilopris är fortfarande kostnadsunderlaget.', + help: 'Var den här produkten kan köpas. Den markerade posten är där rullen köptes.', + saveFailed: 'Det gick inte att spara leverantörstilldelningarna', + title: 'Leverantörer', + description: 'Hantera var du köper filament – till skillnad från märke, som är vem som tillverkat det. Tilldela leverantörer till rullar i rulldialogen; en leverantör som fortfarande är tilldelad rullar går inte att ta bort.', + search: 'Sök leverantörer …', + addNew: 'Lägg till ny leverantör', + namePlaceholder: 'Namn (t.ex. Filament24)', + websitePlaceholder: 'Webbplats/butikslänk', + customerNumberPlaceholder: 'Kundnr', + notePlaceholder: 'Anteckning', + nameRequired: 'Namn krävs', + duplicateName: 'Det finns redan en leverantör med det namnet', + customerNumber: 'Kundnr', + spools: 'Rullar', + noMatch: 'Inga leverantörer matchar din sökning', + empty: 'Inga leverantörer ännu', + added: 'Leverantören har lagts till', + addFailed: 'Det gick inte att lägga till leverantören', + updated: 'Leverantören har uppdaterats', + updateFailed: 'Det gick inte att uppdatera leverantören', + deleted: 'Leverantören har tagits bort', + deleteFailed: 'Det gick inte att ta bort leverantören', + deleteBlocked: 'Går inte att ta bort: fortfarande tilldelad {{count}} rulle/rullar', + loadFailed: 'Det gick inte att läsa in leverantörerna', + deleteTitle: 'Ta bort leverantör', + deleteConfirm: 'Ta bort ”{{name}}”?', + deleteConfirmReferenced: '”{{name}}” är tilldelad {{count}} rulle/rullar. Borttagning är blockerad tills de tilldelningarna har tagits bort.', + }, // RFID button rename (was "Delete Tag" — confusing because it sounds like a // taxonomy delete; this clears the RFID tag/UUID off the spool record) clearRfid: 'Rensa RFID-tagg', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index d458b4da9..22bda1874 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -1671,6 +1671,16 @@ export default { printActivity: 'Baskı Etkinliği', filamentTypes: 'Filament Türleri', filamentTrends: 'Filament Trendleri', + suppliers: { + title: 'Tedarikçiye Göre', + empty: 'Henüz satın alma kaydı yok. Tüketim ve maliyetleri burada gruplamak için bir makarada hangi tedarikçiden alındığını işaretleyin.', + error: 'Tedarikçi istatistikleri yüklenemedi', + supplier: 'Tedarikçi', + spools: 'Makaralar', + remaining: 'Kalan', + consumed: 'Tüketilen', + cost: 'Maliyet', + }, failureAnalysis: 'Başarısızlık Analizi', timeAccuracy: 'Süre Doğruluğu', successful: 'Başarılı:', @@ -4802,6 +4812,47 @@ export default { storageLocationNone: 'Konum ayarlanmamış', lowStockThresholdOverride: 'Düşük stok eşiği (bu makara)', lowStockThresholdOverrideHelp: 'Global eşiği kullanmak için boş bırakın (%{{global}}).', + suppliers: { + label: 'Tedarikçiler', + none: 'Tedarikçi yok', + addSupplier: 'Tedarikçi ekle…', + searchPlaceholder: 'Tedarikçi ara…', + noneLeft: 'Tüm tedarikçiler zaten atanmış', + createNew: '"{{name}}" oluştur', + createFailed: 'Tedarikçi oluşturulamadı', + purchaseSource: 'Buradan satın alındı', + purchaseSourceHelp: 'Bu makaranın gerçekte nereden satın alındığını işaretler; diğer girişler alternatif kaynaklardır.', + articleNumberPlaceholder: 'Tedarikçi ürün no.', + pricePlaceholder: 'Teklif fiyatı/kg', + priceHelp: 'Bu tedarikçideki teklif fiyatı, kaynakları karşılaştırmak içindir — makaranın kendi kg başına maliyeti, maliyet esası olarak kalır.', + help: 'Bu ürünün satın alınabileceği yerler. Vurgulanan giriş, bu makaranın satın alındığı yerdir.', + saveFailed: 'Tedarikçi atamaları kaydedilemedi', + title: 'Tedarikçiler', + description: 'Filamenti nereden satın aldığınızı yönetin — bunu üreten firmayı belirten markadan farklıdır. Makara iletişim kutusunda makaralara tedarikçi atayın; hâlâ makaralara atanmış bir tedarikçi silinemez.', + search: 'Tedarikçi ara...', + addNew: 'Yeni tedarikçi ekle', + namePlaceholder: 'Ad (örn. Filament24)', + websitePlaceholder: 'Web sitesi / mağaza URL\'si', + customerNumberPlaceholder: 'Müşteri no.', + notePlaceholder: 'Not', + nameRequired: 'Ad gereklidir', + duplicateName: 'Bu ada sahip bir tedarikçi zaten var', + customerNumber: 'Müşteri no.', + spools: 'Makaralar', + noMatch: 'Aramanızla eşleşen tedarikçi yok', + empty: 'Henüz tedarikçi yok', + added: 'Tedarikçi eklendi', + addFailed: 'Tedarikçi eklenemedi', + updated: 'Tedarikçi güncellendi', + updateFailed: 'Tedarikçi güncellenemedi', + deleted: 'Tedarikçi silindi', + deleteFailed: 'Tedarikçi silinemedi', + deleteBlocked: 'Silinemez: hâlâ {{count}} makaraya atanmış', + loadFailed: 'Tedarikçiler yüklenemedi', + deleteTitle: 'Tedarikçiyi Sil', + deleteConfirm: '"{{name}}" silinsin mi?', + deleteConfirmReferenced: '"{{name}}" {{count}} makaraya atanmış. Bu atamalar kaldırılana kadar silme engellenir.', + }, clearRfid: 'RFID Etiketini Temizle', rfidCleared: 'RFID etiketi temizlendi', archive: 'Arşivle', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index bba9b8423..92a2f27e9 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -1686,6 +1686,16 @@ export default { printActivity: "Активність друку", filamentTypes: "Типи філаментів", filamentTrends: "Тенденції філаменту", + suppliers: { + title: "За постачальником", + empty: "Покупок ще не зафіксовано. Позначте на котушці, у якого постачальника її придбано, щоб згрупувати тут споживання та витрати.", + error: 'Не вдалося завантажити статистику постачальників', + supplier: "Постачальник", + spools: "Котушки", + remaining: "Залишок", + consumed: "Спожито", + cost: "Вартість", + }, failureAnalysis: "Аналіз невдач", timeAccuracy: "Точність оцінки часу", successful: "Успішно:", @@ -4851,6 +4861,47 @@ export default { storageLocationNone: "Місцезнаходження не встановлено", lowStockThresholdOverride: "Поріг низького запасу (ця котушка)", lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).", + suppliers: { + label: "Постачальники", + none: "Без постачальника", + addSupplier: "Додати постачальника…", + searchPlaceholder: "Пошук постачальників…", + noneLeft: "Усі постачальники вже призначені", + createNew: "Створити «{{name}}»", + createFailed: "Не вдалося створити постачальника", + purchaseSource: "Куплено тут", + purchaseSourceHelp: "Позначає, де цю котушку було фактично придбано; інші записи — альтернативні джерела.", + articleNumberPlaceholder: "Артикул постачальника", + pricePlaceholder: "Пропонована ціна/кг", + priceHelp: "Пропонована ціна в цього постачальника — для порівняння джерел; власна вартість котушки за кг залишається базою собівартості.", + help: "Де можна придбати цей товар. Виділений запис — це місце, де було куплено цю котушку.", + saveFailed: "Не вдалося зберегти призначення постачальників", + title: "Постачальники", + description: "Керуйте тим, де ви купуєте філамент — на відміну від бренду, який вказує виробника. Призначайте постачальників котушкам у діалозі котушки; постачальника, який усе ще призначений котушкам, видалити не можна.", + search: "Пошук постачальників...", + addNew: "Додати нового постачальника", + namePlaceholder: "Назва (напр. Filament24)", + websitePlaceholder: "Вебсайт / URL магазину", + customerNumberPlaceholder: "Номер клієнта", + notePlaceholder: "Нотатка", + nameRequired: "Назва є обов’язковою", + duplicateName: 'Постачальник із такою назвою вже існує', + customerNumber: "Номер клієнта", + spools: "Котушки", + noMatch: "Жоден постачальник не відповідає вашому запиту", + empty: "Постачальників поки немає", + added: "Постачальника додано", + addFailed: "Не вдалося додати постачальника", + updated: "Постачальника оновлено", + updateFailed: "Не вдалося оновити постачальника", + deleted: "Постачальника видалено", + deleteFailed: "Не вдалося видалити постачальника", + deleteBlocked: "Неможливо видалити: усе ще призначено {{count}} котушці(-ам)", + loadFailed: "Не вдалося завантажити постачальників", + deleteTitle: "Видалити постачальника", + deleteConfirm: "Видалити «{{name}}»?", + deleteConfirmReferenced: "«{{name}}» призначено {{count}} котушці(-ам). Видалення заблоковано, доки ці призначення не буде знято.", + }, // RFID button rename (was "Delete Tag" — confusing because it sounds like a // taxonomy delete; this clears the RFID tag/UUID off the spool record) clearRfid: "Очистити тег RFID.", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 0587dce8c..193f4c5ad 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1670,6 +1670,16 @@ export default { printActivity: '打印活动', filamentTypes: '耗材类型', filamentTrends: '耗材趋势', + suppliers: { + title: '按供应商', + empty: '暂无购买记录。在料盘上标记其购买来源供应商后,即可在此按供应商统计消耗和成本。', + error: '无法加载供应商统计数据', + supplier: '供应商', + spools: '料盘', + remaining: '剩余', + consumed: '已消耗', + cost: '成本', + }, failureAnalysis: '失败分析', timeAccuracy: '时间准确度', successful: '成功:', @@ -4802,6 +4812,47 @@ export default { storageLocationNone: '未设置位置', lowStockThresholdOverride: '低库存阈值(此料盘)', lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。', + suppliers: { + label: '供应商', + none: '无供应商', + addSupplier: '添加供应商…', + searchPlaceholder: '搜索供应商…', + noneLeft: '所有供应商均已分配', + createNew: '创建“{{name}}”', + createFailed: '创建供应商失败', + purchaseSource: '在此购买', + purchaseSourceHelp: '标记此料盘的实际购买来源;其他条目为备选购买渠道。', + articleNumberPlaceholder: '供应商货号', + pricePlaceholder: '报价/kg', + priceHelp: '此供应商的报价,用于比较购买渠道;料盘自身的每公斤成本仍作为成本依据。', + help: '此产品的可购买渠道。高亮条目为此料盘的实际购买来源。', + saveFailed: '保存供应商分配失败', + title: '供应商', + description: '管理耗材的购买渠道——不同于品牌,品牌是耗材的制造商。可在料盘对话框中为料盘分配供应商;仍被料盘引用的供应商无法删除。', + search: '搜索供应商...', + addNew: '添加新供应商', + namePlaceholder: '名称(例如 Filament24)', + websitePlaceholder: '网站/商店链接', + customerNumberPlaceholder: '客户编号', + notePlaceholder: '备注', + nameRequired: '名称为必填项', + duplicateName: '已存在同名供应商', + customerNumber: '客户编号', + spools: '料盘', + noMatch: '没有匹配的供应商', + empty: '暂无供应商', + added: '供应商已添加', + addFailed: '添加供应商失败', + updated: '供应商已更新', + updateFailed: '更新供应商失败', + deleted: '供应商已删除', + deleteFailed: '删除供应商失败', + deleteBlocked: '无法删除:仍分配给 {{count}} 个料盘', + loadFailed: '加载供应商失败', + deleteTitle: '删除供应商', + deleteConfirm: '确定删除“{{name}}”吗?', + deleteConfirmReferenced: '“{{name}}”已分配给 {{count}} 个料盘。请先移除这些分配后才能删除。', + }, clearRfid: '清除 RFID 标签', rfidCleared: 'RFID 标签已清除', archive: '归档', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 67f3e9b53..13e61890d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -1670,6 +1670,16 @@ export default { printActivity: '列印活動', filamentTypes: '耗材類型', filamentTrends: '耗材趨勢', + suppliers: { + title: '依供應商', + empty: '尚未記錄任何購買。在料盤上標記其購買來源的供應商,即可在此依供應商彙整消耗量與成本。', + error: '無法載入供應商統計資料', + supplier: '供應商', + spools: '料盤', + remaining: '剩餘', + consumed: '已消耗', + cost: '成本', + }, failureAnalysis: '失敗分析', timeAccuracy: '時間準確度', successful: '成功:', @@ -4802,6 +4812,47 @@ export default { storageLocationNone: '未設定位置', lowStockThresholdOverride: '低庫存閾值(此料盤)', lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。', + suppliers: { + label: '供應商', + none: '無供應商', + addSupplier: '新增供應商…', + searchPlaceholder: '搜尋供應商…', + noneLeft: '所有供應商皆已指派', + createNew: '建立「{{name}}」', + createFailed: '建立供應商失敗', + purchaseSource: '於此購買', + purchaseSourceHelp: '標記此料盤實際購買的來源;其他項目為替代來源。', + articleNumberPlaceholder: '供應商商品編號', + pricePlaceholder: '報價/公斤', + priceHelp: '此供應商的報價,用於比較購買來源;料盤本身的每公斤成本仍為成本基準。', + help: '此產品可購買的地點。醒目標示的項目為此料盤的實際購買處。', + saveFailed: '儲存供應商指派失敗', + title: '供應商', + description: '管理您購買耗材的來源——與品牌不同,品牌是製造商。可在料盤對話框中為料盤指派供應商;仍指派給料盤的供應商無法刪除。', + search: '搜尋供應商...', + addNew: '新增供應商', + namePlaceholder: '名稱(例如 Filament24)', + websitePlaceholder: '網站/商店網址', + customerNumberPlaceholder: '客戶編號', + notePlaceholder: '備註', + nameRequired: '名稱為必填', + duplicateName: '已存在同名供應商', + customerNumber: '客戶編號', + spools: '料盤', + noMatch: '沒有符合搜尋的供應商', + empty: '尚無供應商', + added: '供應商已新增', + addFailed: '新增供應商失敗', + updated: '供應商已更新', + updateFailed: '更新供應商失敗', + deleted: '供應商已刪除', + deleteFailed: '刪除供應商失敗', + deleteBlocked: '無法刪除:仍指派給 {{count}} 個料盤', + loadFailed: '載入供應商失敗', + deleteTitle: '刪除供應商', + deleteConfirm: '刪除「{{name}}」?', + deleteConfirmReferenced: '「{{name}}」已指派給 {{count}} 個料盤。必須先移除這些指派才能刪除。', + }, clearRfid: '清除 RFID 標籤', rfidCleared: 'RFID 標籤已清除', archive: '歸檔', diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 561cc776c..83fb3635b 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -7,7 +7,7 @@ import { Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns, ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin, - Upload, Download, + Upload, Download, Store, } from 'lucide-react'; import { ForecastPanel } from '../components/ForecastPanel'; import { api, spoolbuddyApi, ApiError } from '../api/client'; @@ -22,6 +22,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal'; import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal'; import { LocationsModal } from '../components/LocationsModal'; +import { SuppliersModal } from '../components/SuppliersModal'; import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; @@ -98,6 +99,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [ { id: 'printed_total', label: 'Printed Total', visible: false }, { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false }, { id: 'note', label: 'Note', visible: false }, + { id: 'suppliers', label: 'Suppliers', visible: false }, { id: 'pa_k', label: 'PA(K)', visible: true }, { id: 'tag_id', label: 'Tag ID', visible: false }, { id: 'data_origin', label: 'Data Origin', visible: false }, @@ -227,6 +229,7 @@ const columnHeaders: Record string> = { printed_total: () => 'Printed Total', printed_since_weight: () => 'Printed Since Weight', note: (t) => t('inventory.note'), + suppliers: (t) => t('inventory.suppliers.label'), pa_k: () => 'PA(K)', tag_id: () => 'Tag ID', data_origin: () => 'Data Origin', @@ -367,6 +370,31 @@ const columnCells: Record ReactNode> = { note: ({ spool }) => ( {spool.note || '-'} ), + // 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 -; + const sorted = [...links].sort((a, b) => Number(b.is_purchase_source) - Number(a.is_purchase_source)); + return ( +
+ {sorted.map((link) => ( + + {link.supplier_name} + + ))} +
+ ); + }, pa_k: ({ spool }) => { const count = spool.k_profiles?.length ?? 0; if (count === 0) return -; @@ -513,6 +541,13 @@ const columnSortValues: Record< used: (s) => s.weight_used, remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0, note: (s) => (s.note || '').toLowerCase(), + // Sorts on the purchase-source supplier, falling back to the first + // assignment — a spool has to sit in exactly one place in the list. + suppliers: (s) => { + const links = s.suppliers ?? []; + const primary = links.find((l) => l.is_purchase_source) ?? links[0]; + return (primary?.supplier_name || '').toLowerCase(); + }, data_origin: (s) => (s.data_origin || '').toLowerCase(), tag_type: (s) => (s.tag_type || '').toLowerCase(), stock: (s) => s.slicer_filament ? 1 : 0, @@ -595,6 +630,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo const [csvImportOpen, setCsvImportOpen] = useState(false); const [exportingCsv, setExportingCsv] = useState(false); const [locationsModalOpen, setLocationsModalOpen] = useState(false); + const [suppliersModalOpen, setSuppliersModalOpen] = useState(false); // Filter state const [archiveFilter, setArchiveFilter] = useState('active'); @@ -602,6 +638,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo const [materialFilter, setMaterialFilter] = useState(''); const [brandFilter, setBrandFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState(''); + // Filter on an assigned supplier (#2988), same shape as category. + const [supplierFilter, setSupplierFilter] = useState(''); const [spoolFilter, setSpoolFilter] = useState(''); const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all'); const [search, setSearch] = useState(''); @@ -636,7 +674,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo // honest vs. what the user is actually looking at. useEffect(() => { setSelectedIds(new Set()); - }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search]); + }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, search]); // Pagination state (pageSize persisted to localStorage) const [pageIndex, setPageIndex] = useState(0); @@ -1274,6 +1312,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo } } + // Supplier dropdown (#2988): "everything from supplier X" matches ANY + // assignment, purchase source or alternative; `__none__` finds spools + // without supplier assignments. + if (supplierFilter) { + if (supplierFilter === '__none__') { + filtered = filtered.filter((s) => (s.suppliers ?? []).length === 0); + } else { + const supplierId = Number(supplierFilter); + filtered = filtered.filter((s) => (s.suppliers ?? []).some((l) => l.supplier_id === supplierId)); + } + } + // Spool name dropdown if (spoolFilter) { const catalogId = Number(spoolFilter); @@ -1309,7 +1359,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo } return filtered; - }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]); + }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]); // Reset page on filter changes const resetPage = () => setPageIndex(0); @@ -1330,6 +1380,17 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[]; const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort(); const hasUncategorized = (spools ?? []).some((s) => !s.category); + // #2988: suppliers seen across the inventory, for the filter dropdown. + const uniqueSuppliers = useMemo(() => { + const byId = new Map(); + for (const s of spools ?? []) { + for (const link of s.suppliers ?? []) { + byId.set(link.supplier_id, link.supplier_name); + } + } + return [...byId.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); + }, [spools]); + const hasUnsupplied = (spools ?? []).some((s) => (s.suppliers ?? []).length === 0); const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => { const nameA = (catalogMap[a]?.name || '').toLowerCase(); const nameB = (catalogMap[b]?.name || '').toLowerCase(); @@ -1340,7 +1401,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.location_id && !s.storage_location?.trim()); // Check if any filters are non-default - const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search; + const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search; const handleColumnConfigSave = (config: ColumnConfig[]) => { setColumnConfig(config); @@ -1462,6 +1523,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo setMaterialFilter(''); setBrandFilter(''); setCategoryFilter(''); + setSupplierFilter(''); setSpoolFilter(''); setStockFilter('all'); setSearch(''); @@ -1515,6 +1577,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo {t('locations.manage')} + {/* Suppliers (#2988): inventory master data like Locations, so the + modal opens from the same toolbar — in both inventory modes. */} +