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

This commit is contained in:
Thomansky
2026-09-28 15:14:07 +02:00
committed by GitHub
parent 66379e97b5
commit 053cfa73ad
46 changed files with 4843 additions and 54 deletions
+393 -18
View File
@@ -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()
@@ -41,9 +41,11 @@ from backend.app.models.settings import Settings
from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
from backend.app.models.spoolman_k_profile import SpoolmanKProfile
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
from backend.app.models.user import User
from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
from backend.app.schemas.supplier import SpoolSupplierLinkInput
from backend.app.services.location_service import (
enrich_spool_dicts_with_location_id,
maybe_sync_spoolman_locations,
@@ -476,6 +478,19 @@ async def list_spools(
for m in mapped:
m["k_profiles"] = kp_by_spool.get(m["id"], [])
# Supplier assignments (#2988) live Bambuddy-side even for Spoolman
# spools, so the list carries them in both modes identically.
link_result = await db.execute(
select(SpoolmanSpoolSupplier)
.options(selectinload(SpoolmanSpoolSupplier.supplier))
.where(SpoolmanSpoolSupplier.spoolman_spool_id.in_(spool_ids))
)
links_by_spool: dict[int, list[dict]] = {}
for link in link_result.scalars().all():
links_by_spool.setdefault(link.spoolman_spool_id, []).append(_supplier_link_to_dict(link))
for m in mapped:
m["suppliers"] = links_by_spool.get(m["id"], [])
await enrich_spool_dicts_with_location_id(db, mapped)
return mapped
@@ -498,6 +513,12 @@ async def get_spool(
kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
link_result = await db.execute(
select(SpoolmanSpoolSupplier)
.options(selectinload(SpoolmanSpoolSupplier.supplier))
.where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)
)
mapped["suppliers"] = [_supplier_link_to_dict(link) for link in link_result.scalars().all()]
await enrich_spool_dicts_with_location_id(db, [mapped])
return mapped
@@ -902,6 +923,22 @@ async def update_spool(
return _map_spoolman_spool(updated)
async def _purge_local_rows_for_spool(db: AsyncSession, spool_id: int) -> None:
"""Drop the Bambuddy-side rows a deleted Spoolman spool leaves behind.
Spoolman owns the spool; Bambuddy owns the K profiles, the filament preset
overrides and the supplier assignments, each keyed by the remote id with no
foreign key that could cascade. The K-profile and preset leaks are inert,
but a leaked ``spoolman_spool_suppliers`` row keeps the supplier's
reference count non-zero, so deleting that supplier answers 409 forever
with no way for the user to find the phantom reference (#2988).
The caller commits.
"""
for model in (SpoolmanKProfile, SpoolmanFilamentPreset, SpoolmanSpoolSupplier):
await db.execute(delete(model).where(model.spoolman_spool_id == spool_id))
@router.delete("/spools/{spool_id}")
async def delete_spool(
spool_id: int = Path(..., gt=0),
@@ -912,6 +949,8 @@ async def delete_spool(
client = await _get_client(db)
async with _translate_spoolman_errors():
await client.delete_spool(spool_id)
await _purge_local_rows_for_spool(db, spool_id)
await db.commit()
await ws_manager.broadcast({"type": "inventory_changed"})
return {"status": "deleted"}
@@ -1042,6 +1081,7 @@ async def bulk_delete_spools(
try:
async with _translate_spoolman_errors():
await client.delete_spool(sid)
await _purge_local_rows_for_spool(db, sid)
deleted += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
@@ -1049,6 +1089,7 @@ async def bulk_delete_spools(
logger.exception("Spoolman bulk-delete failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if deleted:
await db.commit()
await ws_manager.broadcast({"type": "inventory_changed"})
return {"deleted": deleted, "errors": errors}
@@ -2149,3 +2190,77 @@ async def save_spoolman_k_profiles(
await db.refresh(obj)
return [_k_profile_to_dict(p) for p in saved]
def _supplier_link_to_dict(link: SpoolmanSpoolSupplier) -> dict:
"""Same shape as ``SpoolSupplierResponse`` so the frontend renders both
inventories with one component."""
return {
"id": link.id,
"supplier_id": link.supplier_id,
"supplier_name": link.supplier_name,
"supplier_article_number": link.supplier_article_number,
"quoted_price_per_kg": link.quoted_price_per_kg,
"is_purchase_source": link.is_purchase_source,
}
@router.get("/spools/{spool_id}/suppliers")
async def get_spoolman_spool_suppliers(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> list[dict]:
"""Supplier assignments for a Spoolman spool (#2988, Bambuddy-side rows)."""
await _get_client(db)
result = await db.execute(
select(SpoolmanSpoolSupplier)
.options(selectinload(SpoolmanSpoolSupplier.supplier))
.where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id)
)
return [_supplier_link_to_dict(link) for link in result.scalars().all()]
@router.put("/spools/{spool_id}/suppliers")
async def save_spoolman_spool_suppliers(
spool_id: int = Path(..., gt=0),
links: list[SpoolSupplierLinkInput] = Body(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> list[dict]:
"""Replace a Spoolman spool's supplier assignments (#2988).
Mirror of the built-in inventory's replace-all endpoint — Spoolman owns
the spool, Bambuddy owns the assignment (``SpoolmanKProfile`` precedent),
so the rows are local and the spool is only verified to exist remotely.
"""
client = await _get_client(db)
async with _translate_spoolman_errors():
await client.get_spool(spool_id)
supplier_ids = [link.supplier_id for link in links]
if len(set(supplier_ids)) != len(supplier_ids):
raise HTTPException(400, "Duplicate supplier in assignment list")
if sum(1 for link in links if link.is_purchase_source) > 1:
raise HTTPException(400, "Only one assignment can be the purchase source")
if supplier_ids:
found = await db.execute(select(Supplier.id).where(Supplier.id.in_(supplier_ids)))
missing = set(supplier_ids) - {row[0] for row in found.all()}
if missing:
raise HTTPException(404, f"Supplier(s) not found: {sorted(missing)}")
await db.execute(delete(SpoolmanSpoolSupplier).where(SpoolmanSpoolSupplier.spoolman_spool_id == spool_id))
saved: list[SpoolmanSpoolSupplier] = []
for link in links:
row = SpoolmanSpoolSupplier(spoolman_spool_id=spool_id, **link.model_dump())
db.add(row)
saved.append(row)
await db.commit()
refreshed = await db.execute(
select(SpoolmanSpoolSupplier)
.options(selectinload(SpoolmanSpoolSupplier.supplier))
.where(SpoolmanSpoolSupplier.id.in_([row.id for row in saved]))
.order_by(SpoolmanSpoolSupplier.id)
)
await ws_manager.broadcast({"type": "inventory_changed"})
return [_supplier_link_to_dict(link) for link in refreshed.scalars().all()]
+205
View File
@@ -5076,6 +5076,10 @@ async def run_migrations(conn):
# on fresh installs only — this covers databases whose table predates it.
await _migrate_location_ha_sensor_unique_binding(conn)
# Migration: supplier master list + spool assignments (#2988).
# create_all() covers fresh installs; this covers upgrades.
await _migrate_create_supplier_tables(conn)
# Migration: repair the tare of spools the RFID auto-add gave the wrong
# Bambu spool row (#2909). Runs last so the spool catalogue it reads is
# whatever this database actually holds.
@@ -5145,6 +5149,207 @@ async def _migrate_confirm_prompt_body_template(conn) -> None:
)
async def _migrate_create_supplier_tables(conn) -> None:
"""Create the supplier tables on databases that predate #2988.
``Base.metadata.create_all()`` covers fresh installs; upgrades get the
tables here, following the ``_migrate_create_finance_tables`` shape.
``spool_suppliers`` deliberately has NO ON DELETE CASCADE on the supplier
side — the API refuses to delete a referenced supplier (409) so
assignments can never silently orphan.
"""
if is_sqlite():
statements = [
"""
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY,
name VARCHAR(200) NOT NULL,
name_key VARCHAR(200) NOT NULL,
website VARCHAR(500),
customer_number VARCHAR(100),
note VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS spool_suppliers (
id INTEGER PRIMARY KEY,
spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
supplier_article_number VARCHAR(100),
quoted_price_per_kg FLOAT,
is_purchase_source BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers (
id INTEGER PRIMARY KEY,
spoolman_spool_id INTEGER NOT NULL,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
supplier_article_number VARCHAR(100),
quoted_price_per_kg FLOAT,
is_purchase_source BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id)
)
""",
]
else:
statements = [
"""
CREATE TABLE IF NOT EXISTS suppliers (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
name_key VARCHAR(200) NOT NULL,
website VARCHAR(500),
customer_number VARCHAR(100),
note VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS spool_suppliers (
id SERIAL PRIMARY KEY,
spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
supplier_article_number VARCHAR(100),
quoted_price_per_kg FLOAT,
is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers (
id SERIAL PRIMARY KEY,
spoolman_spool_id INTEGER NOT NULL,
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
supplier_article_number VARCHAR(100),
quoted_price_per_kg FLOAT,
is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id)
)
""",
]
for statement in statements:
await _safe_execute(conn, statement)
# The model declares index=True on these; fresh installs get them from
# create_all(), migrated databases need them spelled out.
await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_suppliers_name ON suppliers (name)")
await _migrate_supplier_name_key(conn)
await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_spool_id ON spool_suppliers (spool_id)")
await _safe_execute(
conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_supplier_id ON spool_suppliers (supplier_id)"
)
await _safe_execute(
conn,
"CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_spoolman_spool_id"
" ON spoolman_spool_suppliers (spoolman_spool_id)",
)
await _safe_execute(
conn,
"CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_supplier_id ON spoolman_spool_suppliers (supplier_id)",
)
async def _migrate_supplier_name_key(conn) -> None:
"""Backfill ``suppliers.name_key`` and collapse case-insensitive duplicates (#2988).
The unique index cannot simply be created: a database written by an
earlier build of this branch is allowed to hold two suppliers whose names
differ only in case, and ``CREATE UNIQUE INDEX`` refuses to build over
them. ``_safe_execute`` re-raises that IntegrityError, which would abort
startup with no route to recovery from the UI — the same trap
``_migrate_location_ha_sensor_unique_binding`` clears first.
Colliding rows are merged rather than deleted, because a supplier is
referenced: the oldest row wins (it is the one assignments and the CSV
import already resolved to), its empty fields are filled from the
duplicate, every assignment is re-pointed to it, and only then is the
duplicate dropped. An assignment the surviving row already holds for the
same spool is dropped instead of re-pointed — the (spool, supplier) pair
is unique.
"""
from sqlalchemy import text
from backend.app.models.supplier import supplier_name_key
# Only a table written by an earlier build of this branch lacks the column
# (the CREATE TABLE above declares it NOT NULL). ADD COLUMN cannot carry
# NOT NULL without a default, so it is added nullable, backfilled below and
# tightened afterwards where the database can do that in place.
await _safe_execute(conn, "ALTER TABLE suppliers ADD COLUMN name_key VARCHAR(200)")
async with conn.begin_nested():
rows = (
await conn.execute(
text("SELECT id, name, name_key, website, customer_number, note FROM suppliers ORDER BY id")
)
).fetchall()
kept: dict[str, int] = {}
for row in rows:
key = supplier_name_key(row.name or "")
winner_id = kept.get(key)
if winner_id is None:
kept[key] = row.id
if row.name_key != key:
await conn.execute(
text("UPDATE suppliers SET name_key = :key WHERE id = :id"),
{"key": key, "id": row.id},
)
continue
params = {"keep": winner_id, "drop": row.id}
await conn.execute(
text(
"DELETE FROM spool_suppliers WHERE supplier_id = :drop AND spool_id IN "
"(SELECT spool_id FROM spool_suppliers WHERE supplier_id = :keep)"
),
params,
)
await conn.execute(text("UPDATE spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params)
await conn.execute(
text(
"DELETE FROM spoolman_spool_suppliers WHERE supplier_id = :drop AND spoolman_spool_id IN "
"(SELECT spoolman_spool_id FROM spoolman_spool_suppliers WHERE supplier_id = :keep)"
),
params,
)
await conn.execute(
text("UPDATE spoolman_spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params
)
await conn.execute(
text(
"UPDATE suppliers SET website = COALESCE(website, :website), "
"customer_number = COALESCE(customer_number, :customer_number), "
"note = COALESCE(note, :note) WHERE id = :keep"
),
{
"website": row.website,
"customer_number": row.customer_number,
"note": row.note,
"keep": winner_id,
},
)
await conn.execute(text("DELETE FROM suppliers WHERE id = :drop"), {"drop": row.id})
logger.info("Merged duplicate supplier %r (id=%s) into id=%s", row.name, row.id, winner_id)
# Superseded by ix_suppliers_name_key: lower(name) folds ASCII only, so it
# never enforced the rule for non-ASCII names in the first place.
await _safe_execute(conn, "DROP INDEX IF EXISTS uq_suppliers_name_lower")
await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_suppliers_name_key ON suppliers (name_key)")
# NULLs never collide in a unique index, so the guarantee belongs in the
# schema, as create_all() declares it on fresh installs. Every row has a
# key by now. SQLite cannot alter a column in place; there the ORM hook on
# Supplier.name is what writes it.
if not is_sqlite():
await _safe_execute(conn, "ALTER TABLE suppliers ALTER COLUMN name_key SET NOT NULL")
async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
"""Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
+4
View File
@@ -39,6 +39,7 @@ from backend.app.models.spool_filament_preset import SpoolFilamentPreset, Spoolm
from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier
from backend.app.models.user import User
from backend.app.models.user_email_pref import UserEmailPreference
from backend.app.models.user_otp_code import UserOTPCode
@@ -90,6 +91,9 @@ __all__ = [
"SpoolAssignment",
"SpoolCatalogEntry",
"SpoolUsageHistory",
"Supplier",
"SpoolSupplier",
"SpoolmanSpoolSupplier",
"ColorCatalogEntry",
"SpoolBuddyDevice",
"SponsorToastState",
+6
View File
@@ -83,9 +83,15 @@ class Spool(Base):
)
assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
location: Mapped["Location | None"] = relationship(back_populates="spools")
# Supplier assignments (#2988): where this product can be bought, with
# per-assignment article number / price and a purchase-source marker.
# Default loader like every other relationship here — the handful of
# routes that embed them ask for selectinload() at the query site.
supplier_links: Mapped[list["SpoolSupplier"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
from backend.app.models.location import Location # noqa: E402
from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402
from backend.app.models.spool_filament_preset import SpoolFilamentPreset # noqa: E402
from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402
from backend.app.models.supplier import SpoolSupplier # noqa: E402
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.spool import Spool
def supplier_name_key(name: str) -> str:
"""Case-insensitive lookup key stored on ``Supplier.name_key``.
Folded in Python, not in SQL: SQLite's ``lower()`` folds ASCII only, so a
unique index on ``lower(name)`` lets “Ökofilament” and “ökofilament” both
through — and the CSV import, which folds in Python, then collapses them
onto one map entry and resolves to whichever row it built last. Every
caller goes through this one function so the two folds cannot drift.
"""
return name.strip().lower()
class Supplier(Base):
"""Managed supplier master list (#2988).
A supplier is *where filament is bought* — distinct from ``Spool.brand``,
which is who made it. One supplier carries many brands, and the same
product is available from several suppliers, hence the n:m assignment
below instead of a free-text field that drifts in spelling.
"""
__tablename__ = "suppliers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200), index=True)
# Case-insensitive uniqueness -- the whole feature keys on the name. The
# CSV import resolves a column of names against this table and the master
# list is what a rename re-points, so "Extrudr" and "extrudr" must not be
# two rows. Stored key rather than a unique index on lower(name), for the
# reason in supplier_name_key: only the Python fold is Unicode-correct on
# every backend, and it is the same fold the import map uses.
name_key: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
# Shop / website URL, purely informational.
website: Mapped[str | None] = mapped_column(String(500))
# The business's own customer number AT this supplier.
customer_number: Mapped[str | None] = mapped_column(String(100))
note: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
spool_links: Mapped[list[SpoolSupplier]] = relationship(back_populates="supplier")
@validates("name")
def _sync_name_key(self, _key: str, value: str) -> str:
"""Derive name_key from every name write, so no insert path can skip it."""
self.name_key = supplier_name_key(value)
return value
class SpoolSupplier(Base):
"""Spool-to-supplier assignment with per-assignment attributes (#2988).
Modelled after ``CostCenterMember`` (surrogate id + UNIQUE pair) rather
than a bare association table, because the assignment carries data: the
supplier's own article number for the product (NOT the internal material
number from #2870), the price at this supplier, and whether this concrete
spool was actually bought there — the other rows are alternative sources.
"""
__tablename__ = "spool_suppliers"
__table_args__ = (UniqueConstraint("spool_id", "supplier_id", name="uq_spool_suppliers_spool_supplier"),)
id: Mapped[int] = mapped_column(primary_key=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
# The supplier's article number for this product.
supplier_article_number: Mapped[str | None] = mapped_column(String(100))
# QUOTED price per kg at this supplier, for comparing sources. Named so it
# can never be read as actual cost: ``spool.cost_per_kg`` is the cost basis
# for every print and is never written from here.
quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
# True on the assignment this spool was actually purchased from.
is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
supplier: Mapped[Supplier] = relationship(back_populates="spool_links")
spool: Mapped[Spool] = relationship(back_populates="supplier_links")
@property
def supplier_name(self) -> str:
"""Flattened for SpoolSupplierResponse. Every route that embeds links
chains selectinload() onto ``supplier``, so this never lazy-loads."""
return self.supplier.name if self.supplier else ""
class SpoolmanSpoolSupplier(Base):
"""``SpoolSupplier`` for a Spoolman-managed spool.
Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns the
supplier assignment, so the row is local and keyed by the remote spool id
with no foreign key to enforce it. Suppliers are Bambuddy-side on purpose —
Spoolman's ``vendor`` is the manufacturer, not the seller.
"""
__tablename__ = "spoolman_spool_suppliers"
__table_args__ = (UniqueConstraint("spoolman_spool_id", "supplier_id", name="uq_spoolman_spool_suppliers_pair"),)
id: Mapped[int] = mapped_column(primary_key=True)
spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), index=True)
supplier_article_number: Mapped[str | None] = mapped_column(String(100))
quoted_price_per_kg: Mapped[float | None] = mapped_column(Float)
is_purchase_source: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
supplier: Mapped[Supplier] = relationship()
@property
def supplier_name(self) -> str:
return self.supplier.name if self.supplier else ""
+8
View File
@@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from backend.app.schemas.supplier import SpoolSupplierResponse
# Visual variant applied to a spool's swatch — purely cosmetic, does not
# affect MQTT/firmware. Kept independent of `subtype` so users can override
# the rendering hint without touching Bambu's categorical filament label.
@@ -241,9 +243,15 @@ class SpoolResponse(SpoolBase):
created_at: datetime
updated_at: datetime
k_profiles: list[SpoolKProfileResponse] = []
# Supplier assignments (#2988): where this product can be bought, with
# per-assignment article number / price and a purchase-source marker.
# Reads the ORM relationship `supplier_links`, which every route
# answering with this schema loads explicitly (see spool_response_loads).
suppliers: list[SpoolSupplierResponse] = Field(default=[], validation_alias="supplier_links")
class Config:
from_attributes = True
populate_by_name = True
class SpoolAssignmentCreate(BaseModel):
+112
View File
@@ -0,0 +1,112 @@
"""Pydantic schemas for the supplier master list (#2988)."""
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
# The CSV `suppliers` column joins names with "; " (#2988). A name carrying
# the separator would split into unknown names on import and silently drop the
# assignments, so it is refused at the edge rather than escaped — the column
# stays readable and hand-editable.
SUPPLIER_NAME_SEPARATOR = ";"
def validate_supplier_name(value: str) -> str:
trimmed = value.strip()
if not trimmed:
raise ValueError("name must not be empty")
if SUPPLIER_NAME_SEPARATOR in trimmed:
raise ValueError("name must not contain ';' — it separates suppliers in the CSV export")
return trimmed
class SupplierBase(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
website: str | None = Field(default=None, max_length=500)
# The business's own customer number AT this supplier.
customer_number: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=500)
class SupplierCreate(SupplierBase):
# Only on the write schemas: SupplierResponse inherits SupplierBase, and a
# row written before the rule existed must still be readable. mode="before"
# so the name is trimmed before max_length sees it — a padded name at the
# cap is not too long once stripped. Non-strings pass through to the type
# check.
@field_validator("name", mode="before")
@classmethod
def _normalize_name(cls, value: object) -> object:
return validate_supplier_name(value) if isinstance(value, str) else value
class SupplierUpdate(BaseModel):
# Optional so a PATCH can leave the name alone. An explicit null is a 422
# here instead of a NOT NULL violation surfacing as a 500.
name: str | None = Field(default=None, min_length=1, max_length=200)
website: str | None = Field(default=None, max_length=500)
customer_number: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=500)
@field_validator("name", mode="before")
@classmethod
def _normalize_name(cls, value: object) -> object:
if value is None:
raise ValueError("name must not be null")
return validate_supplier_name(value) if isinstance(value, str) else value
class SupplierResponse(SupplierBase):
id: int
# Number of spools referencing this supplier — shown in the settings list
# and the reason a delete is refused (409) instead of orphaning links.
spool_count: int = 0
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SpoolSupplierLinkInput(BaseModel):
"""One spool-to-supplier assignment as written by the spool dialog."""
supplier_id: int = Field(..., gt=0)
# The supplier's own article number for the product — NOT the internal
# material number (#2870).
supplier_article_number: str | None = Field(default=None, max_length=100)
# Quoted price per kg at this supplier, for comparing sources. Never a
# cost basis — ``spool.cost_per_kg`` stays authoritative and is not
# written from assignments.
quoted_price_per_kg: float | None = Field(default=None, ge=0)
# Marks where this concrete spool was actually bought; the other
# assignments are alternative sources.
is_purchase_source: bool = False
class SpoolSupplierResponse(BaseModel):
id: int
supplier_id: int
supplier_name: str
supplier_article_number: str | None = None
quoted_price_per_kg: float | None = None
is_purchase_source: bool = False
class Config:
from_attributes = True
class SupplierStats(BaseModel):
"""Per-supplier inventory aggregate (#2988), purchase-source spools only.
``spool_count`` and ``remaining_g`` cover active spools bought at this
supplier; ``consumed_g`` and ``cost`` sum the recorded usage history of
every purchase-source spool, archived included.
"""
supplier_id: int
supplier_name: str
spool_count: int
remaining_g: float
consumed_g: float
cost: float
+64
View File
@@ -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 ""
@@ -255,9 +255,17 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
# when creating SpoolAssignment runs synchronously outside the greenlet.
spool.k_profiles = []
spool.assignments = []
spool.supplier_links = []
db.add(spool)
await db.flush()
# A new spool of a product that already carries supplier assignments
# inherits the source list (#2988) — a scanned refill arrives knowing
# where it can be bought.
from backend.app.services.supplier_links import apply_supplier_inheritance
await apply_supplier_inheritance(db, spool)
logger.info(
"Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
spool.id,
+106
View File
@@ -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))
@@ -0,0 +1,926 @@
"""API coverage for the supplier master list and spool assignments (#2988).
The master list lives under /api/v1/inventory/suppliers (Locations pattern),
gated by the plain inventory permissions. Assignments exist for both
inventories: `spool_suppliers` for built-in spools and the
`spoolman_spool_suppliers` twin keyed by the remote spool id.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.inventory import DUPLICATE_SUPPLIER_NAME
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Supplier
SAMPLE_SPOOLMAN_SPOOL = {
"id": 7,
"filament": {
"id": 1,
"name": "PETG CF",
"material": "PETG",
"weight": 1000,
"color_hex": "000000",
"vendor": {"id": 1, "name": "BrandX"},
},
"remaining_weight": 600.0,
"used_weight": 400.0,
"location": None,
"comment": None,
"first_used": None,
"last_used": None,
"registered": "2024-01-01T00:00:00+00:00",
"archived": False,
"price": None,
"extra": {},
}
@pytest.fixture
async def spool_factory(db_session: AsyncSession):
async def _create(**kwargs):
defaults = {
"material": "PLA",
"subtype": "Matte",
"brand": "Bambu Lab",
"color_name": "Charcoal",
"rgba": "333333FF",
"label_weight": 1000,
"core_weight": 250,
"weight_used": 0,
"weight_used_baseline": 0,
"weight_locked": False,
}
defaults.update(kwargs)
spool = Spool(**defaults)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
return spool
return _create
@pytest.fixture
async def supplier_factory(db_session: AsyncSession):
_counter = [0]
async def _create(**kwargs):
_counter[0] += 1
defaults = {"name": f"Supplier {_counter[0]}"}
defaults.update(kwargs)
supplier = Supplier(**defaults)
db_session.add(supplier)
await db_session.commit()
await db_session.refresh(supplier)
return supplier
return _create
@pytest.fixture
async def spoolman_settings(db_session: AsyncSession):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
@pytest.fixture
def mock_spoolman_client():
client = MagicMock()
client.base_url = "http://localhost:7912"
client.health_check = AsyncMock(return_value=True)
client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
client.get_distinct_locations = AsyncMock(return_value=[])
client.delete_spool = AsyncMock(return_value=None)
with (
patch(
"backend.app.api.routes.spoolman_inventory._get_client",
AsyncMock(return_value=client),
),
# The supplier delete reconciles twin rows against Spoolman before it
# refuses (#2988); inventory.py resolves its own client.
patch("backend.app.api.routes.inventory.get_spoolman_client", AsyncMock(return_value=client)),
):
yield client
@pytest.mark.unit
def test_supplier_relationships_use_the_default_loader():
"""No relationship-level eager loader (#2988).
``Spool.supplier_links`` used to be ``lazy="selectin"``, which made every
``select(Spool)`` in the app — usage tracker, AMS sync, labels, backup —
pay two extra round trips for assignments it never reads. The routes that
embed them ask for ``selectinload()`` at the query site instead.
"""
from sqlalchemy import inspect as sa_inspect
assert sa_inspect(Spool).relationships["supplier_links"].lazy == "select"
assert sa_inspect(SpoolSupplier).relationships["supplier"].lazy == "select"
assert sa_inspect(SpoolmanSpoolSupplier).relationships["supplier"].lazy == "select"
class TestSupplierCrud:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_and_list(self, async_client: AsyncClient):
resp = await async_client.post(
"/api/v1/inventory/suppliers",
json={"name": "Filament24", "website": "https://filament24.example", "customer_number": "C-1042"},
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "Filament24"
assert body["spool_count"] == 0
listing = await async_client.get("/api/v1/inventory/suppliers")
assert listing.status_code == 200
assert [s["name"] for s in listing.json()] == ["Filament24"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update(self, async_client: AsyncClient, supplier_factory):
supplier = await supplier_factory(name="Old Name")
resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": "New Name"})
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_unreferenced(self, async_client: AsyncClient, supplier_factory):
supplier = await supplier_factory()
resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
assert resp.status_code == 200
assert (await async_client.get("/api/v1/inventory/suppliers")).json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_referenced_is_refused(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
supplier = await supplier_factory()
spool = await spool_factory()
db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
await db_session.commit()
resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
assert resp.status_code == 409
assert "cannot be deleted" in resp.json()["detail"]
# The listing surfaces the usage count behind the refusal.
listing = await async_client.get("/api/v1/inventory/suppliers")
assert listing.json()[0]["spool_count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_refused_for_spoolman_only_reference(
self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
):
"""A supplier used only by Spoolman-mode assignments is still protected."""
supplier = await supplier_factory()
db_session.add(SpoolmanSpoolSupplier(spoolman_spool_id=7, supplier_id=supplier.id))
await db_session.commit()
resp = await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")
assert resp.status_code == 409
assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_reads_case_insensitively(self, async_client: AsyncClient, supplier_factory):
"""Ordered on the folded name, so "extrudr" sits between "Bambu Store"
and "Zultrat" rather than after every capitalised name (#2988)."""
for name in ["Zultrat", "extrudr", "Bambu Store"]:
await supplier_factory(name=name)
listing = (await async_client.get("/api/v1/inventory/suppliers")).json()
assert [s["name"] for s in listing] == ["Bambu Store", "extrudr", "Zultrat"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_padded_name_at_the_cap_is_measured_after_trimming(
self, async_client: AsyncClient, supplier_factory
):
"""The length limit applies to the stored name, not to surrounding
whitespace that is stripped anyway (#2988)."""
name = "S" * 200
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": f" {name} "})
assert resp.status_code == 201
assert resp.json()["name"] == name
other = await supplier_factory(name="Other")
resp = await async_client.patch(f"/api/v1/inventory/suppliers/{other.id}", json={"name": f" {'T' * 200} "})
assert resp.status_code == 200
assert resp.json()["name"] == "T" * 200
# A name that really is too long is still refused.
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "U" * 201})
assert resp.status_code == 422
class TestSupplierNameUniqueness:
"""Supplier names are the feature's key (#2988): CSV import resolves
against them and a rename re-points every assignment, so two rows with
the same name silently send an import to the wrong supplier."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_duplicate_name_is_refused(self, async_client: AsyncClient):
assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})).status_code == 201
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
assert resp.status_code == 409
assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_duplicate_is_case_and_whitespace_insensitive(self, async_client: AsyncClient):
"""The CSV map is keyed on the trimmed lower-cased name, so a case
variant would be just as ambiguous as an exact duplicate."""
await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr"})
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": " eXtRuDr "})
assert resp.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_duplicate_is_refused_for_non_ascii_names(self, async_client: AsyncClient):
"""The fold has to be the Python one to be worth anything here.
SQLite's lower() folds ASCII only, so a unique index on lower(name)
saw these as two different names and let both in — while the import
map, which folds in Python, collapsed them onto a single entry and
resolved to whichever row it built last. That is exactly the silent
wrong-supplier assignment the rule exists to prevent.
"""
assert (await async_client.post("/api/v1/inventory/suppliers", json={"name": "Ökofilament"})).status_code == 201
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "ökofilament"})
assert resp.status_code == 409
assert resp.json()["detail"] == DUPLICATE_SUPPLIER_NAME
assert len((await async_client.get("/api/v1/inventory/suppliers")).json()) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_onto_an_existing_name_is_refused(self, async_client: AsyncClient, supplier_factory):
a = await supplier_factory(name="Extrudr")
b = await supplier_factory(name="Filament24")
resp = await async_client.patch(f"/api/v1/inventory/suppliers/{b.id}", json={"name": "extrudr"})
assert resp.status_code == 409
# Renaming a supplier to the name it already has is not a conflict.
assert (
await async_client.patch(f"/api/v1/inventory/suppliers/{a.id}", json={"name": "Extrudr"})
).status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_name_is_trimmed_on_write(self, async_client: AsyncClient):
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": " Extrudr "})
assert resp.status_code == 201
assert resp.json()["name"] == "Extrudr"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_explicit_null_name_is_a_validation_error(self, async_client: AsyncClient, supplier_factory):
"""422, not the 500 a NOT NULL violation used to produce."""
supplier = await supplier_factory()
resp = await async_client.patch(f"/api/v1/inventory/suppliers/{supplier.id}", json={"name": None})
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_csv_separator_in_name_is_refused(self, async_client: AsyncClient):
"""A ';' in the name would split into unknown names on CSV import and
silently drop every assignment that used it."""
resp = await async_client.post("/api/v1/inventory/suppliers", json={"name": "Extrudr; GmbH"})
assert resp.status_code == 422
class TestSpoolSupplierAssignments:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_replace_all_and_embed_in_spool_response(
self, async_client: AsyncClient, supplier_factory, spool_factory
):
a = await supplier_factory(name="Supplier A")
b = await supplier_factory(name="Supplier B")
spool = await spool_factory()
resp = await async_client.put(
f"/api/v1/inventory/spools/{spool.id}/suppliers",
json=[
{
"supplier_id": a.id,
"supplier_article_number": "A-100",
"quoted_price_per_kg": 19.99,
"is_purchase_source": True,
},
{"supplier_id": b.id, "quoted_price_per_kg": 22.5},
],
)
assert resp.status_code == 200
body = resp.json()
assert {row["supplier_name"] for row in body} == {"Supplier A", "Supplier B"}
assert [row["is_purchase_source"] for row in sorted(body, key=lambda r: r["supplier_id"])] == [True, False]
# Embedded in the inventory listing.
listing = await async_client.get("/api/v1/inventory/spools")
spool_row = next(s for s in listing.json() if s["id"] == spool.id)
assert {row["supplier_name"] for row in spool_row["suppliers"]} == {"Supplier A", "Supplier B"}
# Replace-all: shrinking the list removes the other assignment.
resp = await async_client.put(
f"/api/v1/inventory/spools/{spool.id}/suppliers",
json=[{"supplier_id": b.id, "is_purchase_source": True}],
)
assert resp.status_code == 200
assert [row["supplier_name"] for row in resp.json()] == ["Supplier B"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_two_purchase_sources_are_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
a = await supplier_factory()
b = await supplier_factory()
spool = await spool_factory()
resp = await async_client.put(
f"/api/v1/inventory/spools/{spool.id}/suppliers",
json=[
{"supplier_id": a.id, "is_purchase_source": True},
{"supplier_id": b.id, "is_purchase_source": True},
],
)
assert resp.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unknown_supplier_is_refused(self, async_client: AsyncClient, spool_factory):
spool = await spool_factory()
resp = await async_client.put(
f"/api/v1/inventory/spools/{spool.id}/suppliers",
json=[{"supplier_id": 999999}],
)
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_duplicate_supplier_is_refused(self, async_client: AsyncClient, supplier_factory, spool_factory):
a = await supplier_factory()
spool = await spool_factory()
resp = await async_client.put(
f"/api/v1/inventory/spools/{spool.id}/suppliers",
json=[{"supplier_id": a.id}, {"supplier_id": a.id}],
)
assert resp.status_code == 400
class TestSpoolmanSupplierAssignments:
"""Spoolman parity (#2988): same endpoints, same shape, twin table."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_replace_all_and_embed(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
):
a = await supplier_factory(name="Supplier A")
resp = await async_client.put(
"/api/v1/spoolman/inventory/spools/7/suppliers",
json=[
{
"supplier_id": a.id,
"supplier_article_number": "A-100",
"quoted_price_per_kg": 19.99,
"is_purchase_source": True,
}
],
)
assert resp.status_code == 200
body = resp.json()
assert body[0]["supplier_name"] == "Supplier A"
assert body[0]["quoted_price_per_kg"] == 19.99
# Same rows via GET, embedded in the single-spool and list responses.
assert (await async_client.get("/api/v1/spoolman/inventory/spools/7/suppliers")).json() == body
single = await async_client.get("/api/v1/spoolman/inventory/spools/7")
assert [row["supplier_name"] for row in single.json()["suppliers"]] == ["Supplier A"]
listing = await async_client.get("/api/v1/spoolman/inventory/spools")
spool_row = next(s for s in listing.json() if s["id"] == 7)
assert [row["supplier_name"] for row in spool_row["suppliers"]] == ["Supplier A"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_validation_matches_builtin_inventory(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
):
a = await supplier_factory()
b = await supplier_factory()
resp = await async_client.put(
"/api/v1/spoolman/inventory/spools/7/suppliers",
json=[{"supplier_id": a.id, "is_purchase_source": True}, {"supplier_id": b.id, "is_purchase_source": True}],
)
assert resp.status_code == 400
resp = await async_client.put(
"/api/v1/spoolman/inventory/spools/7/suppliers",
json=[{"supplier_id": 999999}],
)
assert resp.status_code == 404
class TestSupplierInheritance:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_new_spool_of_same_product_inherits_sources(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
supplier = await supplier_factory(name="Supplier A")
donor = await spool_factory()
db_session.add(
SpoolSupplier(
spool_id=donor.id,
supplier_id=supplier.id,
supplier_article_number="A-100",
quoted_price_per_kg=19.99,
is_purchase_source=True,
)
)
await db_session.commit()
resp = await async_client.post(
"/api/v1/inventory/spools",
json={"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
)
assert resp.status_code == 200
suppliers = resp.json()["suppliers"]
assert [row["supplier_name"] for row in suppliers] == ["Supplier A"]
assert suppliers[0]["supplier_article_number"] == "A-100"
assert suppliers[0]["quoted_price_per_kg"] == 19.99
# Where THIS spool was bought is unknown — never inherited.
assert suppliers[0]["is_purchase_source"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_different_product_inherits_nothing(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
supplier = await supplier_factory()
donor = await spool_factory()
db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id))
await db_session.commit()
resp = await async_client.post(
"/api/v1/inventory/spools",
json={"material": "PETG", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
)
assert resp.status_code == 200
assert resp.json()["suppliers"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_create_gives_every_copy_its_own_links(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
"""One donor lookup for the batch, one set of rows per copy."""
supplier = await supplier_factory(name="Supplier A")
donor = await spool_factory()
db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
await db_session.commit()
resp = await async_client.post(
"/api/v1/inventory/spools/bulk",
json={
"spool": {"material": "PLA", "subtype": "Matte", "brand": "Bambu Lab", "color_name": "Charcoal"},
"quantity": 3,
},
)
assert resp.status_code == 200
created = resp.json()
assert len(created) == 3
for row in created:
assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
assert row["suppliers"][0]["supplier_article_number"] == "A-100"
assert row["suppliers"][0]["is_purchase_source"] is False
# Own rows, not shared ones.
assert len({row["suppliers"][0]["id"] for row in created}) == 3
class TestSupplierLifecycle:
"""What happens to assignments when the spool they hang on goes away."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleting_a_spool_frees_its_supplier(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
supplier = await supplier_factory()
spool = await spool_factory()
db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id))
await db_session.commit()
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
assert (await async_client.delete(f"/api/v1/inventory/spools/{spool.id}")).status_code == 200
# delete-orphan on Spool.supplier_links takes the assignment with it,
# so the supplier stops being referenced and becomes deletable.
assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleting_a_spoolman_spool_drops_the_twin_rows(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
):
"""Spoolman owns the spool, Bambuddy owns the assignment, and nothing
in the database can cascade it. A leaked row keeps the supplier's
reference count non-zero, so the delete guard would answer 409 for a
spool the user can no longer see (#2988)."""
supplier = await supplier_factory()
assert (
await async_client.put(
"/api/v1/spoolman/inventory/spools/7/suppliers",
json=[{"supplier_id": supplier.id, "is_purchase_source": True}],
)
).status_code == 200
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
assert (await async_client.delete("/api/v1/spoolman/inventory/spools/7")).status_code == 200
rows = await db_session.execute(select(SpoolmanSpoolSupplier))
assert rows.scalars().all() == []
assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_drops_the_twin_rows_too(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
):
supplier = await supplier_factory()
assert (
await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
).status_code == 200
# The row has to be proven present before the delete, or the empty
# assertion below holds whether or not the purge did anything.
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk-delete", json={"ids": [7]})
assert resp.status_code == 200
assert resp.json()["deleted"] == 1
rows = await db_session.execute(select(SpoolmanSpoolSupplier))
assert rows.scalars().all() == []
assert (await async_client.get("/api/v1/inventory/suppliers")).json()[0]["spool_count"] == 0
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_reconciles_a_spool_deleted_in_spoolman_itself(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
):
"""Spoolman is a separate application with its own UI, and Bambuddy
only hears about the deletes it performs itself. A spool removed over
there leaves its assignment behind, and that phantom reference used to
make the supplier permanently undeletable with nothing on any screen
that could show or clear it (#2988)."""
supplier = await supplier_factory()
assert (
await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
).status_code == 200
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
# Spool 7 disappears from Spoolman without Bambuddy doing anything.
mock_spoolman_client.get_all_spools = AsyncMock(return_value=[])
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 200
rows = await db_session.execute(select(SpoolmanSpoolSupplier))
assert rows.scalars().all() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reconcile_keeps_assignments_of_an_archived_spool(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client
):
"""Archiving is a soft delete: the spool is still there and its
assignment has to survive, so the reconcile asks for the archived ones
too and the delete still answers 409."""
supplier = await supplier_factory()
assert (
await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
).status_code == 200
async def _all_spools(allow_archived: bool = False):
return [dict(SAMPLE_SPOOLMAN_SPOOL, archived=True)] if allow_archived else []
mock_spoolman_client.get_all_spools = AsyncMock(side_effect=_all_spools)
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reconcile_keeps_the_rows_when_spoolman_is_unreachable(
self, async_client: AsyncClient, supplier_factory, spoolman_settings, mock_spoolman_client, db_session
):
"""A failed lookup is not evidence that the spool is gone."""
supplier = await supplier_factory()
assert (
await async_client.put("/api/v1/spoolman/inventory/spools/7/suppliers", json=[{"supplier_id": supplier.id}])
).status_code == 200
mock_spoolman_client.get_all_spools = AsyncMock(side_effect=RuntimeError("Cannot reach Spoolman"))
assert (await async_client.delete(f"/api/v1/inventory/suppliers/{supplier.id}")).status_code == 409
rows = await db_session.execute(select(SpoolmanSpoolSupplier))
assert len(rows.scalars().all()) == 1
class TestSupplierStats:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_groups_by_purchase_source(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
a = await supplier_factory(name="Supplier A")
b = await supplier_factory(name="Supplier B")
bought_at_a = await spool_factory(label_weight=1000, weight_used=200)
alt_only = await spool_factory(color_name="Red")
db_session.add_all(
[
SpoolSupplier(spool_id=bought_at_a.id, supplier_id=a.id, is_purchase_source=True),
# Alternative source only — must NOT count toward supplier B.
SpoolSupplier(spool_id=alt_only.id, supplier_id=b.id, is_purchase_source=False),
SpoolUsageHistory(
spool_id=bought_at_a.id, weight_used=150, percent_used=15, status="completed", cost=3.0
),
]
)
await db_session.commit()
resp = await async_client.get("/api/v1/inventory/stats/suppliers")
assert resp.status_code == 200
rows = resp.json()
assert len(rows) == 1
assert rows[0]["supplier_name"] == "Supplier A"
assert rows[0]["spool_count"] == 1
assert rows[0]["remaining_g"] == pytest.approx(800)
assert rows[0]["consumed_g"] == pytest.approx(150)
# Cost comes from the recorded usage history (spool.cost_per_kg based),
# never from quoted_price_per_kg.
assert rows[0]["cost"] == pytest.approx(3.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_usage_half_honours_the_date_window(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
"""The dashboard timeframe scopes consumption and cost; stock is
point-in-time and stays out of the window (#2988)."""
from datetime import datetime, timedelta, timezone
supplier = await supplier_factory(name="Supplier A")
spool = await spool_factory(label_weight=1000, weight_used=300)
now = datetime.now(timezone.utc)
db_session.add_all(
[
SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True),
SpoolUsageHistory(
spool_id=spool.id,
weight_used=100,
percent_used=10,
status="completed",
cost=2.0,
created_at=now - timedelta(days=90),
),
SpoolUsageHistory(
spool_id=spool.id,
weight_used=200,
percent_used=20,
status="completed",
cost=4.0,
created_at=now - timedelta(days=2),
),
]
)
await db_session.commit()
lifetime = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
assert lifetime[0]["consumed_g"] == pytest.approx(300)
assert lifetime[0]["cost"] == pytest.approx(6.0)
date_from = (now - timedelta(days=30)).date().isoformat()
windowed = (await async_client.get(f"/api/v1/inventory/stats/suppliers?date_from={date_from}")).json()
assert windowed[0]["consumed_g"] == pytest.approx(200)
assert windowed[0]["cost"] == pytest.approx(4.0)
# Stock is not windowed.
assert windowed[0]["spool_count"] == 1
assert windowed[0]["remaining_g"] == pytest.approx(700)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_over_consumed_spool_counts_as_empty_not_negative(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
"""Each spool is clamped before the sum, as the inventory page shows it:
a full 1000 g spool next to one over-consumed by 200 g is 1000 g in
stock, not 800 g (#2988)."""
supplier = await supplier_factory(name="Supplier A")
full = await spool_factory(label_weight=1000, weight_used=0)
over = await spool_factory(label_weight=1000, weight_used=1200, color_name="Red")
db_session.add_all(
[
SpoolSupplier(spool_id=full.id, supplier_id=supplier.id, is_purchase_source=True),
SpoolSupplier(spool_id=over.id, supplier_id=supplier.id, is_purchase_source=True),
]
)
await db_session.commit()
rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
assert rows[0]["spool_count"] == 2
assert rows[0]["remaining_g"] == pytest.approx(1000)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_equal_consumption_falls_back_to_the_name(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
"""A narrow date range leaves most rows at 0 g; they read by name
instead of in whatever order the GROUP BY produced (#2988)."""
names = ["zultrat", "Extrudr", "Bambu Store"]
for name in names:
supplier = await supplier_factory(name=name)
spool = await spool_factory(color_name=name)
db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=supplier.id, is_purchase_source=True))
heavy = await supplier_factory(name="Yet Another")
heavy_spool = await spool_factory(color_name="Heavy")
db_session.add_all(
[
SpoolSupplier(spool_id=heavy_spool.id, supplier_id=heavy.id, is_purchase_source=True),
SpoolUsageHistory(spool_id=heavy_spool.id, weight_used=50, percent_used=5, status="completed"),
]
)
await db_session.commit()
rows = (await async_client.get("/api/v1/inventory/stats/suppliers")).json()
assert [r["supplier_name"] for r in rows] == ["Yet Another", "Bambu Store", "Extrudr", "zultrat"]
class TestSupplierCsv:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_export_carries_both_columns(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
a = await supplier_factory(name="Supplier A")
b = await supplier_factory(name="Supplier B")
spool = await spool_factory()
db_session.add_all(
[
SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True),
SpoolSupplier(spool_id=spool.id, supplier_id=b.id),
]
)
await db_session.commit()
export = await async_client.get("/api/v1/inventory/spools/export")
assert export.status_code == 200
header, row = export.text.splitlines()[:2]
columns = header.split(",")
assert "suppliers" in columns
assert "purchase_supplier" in columns
assert "Supplier A; Supplier B" in row
assert row.split(",")[columns.index("purchase_supplier")].strip('"') == "Supplier A"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_import_matches_by_name_and_creates_nothing(
self, async_client: AsyncClient, supplier_factory, db_session: AsyncSession
):
await supplier_factory(name="Supplier A")
await supplier_factory(name="Supplier B")
csv_text = (
"material,brand,color_name,suppliers,purchase_supplier\n"
'PLA,Bambu Lab,Charcoal," supplier a ; Supplier B",Supplier B\n'
"PETG,Bambu Lab,Red,Unknown Corp,\n"
)
# Dry run: the unknown name is a warning, not a row error.
preview = await async_client.post(
"/api/v1/inventory/spools/import?dry_run=true",
files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
)
assert preview.status_code == 200
body = preview.json()
assert body["valid_count"] == 2
assert body["error_count"] == 0
assert any("Unknown Corp" in w for w in body["warnings"])
# Real import: both rows land; assignments match by name, trimmed and
# case-insensitive; the unknown name is dropped and NOT created.
result = await async_client.post(
"/api/v1/inventory/spools/import",
files={"file": ("spools.csv", csv_text.encode(), "text/csv")},
)
assert result.status_code == 200
assert result.json()["created"] == 2
listing = await async_client.get("/api/v1/inventory/spools")
by_color = {s["color_name"]: s for s in listing.json()}
charcoal = by_color["Charcoal"]["suppliers"]
assert {row["supplier_name"] for row in charcoal} == {"Supplier A", "Supplier B"}
assert [row["supplier_name"] for row in charcoal if row["is_purchase_source"]] == ["Supplier B"]
assert by_color["Red"]["suppliers"] == []
suppliers = (await async_client.get("/api/v1/inventory/suppliers")).json()
assert {s["name"] for s in suppliers} == {"Supplier A", "Supplier B"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_round_trip_preserves_assignments(
self, async_client: AsyncClient, supplier_factory, spool_factory, db_session: AsyncSession
):
a = await supplier_factory(name="Supplier A")
spool = await spool_factory()
db_session.add(SpoolSupplier(spool_id=spool.id, supplier_id=a.id, is_purchase_source=True))
await db_session.commit()
export = await async_client.get("/api/v1/inventory/spools/export")
result = await async_client.post(
"/api/v1/inventory/spools/import",
files={"file": ("spools.csv", export.content, "text/csv")},
)
assert result.status_code == 200
assert result.json()["created"] == 1
listing = (await async_client.get("/api/v1/inventory/spools")).json()
assert len(listing) == 2
for row in listing:
assert [link["supplier_name"] for link in row["suppliers"]] == ["Supplier A"]
assert row["suppliers"][0]["is_purchase_source"] is True
class TestFromSlotInheritance:
"""The RFID "+ Add to inventory" path (#2988).
POST /spools/from-slot builds the spool through create_spool_from_tray,
which pre-initialises spool.supplier_links to []. The inheritance rows are
added afterwards, so the closing query has to repopulate the collection —
otherwise the identity-mapped instance answers with the stale empty list
and the caller sees no suppliers until the next fetch.
"""
@staticmethod
def _status_for_tray(ams_id: int, tray_id: int, tray: dict):
status = MagicMock()
status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
return status
@pytest.mark.asyncio
@pytest.mark.integration
async def test_from_slot_response_carries_inherited_suppliers(
self,
async_client: AsyncClient,
printer_factory,
supplier_factory,
spool_factory,
db_session: AsyncSession,
):
printer = await printer_factory(name="X1C-supplier-inherit")
supplier = await supplier_factory(name="Supplier A")
donor = await spool_factory(material="PLA", subtype=None, brand="Bambu Lab", color_name="Clear")
db_session.add(SpoolSupplier(spool_id=donor.id, supplier_id=supplier.id, supplier_article_number="A-100"))
await db_session.commit()
# alpha=00 → create_spool_from_tray names the colour "Clear", matching
# the donor product without needing a colour-catalogue row.
tray = {
"tray_type": "PLA",
"tray_color": "11223300",
"tag_uid": "1122334455667788",
"tray_uuid": "0123456789ABCDEF0123456789ABCDEF",
}
with patch(
"backend.app.services.printer_manager.printer_manager.get_status",
return_value=self._status_for_tray(0, 1, tray),
):
resp = await async_client.post(
"/api/v1/inventory/spools/from-slot",
json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert [row["supplier_name"] for row in body["suppliers"]] == ["Supplier A"]
assert body["suppliers"][0]["supplier_article_number"] == "A-100"
assert body["suppliers"][0]["is_purchase_source"] is False
# The row was always written — the defect was the response reading a
# stale collection off the identity-mapped instance.
rows = await db_session.execute(select(SpoolSupplier).where(SpoolSupplier.spool_id == body["id"]))
assert [row.supplier_id for row in rows.scalars().all()] == [supplier.id]
+45 -27
View File
@@ -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"
@@ -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")]
@@ -47,6 +47,11 @@ vi.mock('../../api/client', () => ({
getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
unassignSpool: vi.fn().mockResolvedValue({}),
unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
// Suppliers (#2988) — the SupplierSection inside the form loads these.
getSuppliers: vi.fn().mockResolvedValue([]),
createSupplier: vi.fn().mockResolvedValue({ id: 1, name: 'S' }),
setSpoolSuppliers: vi.fn().mockResolvedValue([]),
setSpoolmanSpoolSuppliers: vi.fn().mockResolvedValue([]),
},
ApiError: class ApiError extends Error {
status: number;
@@ -1367,6 +1372,105 @@ describe('SpoolFormModal copy mode', () => {
const [payload] = vi.mocked(api.createSpool).mock.calls[0];
expect((payload as Record<string, unknown>).weight_used).toBe(0);
});
// The dialog seeds the supplier chips from the spool being copied and shows
// them, so the copy has to actually get them (#2988). The backend's
// inheritance is not a stand-in: it keys on the (material, subtype, brand,
// color_name) tuple, so it resolves to the NEWEST spool of that product
// rather than the one on screen, and in Spoolman mode it never runs at all.
const spoolWithSuppliers: InventorySpool = {
...existingSpool,
suppliers: [
{
id: 11,
supplier_id: 4,
supplier_name: 'Extrudr',
supplier_article_number: 'EX-42',
quoted_price_per_kg: 21.5,
is_purchase_source: true,
},
],
};
async function clickCopy() {
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
});
const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
expect(saveBtn).toBeTruthy();
fireEvent.click(saveBtn!);
}
it('saves the supplier assignments it displays when copying', async () => {
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithSuppliers}
mode="copy"
currencySymbol="$"
/>
);
await clickCopy();
await waitFor(() => {
expect(api.setSpoolSuppliers).toHaveBeenCalledTimes(1);
});
// createSpool is mocked to answer id 99.
expect(vi.mocked(api.setSpoolSuppliers).mock.calls[0]).toEqual([
99,
[
{
supplier_id: 4,
supplier_article_number: 'EX-42',
quoted_price_per_kg: 21.5,
// Where a copy was bought is unknown — only the source list carries over.
is_purchase_source: false,
},
],
]);
});
it('saves them through the Spoolman endpoint in Spoolman mode', async () => {
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithSuppliers}
mode="copy"
currencySymbol="$"
spoolmanMode={true}
/>
);
await clickCopy();
await waitFor(() => {
expect(api.setSpoolmanSpoolSuppliers).toHaveBeenCalledTimes(1);
});
// createSpoolmanInventorySpool is mocked to answer id 88.
expect(vi.mocked(api.setSpoolmanSpoolSuppliers).mock.calls[0][0]).toBe(88);
expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
});
it('leaves an untouched create to the backend inheritance', async () => {
render(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
});
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
fireEvent.click(submitButton!);
await waitFor(() => {
expect(api.createSpool).toHaveBeenCalledTimes(1);
});
// An empty replace-all here would wipe what the backend just inherited.
expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
});
});
// The "#<id>" affordance in the modal header (#1385) is only meaningful when
@@ -0,0 +1,154 @@
/**
* Tests for the spool dialog's supplier multi-select (#2988).
*/
import { useState } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SupplierSection, type SupplierLinkDraft } from '../../components/spool-form/SupplierSection';
import { api } from '../../api/client';
import { inventorySuppliersQueryKey } from '../../utils/inventoryQueries';
const mockShowToast = vi.fn();
vi.mock('../../api/client', () => ({
api: {
getSuppliers: vi.fn(),
createSupplier: vi.fn(),
},
}));
vi.mock('../../contexts/ToastContext', () => ({
useToast: () => ({ showToast: mockShowToast }),
}));
const suppliers = [
{ id: 1, name: 'Filament24', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '' },
{ id: 2, name: 'PrintStore', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '' },
];
function Harness({ initial = [] as SupplierLinkDraft[], onChange = (_: SupplierLinkDraft[]) => {} }) {
const [links, setLinks] = useState<SupplierLinkDraft[]>(initial);
return (
<SupplierSection
links={links}
onChange={(next) => {
setLinks(next);
onChange(next);
}}
currencySymbol="€"
/>
);
}
// The supplier list is read through react-query under the shared
// inventory-suppliers key, so every render needs a client (#2988).
function renderSection(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return { ...render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>), client };
}
describe('SupplierSection', () => {
beforeEach(() => {
vi.clearAllMocks();
(api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue(suppliers);
});
it('adds a supplier from the dropdown as a chip', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderSection(<Harness onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /Add supplier/ }));
await user.click(await screen.findByRole('button', { name: 'Filament24' }));
expect(screen.getByText('Filament24')).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ supplier_id: 1, supplier_name: 'Filament24', is_purchase_source: false }),
]);
});
it('creates a new supplier inline without leaving the dialog', async () => {
(api.createSupplier as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 3, name: 'NewShop', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '',
});
const user = userEvent.setup();
renderSection(<Harness />);
await user.click(screen.getByRole('button', { name: /Add supplier/ }));
await user.type(await screen.findByPlaceholderText(/Search suppliers/), 'NewShop');
await user.click(screen.getByRole('button', { name: /Create "NewShop"/ }));
await waitFor(() => {
expect(api.createSupplier).toHaveBeenCalledWith({ name: 'NewShop' });
});
expect(screen.getByText('NewShop')).toBeInTheDocument();
});
it('says the load failed instead of offering an empty list', async () => {
// Silently rendering nothing invites the user to create a supplier that
// already exists.
(api.getSuppliers as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
const user = userEvent.setup();
renderSection(<Harness />);
await user.click(screen.getByRole('button', { name: /Add supplier/ }));
expect(await screen.findByText(/Failed to load suppliers/i)).toBeInTheDocument();
});
it('re-reads the list when the shared key is invalidated', async () => {
const user = userEvent.setup();
const { client } = renderSection(<Harness />);
await user.click(screen.getByRole('button', { name: /Add supplier/ }));
await screen.findByRole('button', { name: 'Filament24' });
(api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue([
...suppliers,
{ id: 9, name: 'Elsewhere', website: null, customer_number: null, note: null, spool_count: 0, created_at: '', updated_at: '' },
]);
await client.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
expect(await screen.findByRole('button', { name: 'Elsewhere' })).toBeInTheDocument();
});
it('keeps at most one purchase source across assignments', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderSection(
<Harness
initial={[
{ supplier_id: 1, supplier_name: 'Filament24', supplier_article_number: '', quoted_price_per_kg: null, is_purchase_source: true },
{ supplier_id: 2, supplier_name: 'PrintStore', supplier_article_number: '', quoted_price_per_kg: null, is_purchase_source: false },
]}
onChange={onChange}
/>,
);
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]);
const last = onChange.mock.calls.at(-1)?.[0] as SupplierLinkDraft[];
expect(last.find((l) => l.supplier_id === 2)?.is_purchase_source).toBe(true);
// The previous purchase source was demoted automatically.
expect(last.find((l) => l.supplier_id === 1)?.is_purchase_source).toBe(false);
});
it('removes an assignment via the chip', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderSection(
<Harness
initial={[
{ supplier_id: 1, supplier_name: 'Filament24', supplier_article_number: '', quoted_price_per_kg: null, is_purchase_source: false },
]}
onChange={onChange}
/>,
);
await user.click(screen.getByRole('button', { name: /Remove/i }));
expect(onChange).toHaveBeenLastCalledWith([]);
expect(screen.queryByText('Filament24')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
/**
* Tests for the "By Supplier" dashboard widget (#2988).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SupplierStats } from '../../components/SupplierStats';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: { getSupplierStats: vi.fn() },
}));
const rows = [
{ supplier_id: 1, supplier_name: 'Filament24', spool_count: 2, remaining_g: 1400, consumed_g: 600, cost: 12.5 },
];
function renderWidget(props: { dateFrom?: string; dateTo?: string } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<SupplierStats currency="€" {...props} />
</QueryClientProvider>,
);
}
describe('SupplierStats', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getSupplierStats).mockResolvedValue(rows);
});
it('passes the dashboard timeframe to the endpoint', async () => {
renderWidget({ dateFrom: '2026-08-01', dateTo: '2026-08-31' });
expect(await screen.findByText('Filament24')).toBeInTheDocument();
expect(api.getSupplierStats).toHaveBeenCalledWith('2026-08-01', '2026-08-31');
});
it('refetches when the timeframe changes', async () => {
const { rerender } = renderWidget({ dateFrom: '2026-08-01' });
await screen.findByText('Filament24');
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={client}>
<SupplierStats currency="€" dateFrom="2026-01-01" />
</QueryClientProvider>,
);
await screen.findByText('Filament24');
// The range is part of the query key, so a new window is a new read.
expect(vi.mocked(api.getSupplierStats).mock.calls).toContainEqual(['2026-01-01', undefined]);
});
it('reports a failed request as an error, not as "no suppliers yet"', async () => {
// A 403 from a missing inventory:read or a 500 used to render the empty
// state, telling the user they had not assigned suppliers they had.
vi.mocked(api.getSupplierStats).mockRejectedValue(new Error('boom'));
renderWidget();
expect(await screen.findByText(/Could not load supplier statistics/i)).toBeInTheDocument();
expect(screen.queryByText(/No purchases recorded yet/i)).not.toBeInTheDocument();
});
it('still shows the empty state when the inventory really is empty', async () => {
vi.mocked(api.getSupplierStats).mockResolvedValue([]);
renderWidget();
expect(await screen.findByText(/No purchases recorded yet/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,163 @@
/**
* Tests for the SuppliersModal (#2988) — the supplier master list opened from the Inventory toolbar.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SuppliersModal } from '../../components/SuppliersModal';
import { api, ApiError } from '../../api/client';
import { inventorySuppliersQueryKey } from '../../utils/inventoryQueries';
const mockShowToast = vi.fn();
vi.mock('../../api/client', async (importOriginal) => {
const original = await importOriginal<typeof import('../../api/client')>();
return {
ApiError: original.ApiError,
api: {
getSuppliers: vi.fn(),
createSupplier: vi.fn(),
updateSupplier: vi.fn(),
deleteSupplier: vi.fn(),
},
};
});
vi.mock('../../contexts/ToastContext', () => ({
useToast: () => ({ showToast: mockShowToast }),
}));
const suppliers = [
{
id: 1,
name: 'Filament24',
website: 'https://filament24.example',
customer_number: 'C-1042',
note: null,
spool_count: 3,
created_at: '2026-01-01',
updated_at: '2026-01-01',
},
{
id: 2,
name: 'PrintStore',
website: null,
customer_number: null,
note: 'B2B only',
spool_count: 0,
created_at: '2026-01-01',
updated_at: '2026-01-01',
},
];
// The list lives in react-query under the shared inventory-suppliers key, so
// the modal needs a client the same way LocationsModal's tests give it one.
function renderModal(open = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const result = render(
<QueryClientProvider client={client}>
<SuppliersModal open={open} onClose={() => {}} />
</QueryClientProvider>,
);
return { ...result, client };
}
describe('SuppliersModal', () => {
beforeEach(() => {
vi.clearAllMocks();
(api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue(suppliers);
});
it('lists suppliers with their spool usage counts', async () => {
renderModal();
expect(await screen.findByText('Filament24')).toBeInTheDocument();
expect(screen.getByText('PrintStore')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('C-1042')).toBeInTheDocument();
});
it('creates a supplier through the add form and re-reads the list', async () => {
const newShop = {
id: 3,
name: 'NewShop',
website: null,
customer_number: null,
note: null,
spool_count: 0,
created_at: '2026-01-01',
updated_at: '2026-01-01',
};
(api.createSupplier as ReturnType<typeof vi.fn>).mockResolvedValue(newShop);
// The row appears because the shared query is invalidated and refetched,
// not because the component pushed it into a private copy.
(api.getSuppliers as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(suppliers)
.mockResolvedValue([...suppliers, newShop]);
const user = userEvent.setup();
renderModal();
await screen.findByText('Filament24');
await user.click(screen.getByRole('button', { name: /Add/i }));
await user.type(screen.getByPlaceholderText(/Name \(e\.g\./), 'NewShop');
const addButtons = screen.getAllByRole('button', { name: /Add/i });
await user.click(addButtons[addButtons.length - 1]);
await waitFor(() => {
expect(api.createSupplier).toHaveBeenCalledWith({
name: 'NewShop',
website: null,
customer_number: null,
note: null,
});
});
expect(await screen.findByText('NewShop')).toBeInTheDocument();
});
it('picks up a supplier created elsewhere when the shared key is invalidated', async () => {
// What the inventory_changed broadcast does: useWebSocket invalidates
// inventory-suppliers, and every consumer of that key re-reads (#2988).
const { client } = renderModal();
await screen.findByText('Filament24');
expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument();
(api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue([
...suppliers,
{ ...suppliers[1], id: 9, name: 'Elsewhere' },
]);
await client.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
expect(await screen.findByText('Elsewhere')).toBeInTheDocument();
});
it('says the load failed instead of showing an empty list', async () => {
// An empty list reads as "no suppliers yet" and invites the user to
// create a duplicate of one they cannot see.
(api.getSuppliers as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
renderModal();
expect(await screen.findByText(/Failed to load suppliers/i)).toBeInTheDocument();
});
it('surfaces the delete guard when the supplier is still referenced', async () => {
(api.deleteSupplier as ReturnType<typeof vi.fn>).mockRejectedValue(
new ApiError('Supplier is assigned to 3 spool(s)', 409),
);
const user = userEvent.setup();
renderModal();
await screen.findByText('Filament24');
// Open the confirm for the referenced supplier (first row).
const deleteButtons = document.querySelectorAll('button.text-red-500');
await user.click(deleteButtons[0] as HTMLElement);
// The confirm message warns about the existing assignments.
expect(await screen.findByText(/assigned to 3 spool/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Delete/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining('3'), 'error');
});
// Row stays — nothing was deleted.
expect(screen.getByText('Filament24')).toBeInTheDocument();
});
});
@@ -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();
@@ -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);
});
});
@@ -0,0 +1,144 @@
/**
* The "By Supplier" widget on the stats dashboard (#2988).
*
* It aggregates the internal spool table's purchase-source assignments. In
* Spoolman mode those assignments live in the Spoolman twin table instead, so
* the card would say "no suppliers yet" next to an inventory showing supplier
* chips. The widget is dropped in that mode, and held back until the mode is
* known so a Spoolman install never flashes it.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { StatsPage } from '../../pages/StatsPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const EMPTY_STATS = {
total_prints: 0,
successful_prints: 0,
failed_prints: 0,
cancelled_prints: 0,
total_print_time_hours: 0,
total_filament_grams: 0,
total_cost: 0,
prints_by_filament_type: {},
prints_by_printer: {},
average_time_accuracy: 0,
time_accuracy_by_printer: {},
total_energy_kwh: 0,
total_energy_cost: 0,
};
let supplierRequests: URL[] = [];
function setupHandlers(spoolmanEnabled: boolean, spoolmanGate?: Promise<void>) {
server.use(
http.get('/api/v1/archives/stats', () => HttpResponse.json(EMPTY_STATS)),
http.get('/api/v1/printers/', () => HttpResponse.json([])),
http.get('/api/v1/archives/slim', () => HttpResponse.json([])),
http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD' })),
http.get('/api/v1/settings/spoolman', async () => {
// A gate holds the settings response back while the rest of the
// dashboard renders, which is the ordering the widget has to survive.
if (spoolmanGate) await spoolmanGate;
return HttpResponse.json({
spoolman_enabled: spoolmanEnabled ? 'true' : 'false',
spoolman_url: spoolmanEnabled ? 'http://spoolman.local' : '',
});
}),
http.get('/api/v1/archives/analysis/failures', () =>
HttpResponse.json({
period_days: 30,
total_prints: 0,
failed_prints: 0,
failure_rate: 0,
failures_by_reason: {},
failures_by_filament: {},
failures_by_printer: {},
failures_by_hour: {},
recent_failures: [],
trend: [],
})
),
http.get('/api/v1/inventory/stats/suppliers', ({ request }) => {
supplierRequests.push(new URL(request.url));
return HttpResponse.json([]);
})
);
}
function gated() {
let open = () => {};
const gate = new Promise<void>((resolve) => {
open = resolve;
});
return { gate, open: () => open() };
}
describe('StatsPage supplier widget', () => {
beforeEach(() => {
supplierRequests = [];
});
it('shows the widget in internal inventory mode', async () => {
setupHandlers(false);
render(<StatsPage />);
await waitFor(() => {
expect(screen.getByText('By Supplier')).toBeInTheDocument();
});
await waitFor(() => {
expect(supplierRequests.length).toBeGreaterThan(0);
});
});
it('drops the widget entirely in Spoolman mode', async () => {
setupHandlers(true);
render(<StatsPage />);
// Wait for the dashboard to be up before asserting on an absence.
await waitFor(() => {
expect(screen.getByText('Filament Trends')).toBeInTheDocument();
});
expect(screen.queryByText('By Supplier')).toBeNull();
expect(supplierRequests).toHaveLength(0);
});
it('holds the widget back until the Spoolman setting has resolved', async () => {
const { gate, open } = gated();
setupHandlers(true, gate);
render(<StatsPage />);
// The dashboard is fully up on the archive response alone.
await waitFor(() => {
expect(screen.getByText('Filament Trends')).toBeInTheDocument();
});
expect(screen.queryByText('By Supplier')).toBeNull();
expect(supplierRequests).toHaveLength(0);
open();
await waitFor(() => {
expect(screen.queryByText('By Supplier')).toBeNull();
});
expect(supplierRequests).toHaveLength(0);
});
it('shows the widget once the setting says this is not Spoolman mode', async () => {
const { gate, open } = gated();
setupHandlers(false, gate);
render(<StatsPage />);
await waitFor(() => {
expect(screen.getByText('Filament Trends')).toBeInTheDocument();
});
expect(screen.queryByText('By Supplier')).toBeNull();
open();
await waitFor(() => {
expect(screen.getByText('By Supplier')).toBeInTheDocument();
});
});
});
+85
View File
@@ -3661,6 +3661,61 @@ export interface InventorySpool {
k_profiles?: SpoolKProfile[];
storage_location?: string | null;
location_id?: number | null;
// Supplier assignments (#2988). Absent in Spoolman mode — Spoolman's
// vendor is the manufacturer, not the seller, so there is no mapping.
suppliers?: SpoolSupplierLink[];
}
// ── Suppliers (#2988) ──────────────────────────────────────────────────────
/** Where filament is bought — distinct from brand (who made it). */
export interface Supplier {
id: number;
name: string;
website: string | null;
customer_number: string | null;
note: string | null;
/** Spools referencing this supplier; a referenced supplier cannot be deleted. */
spool_count: number;
created_at: string;
updated_at: string;
}
export interface SupplierInput {
name: string;
website?: string | null;
customer_number?: string | null;
note?: string | null;
}
/** One spool-to-supplier assignment as written by the spool dialog. */
export interface SpoolSupplierLinkInput {
supplier_id: number;
/** The supplier's own article number — NOT the internal material number. */
supplier_article_number?: string | null;
/** Quoted price for comparison — never the cost basis (spool.cost_per_kg). */
quoted_price_per_kg?: number | null;
/** Marks where this concrete spool was actually bought. */
is_purchase_source?: boolean;
}
export interface SpoolSupplierLink {
id: number;
supplier_id: number;
supplier_name: string;
supplier_article_number: string | null;
quoted_price_per_kg: number | null;
is_purchase_source: boolean;
}
/** Per-supplier inventory aggregate (#2988), purchase-source spools only. */
export interface SupplierStats {
supplier_id: number;
supplier_name: string;
spool_count: number;
remaining_g: number;
consumed_g: number;
cost: number;
}
export interface SpoolmanBulkCreateResult {
@@ -6662,6 +6717,36 @@ export const api = {
request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
resetSpoolCatalog: () =>
request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
// ── Suppliers (#2988) — inventory master data, Locations pattern ─────────
getSuppliers: () =>
request<Supplier[]>('/inventory/suppliers'),
createSupplier: (data: SupplierInput) =>
request<Supplier>('/inventory/suppliers', { method: 'POST', body: JSON.stringify(data) }),
updateSupplier: (id: number, data: Partial<SupplierInput>) =>
request<Supplier>(`/inventory/suppliers/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteSupplier: (id: number) =>
request<{ status: string }>(`/inventory/suppliers/${id}`, { method: 'DELETE' }),
setSpoolSuppliers: (spoolId: number, links: SpoolSupplierLinkInput[]) =>
request<SpoolSupplierLink[]>(`/inventory/spools/${spoolId}/suppliers`, {
method: 'PUT',
body: JSON.stringify(links),
}),
// Spoolman parity: the assignment rows live Bambuddy-side, keyed by the
// remote spool id — same request/response shape as the built-in inventory.
setSpoolmanSpoolSuppliers: (spoolmanSpoolId: number, links: SpoolSupplierLinkInput[]) =>
request<SpoolSupplierLink[]>(`/spoolman/inventory/spools/${spoolmanSpoolId}/suppliers`, {
method: 'PUT',
body: JSON.stringify(links),
}),
// date_from/date_to scope the usage half only, so the widget can follow the
// dashboard timeframe; stock stays point-in-time.
getSupplierStats: (dateFrom?: string, dateTo?: string) => {
const params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
const qs = params.toString();
return request<SupplierStats[]>(`/inventory/stats/suppliers${qs ? `?${qs}` : ''}`);
},
getLocations: () =>
request<StorageLocation[]>('/inventory/locations'),
createLocation: (data: { name: string; identifier?: string | null }) =>
@@ -20,6 +20,7 @@ import { MATERIALS } from './spool-form/constants';
import { FilamentSection } from './spool-form/FilamentSection';
import { ColorSection } from './spool-form/ColorSection';
import { AdditionalSection } from './spool-form/AdditionalSection';
import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
import { normaliseFlow } from '../utils/nozzleFlow';
@@ -81,6 +82,13 @@ export function SpoolFormModal({
// through core_weight.
const [coreWeightTouched, setCoreWeightTouched] = useState(false);
const [locationIdTouched, setLocationIdTouched] = useState(false);
// Supplier assignments (#2988). Held outside SpoolFormData — they are
// relational and saved through their own replace-all endpoint. An untouched
// create does not send them, so it keeps the backend's inherited
// assignments instead of wiping them with an empty list; a copy always
// sends them, see saveSupplierLinks.
const [supplierLinks, setSupplierLinks] = useState<SupplierLinkDraft[]>([]);
const [supplierLinksTouched, setSupplierLinksTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
@@ -446,10 +454,23 @@ export function SpoolFormModal({
} else {
setSelectedProfiles(new Map());
}
// Supplier assignments (#2988) — copied on copy: they describe where
// the product is bought, which is what a copy shares.
setSupplierLinks(
(spool.suppliers ?? []).map((link) => ({
supplier_id: link.supplier_id,
supplier_name: link.supplier_name,
supplier_article_number: link.supplier_article_number ?? '',
quoted_price_per_kg: link.quoted_price_per_kg,
// Where a COPY was bought is unknown — only a real edit keeps it.
is_purchase_source: isCopying ? false : link.is_purchase_source,
}))
);
} else {
setFormData(defaultFormData);
setPresetInputValue('');
setSelectedProfiles(new Map());
setSupplierLinks([]);
}
// Reset on every open, not just the create path (#1905). The modal keeps
// its state while closed, and the Quick Add toggle only renders in create
@@ -471,6 +492,7 @@ export function SpoolFormModal({
// inheriting. Only Spoolman spools report the flag (#2908).
setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
setLocationIdTouched(false);
setSupplierLinksTouched(false);
}
}, [isOpen, spool, mode, isCopying]);
@@ -582,6 +604,8 @@ export function SpoolFormModal({
if (newSpool?.id) {
const ok = await savePrinterProfiles(newSpool.id);
if (!ok) return;
const suppliersOk = await saveSupplierLinks(newSpool.id);
if (!suppliersOk) return;
}
await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
@@ -625,6 +649,12 @@ export function SpoolFormModal({
await savePrinterProfiles(s.id);
}
}
// Every copy of a bulk add shares the same supplier assignments (#2988).
if (shouldSaveSupplierLinks) {
for (const s of createdSpools) {
await saveSupplierLinks(s.id);
}
}
await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
if (spoolmanResult && spoolmanResult.failed_count > 0) {
@@ -658,6 +688,8 @@ export function SpoolFormModal({
if (spool?.id) {
const ok = await savePrinterProfiles(spool.id);
if (!ok) return;
const suppliersOk = await saveSupplierLinks(spool.id);
if (!suppliersOk) return;
}
await refreshSpoolQueries();
showToast(t('inventory.spoolUpdated'), 'success');
@@ -774,6 +806,41 @@ export function SpoolFormModal({
},
});
// Supplier assignments (#2988): replace-all save.
//
// Skipped on an untouched create so the backend's inheritance can fill the
// new spool in — an empty list would wipe what it just attached. A COPY is
// the opposite case and always saves: the dialog seeded the chips from the
// spool being copied and showed them, so they have to be what the copy
// gets. Inheritance cannot stand in for that — it keys on the (material,
// subtype, brand, color_name) tuple and so resolves to the NEWEST spool of
// the product rather than the one on screen, and in Spoolman mode there is
// no inheritance at all.
const shouldSaveSupplierLinks = supplierLinksTouched || isCopying;
const saveSupplierLinks = async (spoolId: number): Promise<boolean> => {
if (!shouldSaveSupplierLinks) return true;
// Spoolman parity (#2988): the assignment rows live Bambuddy-side either
// way; only the endpoint differs (twin table keyed by the remote id).
const save = spoolmanMode ? api.setSpoolmanSpoolSuppliers : api.setSpoolSuppliers;
try {
await save(
spoolId,
supplierLinks.map((link) => ({
supplier_id: link.supplier_id,
supplier_article_number: link.supplier_article_number.trim() || null,
quoted_price_per_kg: link.quoted_price_per_kg,
is_purchase_source: link.is_purchase_source,
})),
);
return true;
} catch (err) {
console.error('SpoolFormModal.saveSupplierLinks failed:', err);
showToast(t('inventory.suppliers.saveFailed'), 'error');
return false;
}
};
// Save everything the Printers tab holds: one K profile per hotend and the
// per-printer-model preset overrides. Returns false if either write failed,
// which keeps the modal open so the user does not lose what they picked.
@@ -1117,6 +1184,21 @@ export function SpoolFormModal({
/>
</div>
{/* Suppliers (#2988) — both inventories: the assignments live
Bambuddy-side either way (Spoolman's vendor is the
manufacturer, not the seller), so the same section renders
in Spoolman mode and saves to the twin endpoint. */}
<div>
<SupplierSection
links={supplierLinks}
onChange={(next) => {
setSupplierLinks(next);
setSupplierLinksTouched(true);
}}
currencySymbol={currencySymbol}
/>
</div>
{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
{isEditing && spool && !spoolmanMode && (
<div>
+82
View File
@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
// Consumption, cost and stock grouped by the purchase-source supplier
// (#2988) — "how much did we run through supplier X" straight from the
// recorded usage history, archived spools included.
interface SupplierStatsProps {
currency: string;
// The dashboard timeframe, the same one every other widget on that page
// honours. It scopes consumption and cost; stock is point-in-time.
dateFrom?: string;
dateTo?: string;
}
function formatGrams(g: number): string {
if (Math.abs(g) >= 1000) return `${(g / 1000).toFixed(2)} kg`;
return `${Math.round(g)} g`;
}
export function SupplierStats({ currency, dateFrom, dateTo }: SupplierStatsProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ['supplier-stats', dateFrom ?? 'all', dateTo ?? 'all'],
queryFn: () => api.getSupplierStats(dateFrom, dateTo),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
);
}
// A failed request is not an empty inventory: reporting a 403 or a 500 as
// "you have not assigned any" tells the user they have not done something
// they have.
if (isError) {
return (
<p className="flex items-center gap-2 text-sm text-red-400 py-4">
<AlertTriangle className="w-4 h-4" />
{t('stats.suppliers.error')}
</p>
);
}
if (!data || data.length === 0) {
return <p className="text-sm text-bambu-gray py-4">{t('stats.suppliers.empty')}</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
<th className="py-2 pr-4 font-medium">{t('stats.suppliers.supplier')}</th>
<th className="py-2 pr-4 font-medium text-right">{t('stats.suppliers.spools')}</th>
<th className="py-2 pr-4 font-medium text-right">{t('stats.suppliers.remaining')}</th>
<th className="py-2 pr-4 font-medium text-right">{t('stats.suppliers.consumed')}</th>
<th className="py-2 font-medium text-right">{t('stats.suppliers.cost')}</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.supplier_id} className="border-b border-bambu-dark-tertiary/50 last:border-b-0">
<td className="py-2 pr-4 text-white font-medium">{row.supplier_name}</td>
<td className="py-2 pr-4 text-bambu-gray text-right">{row.spool_count}</td>
<td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.remaining_g)}</td>
<td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.consumed_g)}</td>
<td className="py-2 text-bambu-gray text-right">
{currency} {row.cost.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+386
View File
@@ -0,0 +1,386 @@
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Store, Plus, Trash2, Loader2, Pencil, Check, X, Search, ExternalLink, AlertTriangle } from 'lucide-react';
import { api, ApiError } from '../api/client';
import type { Supplier } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { inventorySuppliersQueryKey, invalidateInventorySuppliers } from '../utils/inventoryQueries';
import { ConfirmModal } from './ConfirmModal';
interface SuppliersModalProps {
open: boolean;
onClose: () => void;
}
// Managed supplier master list (#2988) — where filament is bought, distinct
// from brand (who made it). Inventory master data that spools reference, so
// it opens from the Inventory toolbar exactly like Locations, in both the
// built-in and the Spoolman inventory.
export function SuppliersModal({ open, onClose }: SuppliersModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
// The list lives in react-query under the key useWebSocket invalidates on
// inventory_changed, so a supplier created inline in the spool dialog — or
// by another user, in another tab — shows up here (#2988).
const { data: suppliers = [], isLoading: loading, isError } = useQuery({
queryKey: inventorySuppliersQueryKey,
queryFn: api.getSuppliers,
enabled: open,
});
const [search, setSearch] = useState('');
// Add/Edit form state
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formWebsite, setFormWebsite] = useState('');
const [formCustomerNumber, setFormCustomerNumber] = useState('');
const [formNote, setFormNote] = useState('');
const [saving, setSaving] = useState(false);
const [deleteSupplier, setDeleteSupplier] = useState<Supplier | null>(null);
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const filtered = suppliers.filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
const resetForm = () => {
setFormName('');
setFormWebsite('');
setFormCustomerNumber('');
setFormNote('');
};
const formPayload = () => ({
name: formName.trim(),
website: formWebsite.trim() || null,
customer_number: formCustomerNumber.trim() || null,
note: formNote.trim() || null,
});
const handleAdd = async () => {
if (!formName.trim()) {
showToast(t('inventory.suppliers.nameRequired'), 'error');
return;
}
setSaving(true);
try {
await api.createSupplier(formPayload());
await invalidateInventorySuppliers(queryClient);
setShowAddForm(false);
resetForm();
showToast(t('inventory.suppliers.added'), 'success');
} catch (err) {
console.error('SuppliersModal.handleAdd failed:', err);
// 409: the name is taken — say so instead of a generic failure.
showToast(
t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.addFailed'),
'error',
);
} finally {
setSaving(false);
}
};
const startEdit = (supplier: Supplier) => {
setEditingId(supplier.id);
setFormName(supplier.name);
setFormWebsite(supplier.website ?? '');
setFormCustomerNumber(supplier.customer_number ?? '');
setFormNote(supplier.note ?? '');
};
const cancelEdit = () => {
setEditingId(null);
resetForm();
};
const handleUpdate = async (id: number) => {
if (!formName.trim()) {
showToast(t('inventory.suppliers.nameRequired'), 'error');
return;
}
setSaving(true);
try {
await api.updateSupplier(id, formPayload());
await invalidateInventorySuppliers(queryClient);
cancelEdit();
showToast(t('inventory.suppliers.updated'), 'success');
} catch (err) {
console.error('SuppliersModal.handleUpdate failed:', err);
showToast(
t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.updateFailed'),
'error',
);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteSupplier) return;
try {
await api.deleteSupplier(deleteSupplier.id);
await invalidateInventorySuppliers(queryClient);
showToast(t('inventory.suppliers.deleted'), 'success');
} catch (err) {
console.error('SuppliersModal.handleDelete failed:', err);
// 409: still referenced by spools — surface the guard, not a generic error.
if (err instanceof ApiError && err.status === 409) {
showToast(t('inventory.suppliers.deleteBlocked', { count: deleteSupplier.spool_count }), 'error');
} else {
showToast(t('inventory.suppliers.deleteFailed'), 'error');
}
} finally {
setDeleteSupplier(null);
}
};
const formFields = (
<div className="space-y-2">
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.suppliers.namePlaceholder')}
value={formName}
maxLength={200}
onChange={(e) => setFormName(e.target.value)}
/>
<div className="flex gap-2">
<input
type="text"
className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.suppliers.websitePlaceholder')}
value={formWebsite}
maxLength={500}
onChange={(e) => setFormWebsite(e.target.value)}
/>
<input
type="text"
className="w-40 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.suppliers.customerNumberPlaceholder')}
value={formCustomerNumber}
maxLength={100}
onChange={(e) => setFormCustomerNumber(e.target.value)}
/>
</div>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.suppliers.notePlaceholder')}
value={formNote}
maxLength={500}
onChange={(e) => setFormNote(e.target.value)}
/>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className="relative w-full max-w-3xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-labelledby="suppliers-modal-title"
>
<div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Store className="w-5 h-5 text-bambu-gray" />
<h2 id="suppliers-modal-title" className="text-lg font-semibold text-white">
{t('inventory.suppliers.title')}
</h2>
<span className="text-sm text-bambu-gray">({suppliers.length})</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowAddForm(true); setEditingId(null); resetForm(); }}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-4">
<p className="text-sm text-bambu-gray">{t('inventory.suppliers.description')}</p>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.suppliers.search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary space-y-3">
<h3 className="text-sm font-medium text-white">{t('inventory.suppliers.addNew')}</h3>
{formFields}
<div className="flex gap-2 justify-end">
<button
onClick={() => { setShowAddForm(false); resetForm(); }}
className="px-3 py-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={saving}
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
) : isError ? (
// A failed load must not read as "no suppliers yet" — that invites
// the user to create one that already exists.
<div className="flex items-center justify-center gap-2 py-8 text-sm text-red-400">
<AlertTriangle className="w-4 h-4" />
{t('inventory.suppliers.loadFailed')}
</div>
) : (
<div className="border border-bambu-dark-tertiary rounded-lg">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
<th className="px-4 py-2 text-left text-bambu-gray font-medium hidden sm:table-cell">
{t('inventory.suppliers.customerNumber')}
</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-20">
{t('inventory.suppliers.spools')}
</th>
<th className="px-4 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
{search ? t('inventory.suppliers.noMatch') : t('inventory.suppliers.empty')}
</td>
</tr>
) : (
filtered.map((supplier) => (
<tr key={supplier.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark align-top">
{editingId === supplier.id ? (
<>
<td className="px-4 py-2" colSpan={3}>{formFields}</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleUpdate(supplier.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2 text-white">
<div className="flex items-center gap-1.5">
<span>{supplier.name}</span>
{supplier.website && (
<a
href={supplier.website}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-gray hover:text-bambu-green"
title={supplier.website}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
{supplier.note && <div className="text-xs text-bambu-gray mt-0.5">{supplier.note}</div>}
</td>
<td className="px-4 py-2 text-bambu-gray hidden sm:table-cell">
{supplier.customer_number || '-'}
</td>
<td className="px-4 py-2 text-right font-mono text-bambu-gray">{supplier.spool_count}</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => { startEdit(supplier); setShowAddForm(false); }}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteSupplier(supplier)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div>
{deleteSupplier && (
<ConfirmModal
title={t('inventory.suppliers.deleteTitle')}
message={
deleteSupplier.spool_count > 0
? t('inventory.suppliers.deleteConfirmReferenced', {
name: deleteSupplier.name,
count: deleteSupplier.spool_count,
})
: t('inventory.suppliers.deleteConfirm', { name: deleteSupplier.name })
}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteSupplier(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,255 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ChevronDown, Loader2, Plus, Store, X } from 'lucide-react';
import { api, ApiError } from '../../api/client';
import type { Supplier } from '../../api/client';
import { useToast } from '../../contexts/ToastContext';
import { inventorySuppliersQueryKey, invalidateInventorySuppliers } from '../../utils/inventoryQueries';
// One editable supplier assignment as the form holds it (#2988). Mirrors
// SpoolSupplierLinkInput but keeps the name so chips render without lookups.
export interface SupplierLinkDraft {
supplier_id: number;
supplier_name: string;
supplier_article_number: string;
/** Quoted price for comparison — never the cost basis (spool.cost_per_kg). */
quoted_price_per_kg: number | null;
is_purchase_source: boolean;
}
interface SupplierSectionProps {
links: SupplierLinkDraft[];
onChange: (links: SupplierLinkDraft[]) => void;
currencySymbol: string;
}
// Multi-select with chips for the spool dialog (#2988): a material can come
// from several suppliers. Each chip carries the per-assignment fields
// (supplier's article number, price there, bought-here marker) and "+ new
// supplier" creates a master-list entry without leaving the dialog.
export function SupplierSection({ links, onChange, currencySymbol }: SupplierSectionProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
// Shared key with SuppliersModal, invalidated by the inventory_changed
// broadcast — a supplier created in either place shows up in both (#2988).
const { data: suppliers = [], isLoading, isError } = useQuery({
queryKey: inventorySuppliersQueryKey,
queryFn: api.getSuppliers,
});
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch('');
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [open]);
const linkedIds = useMemo(() => new Set(links.map((l) => l.supplier_id)), [links]);
const candidates = useMemo(
() =>
suppliers
.filter((s) => !linkedIds.has(s.id))
.filter((s) => s.name.toLowerCase().includes(search.trim().toLowerCase())),
[suppliers, linkedIds, search]
);
const exactMatch = suppliers.some((s) => s.name.toLowerCase() === search.trim().toLowerCase());
const addLink = (supplier: Supplier) => {
onChange([
...links,
{
supplier_id: supplier.id,
supplier_name: supplier.name,
supplier_article_number: '',
quoted_price_per_kg: null,
is_purchase_source: false,
},
]);
setOpen(false);
setSearch('');
};
const createAndAdd = async () => {
const name = search.trim();
if (!name) return;
setCreating(true);
try {
const supplier = await api.createSupplier({ name });
await invalidateInventorySuppliers(queryClient);
addLink(supplier);
} catch (err) {
console.error('SupplierSection.createAndAdd failed:', err);
showToast(
t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.createFailed'),
'error',
);
} finally {
setCreating(false);
}
};
const updateLink = (supplierId: number, patch: Partial<SupplierLinkDraft>) => {
onChange(
links.map((link) => {
if (link.supplier_id !== supplierId) {
// Only one assignment can be the purchase source.
return patch.is_purchase_source ? { ...link, is_purchase_source: false } : link;
}
return { ...link, ...patch };
})
);
};
const removeLink = (supplierId: number) => {
onChange(links.filter((link) => link.supplier_id !== supplierId));
};
return (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.suppliers.label')}</label>
{links.length > 0 && (
<div className="space-y-2 mb-2">
{links.map((link) => (
<div key={link.supplier_id} className="p-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/10 text-bambu-green">
<Store className="w-3 h-3" />
{link.supplier_name}
</span>
<label
className="ml-auto flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer"
title={t('inventory.suppliers.purchaseSourceHelp')}
>
<input
type="checkbox"
className="w-3.5 h-3.5 accent-bambu-green"
checked={link.is_purchase_source}
onChange={(e) => updateLink(link.supplier_id, { is_purchase_source: e.target.checked })}
/>
{t('inventory.suppliers.purchaseSource')}
</label>
<button
type="button"
onClick={() => removeLink(link.supplier_id)}
className="p-1 rounded text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
aria-label={t('common.remove')}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex gap-2 mt-2">
<input
type="text"
className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.suppliers.articleNumberPlaceholder')}
value={link.supplier_article_number}
maxLength={100}
onChange={(e) => updateLink(link.supplier_id, { supplier_article_number: e.target.value })}
/>
<div className="relative w-28">
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray pointer-events-none">
{currencySymbol}
</span>
<input
type="number"
className="w-full pl-6 pr-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs text-right placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.suppliers.pricePlaceholder')}
title={t('inventory.suppliers.priceHelp')}
min={0}
step={0.01}
value={link.quoted_price_per_kg ?? ''}
onChange={(e) =>
updateLink(link.supplier_id, {
quoted_price_per_kg: e.target.value === '' ? null : parseFloat(e.target.value),
})
}
/>
</div>
</div>
</div>
))}
</div>
)}
<div className="relative" ref={containerRef}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-bambu-gray hover:text-white flex items-center justify-between transition-colors focus:outline-none focus:border-bambu-green"
>
<span>{t('inventory.suppliers.addSupplier')}</span>
<ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg">
<div className="p-2 border-b border-bambu-dark-tertiary">
<input
type="text"
autoFocus
className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.suppliers.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="max-h-48 overflow-y-auto py-1">
{isLoading ? (
<div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.loading')}
</div>
) : isError ? (
// An empty list here would invite the user to create a
// supplier that already exists, so say the load failed.
<div className="px-3 py-2 text-sm text-red-400 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('inventory.suppliers.loadFailed')}
</div>
) : (
<>
{candidates.map((supplier) => (
<button
key={supplier.id}
type="button"
onClick={() => addLink(supplier)}
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark-tertiary"
>
{supplier.name}
</button>
))}
{candidates.length === 0 && !search.trim() && (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.suppliers.noneLeft')}</div>
)}
{search.trim() && !exactMatch && (
<button
type="button"
onClick={createAndAdd}
disabled={creating}
className="w-full px-3 py-2 text-left text-sm text-bambu-green hover:bg-bambu-dark-tertiary flex items-center gap-1.5"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
{t('inventory.suppliers.createNew', { name: search.trim() })}
</button>
)}
</>
)}
</div>
</div>
)}
</div>
<p className="text-xs text-bambu-gray mt-1">{t('inventory.suppliers.help')}</p>
</div>
);
}
+2 -1
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import { api, ApiError } from '../api/client';
import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
import { inventoryLocationsQueryKey, inventorySuppliersQueryKey } from '../utils/inventoryQueries';
// The only auth-failure close code /api/v1/ws emits (websocket.py
// _WS_CLOSE_UNAUTHORIZED). A 4401 means the ws-token was missing / invalid /
@@ -408,6 +408,7 @@ export function useWebSocket() {
debouncedInvalidate('inventory-spools');
debouncedInvalidate('spoolman-inventory-spools');
debouncedInvalidate(inventoryLocationsQueryKey[0]);
debouncedInvalidate(inventorySuppliersQueryKey[0]);
break;
case 'spool_assignment_changed':
+53
View File
@@ -1670,6 +1670,17 @@ export default {
printActivity: 'Druckaktivität',
filamentTypes: 'Filamenttypen',
filamentTrends: 'Filamenttrends',
// Consumption/cost grouped by purchase-source supplier (#2988)
suppliers: {
title: 'Nach Lieferant',
empty: 'Noch keine Käufe erfasst. Markieren Sie an einer Spule, bei welchem Lieferanten sie gekauft wurde, um Verbrauch und Kosten hier zu gruppieren.',
error: 'Lieferantenstatistik konnte nicht geladen werden',
supplier: 'Lieferant',
spools: 'Spulen',
remaining: 'Verbleibend',
consumed: 'Verbraucht',
cost: 'Kosten',
},
failureAnalysis: 'Fehleranalyse',
timeAccuracy: 'Zeitgenauigkeit',
successful: 'Erfolgreich:',
@@ -4814,6 +4825,48 @@ export default {
storageLocationNone: 'Kein Lagerort',
lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Lieferanten',
none: 'Kein Lieferant',
addSupplier: 'Lieferant hinzufügen…',
searchPlaceholder: 'Lieferanten durchsuchen…',
noneLeft: 'Alle Lieferanten sind bereits zugewiesen',
createNew: '"{{name}}" anlegen',
createFailed: 'Lieferant konnte nicht angelegt werden',
purchaseSource: 'Hier gekauft',
purchaseSourceHelp: 'Markiert, wo diese Spule tatsächlich gekauft wurde; die übrigen Einträge sind alternative Bezugsquellen.',
articleNumberPlaceholder: 'Artikelnr. des Lieferanten',
pricePlaceholder: 'Angebotspreis/kg',
priceHelp: 'Angebotspreis bei diesem Lieferanten, zum Vergleich der Bezugsquellen — der eigene Preis pro kg der Spule bleibt die Kostenbasis.',
help: 'Wo dieses Produkt gekauft werden kann. Der hervorgehobene Eintrag ist die Bezugsquelle dieser Spule.',
saveFailed: 'Lieferantenzuordnungen konnten nicht gespeichert werden',
title: 'Lieferanten',
description: 'Verwalten Sie, wo Sie Filament kaufen — nicht zu verwechseln mit der Marke, also dem Hersteller. Weisen Sie Spulen im Spulendialog Lieferanten zu; ein Lieferant, der noch Spulen zugewiesen ist, kann nicht gelöscht werden.',
search: 'Lieferanten durchsuchen...',
addNew: 'Neuen Lieferanten hinzufügen',
namePlaceholder: 'Name (z. B. Filament24)',
websitePlaceholder: 'Website / Shop-URL',
customerNumberPlaceholder: 'Kundennr.',
notePlaceholder: 'Notiz',
nameRequired: 'Name ist erforderlich',
duplicateName: 'Ein Lieferant mit diesem Namen existiert bereits',
customerNumber: 'Kundennr.',
spools: 'Spulen',
noMatch: 'Keine Lieferanten entsprechen Ihrer Suche',
empty: 'Noch keine Lieferanten',
added: 'Lieferant hinzugefügt',
addFailed: 'Lieferant konnte nicht hinzugefügt werden',
updated: 'Lieferant aktualisiert',
updateFailed: 'Lieferant konnte nicht aktualisiert werden',
deleted: 'Lieferant gelöscht',
deleteFailed: 'Lieferant konnte nicht gelöscht werden',
deleteBlocked: 'Löschen nicht möglich: noch {{count}} Spule(n) zugewiesen',
loadFailed: 'Lieferanten konnten nicht geladen werden',
deleteTitle: 'Lieferant löschen',
deleteConfirm: '"{{name}}" löschen?',
deleteConfirmReferenced: '"{{name}}" ist {{count}} Spule(n) zugewiesen. Das Löschen ist blockiert, bis diese Zuordnungen entfernt wurden.',
},
// RFID button rename (was "Tag löschen")
clearRfid: 'RFID-Tag löschen',
rfidCleared: 'RFID-Tag gelöscht',
+53
View File
@@ -1687,6 +1687,17 @@ export default {
printActivity: 'Print Activity',
filamentTypes: 'Filament Types',
filamentTrends: 'Filament Trends',
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'By Supplier',
empty: 'No purchases recorded yet. Mark on a spool which supplier it was bought from to group consumption and costs here.',
error: 'Could not load supplier statistics',
supplier: 'Supplier',
spools: 'Spools',
remaining: 'Remaining',
consumed: 'Consumed',
cost: 'Cost',
},
failureAnalysis: 'Failure Analysis',
timeAccuracy: 'Time Accuracy',
successful: 'Successful:',
@@ -4854,6 +4865,48 @@ export default {
storageLocationNone: 'No location set',
lowStockThresholdOverride: 'Low-stock threshold (this spool)',
lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Suppliers',
none: 'No supplier',
addSupplier: 'Add supplier…',
searchPlaceholder: 'Search suppliers…',
noneLeft: 'All suppliers are already assigned',
createNew: 'Create "{{name}}"',
createFailed: 'Failed to create supplier',
purchaseSource: 'Bought here',
purchaseSourceHelp: 'Marks where this spool was actually purchased; the other entries are alternative sources.',
articleNumberPlaceholder: 'Supplier article no.',
pricePlaceholder: 'Quoted price/kg',
priceHelp: 'Quoted price at this supplier, for comparing sources — the spool\'s own cost per kg stays the cost basis.',
help: 'Where this product can be bought. The highlighted entry is where this spool was purchased.',
saveFailed: 'Failed to save supplier assignments',
title: 'Suppliers',
description: 'Manage where you buy filament — distinct from brand, which is who made it. Assign suppliers to spools in the spool dialog; a supplier that is still assigned to spools cannot be deleted.',
search: 'Search suppliers...',
addNew: 'Add new supplier',
namePlaceholder: 'Name (e.g. Filament24)',
websitePlaceholder: 'Website / shop URL',
customerNumberPlaceholder: 'Customer no.',
notePlaceholder: 'Note',
nameRequired: 'Name is required',
duplicateName: 'A supplier with this name already exists',
customerNumber: 'Customer no.',
spools: 'Spools',
noMatch: 'No suppliers match your search',
empty: 'No suppliers yet',
added: 'Supplier added',
addFailed: 'Failed to add supplier',
updated: 'Supplier updated',
updateFailed: 'Failed to update supplier',
deleted: 'Supplier deleted',
deleteFailed: 'Failed to delete supplier',
deleteBlocked: 'Cannot delete: still assigned to {{count}} spool(s)',
loadFailed: 'Failed to load suppliers',
deleteTitle: 'Delete Supplier',
deleteConfirm: 'Delete "{{name}}"?',
deleteConfirmReferenced: '"{{name}}" is assigned to {{count}} spool(s). Deleting is blocked until those assignments are removed.',
},
// RFID button rename (was "Delete Tag" — confusing because it sounds like a
// taxonomy delete; this clears the RFID tag/UUID off the spool record)
clearRfid: 'Clear RFID Tag',
+53
View File
@@ -1670,6 +1670,17 @@ export default {
printActivity: 'Actividad de impresión',
filamentTypes: 'Tipos de filamento',
filamentTrends: 'Tendencias del filamento',
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'Por proveedor',
empty: 'Aún no hay compras registradas. Marque en una bobina a qué proveedor se compró para agrupar aquí el consumo y los costes.',
error: 'No se pudieron cargar las estadísticas de proveedores',
supplier: 'Proveedor',
spools: 'Bobinas',
remaining: 'Restante',
consumed: 'Consumido',
cost: 'Coste',
},
failureAnalysis: 'Análisis de fallos',
timeAccuracy: 'Precisión temporal',
successful: 'Con éxito:',
@@ -4817,6 +4828,48 @@ export default {
storageLocationNone: 'Sin ubicación establecida',
lowStockThresholdOverride: 'Umbral de existencias bajas (esta bobina)',
lowStockThresholdOverrideHelp: 'Déjelo en blanco para usar el umbral global ({{global}}%).',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Proveedores',
none: 'Sin proveedor',
addSupplier: 'Añadir proveedor…',
searchPlaceholder: 'Buscar proveedores…',
noneLeft: 'Todos los proveedores ya están asignados',
createNew: 'Crear "{{name}}"',
createFailed: 'Error al crear el proveedor',
purchaseSource: 'Comprado aquí',
purchaseSourceHelp: 'Indica dónde se compró realmente esta bobina; las demás entradas son fuentes alternativas.',
articleNumberPlaceholder: 'N.º de artículo del proveedor',
pricePlaceholder: 'Precio ofertado/kg',
priceHelp: 'Precio ofertado por este proveedor, para comparar fuentes; el coste por kg de la propia bobina sigue siendo la base de coste.',
help: 'Dónde se puede comprar este producto. La entrada resaltada es donde se compró esta bobina.',
saveFailed: 'Error al guardar las asignaciones de proveedores',
title: 'Proveedores',
description: 'Gestione dónde compra el filamento, algo distinto de la marca, que indica quién lo fabricó. Asigne proveedores a las bobinas en el diálogo de bobina; un proveedor que siga asignado a bobinas no se puede eliminar.',
search: 'Buscar proveedores...',
addNew: 'Añadir nuevo proveedor',
namePlaceholder: 'Nombre (p. ej. Filament24)',
websitePlaceholder: 'Sitio web / URL de la tienda',
customerNumberPlaceholder: 'N.º de cliente',
notePlaceholder: 'Nota',
nameRequired: 'El nombre es obligatorio',
duplicateName: 'Ya existe un proveedor con este nombre',
customerNumber: 'N.º de cliente',
spools: 'Bobinas',
noMatch: 'Ningún proveedor coincide con su búsqueda',
empty: 'Aún no hay proveedores',
added: 'Proveedor añadido',
addFailed: 'Error al añadir el proveedor',
updated: 'Proveedor actualizado',
updateFailed: 'Error al actualizar el proveedor',
deleted: 'Proveedor eliminado',
deleteFailed: 'Error al eliminar el proveedor',
deleteBlocked: 'No se puede eliminar: sigue asignado a {{count}} bobina(s)',
loadFailed: 'Error al cargar los proveedores',
deleteTitle: 'Eliminar proveedor',
deleteConfirm: '¿Eliminar "{{name}}"?',
deleteConfirmReferenced: '"{{name}}" está asignado a {{count}} bobina(s). La eliminación está bloqueada hasta que se quiten esas asignaciones.',
},
// RFID button rename
clearRfid: 'Borrar etiqueta RFID',
rfidCleared: 'Etiqueta RFID borrada',
+51
View File
@@ -1670,6 +1670,16 @@ export default {
printActivity: 'Activité d\'impression',
filamentTypes: 'Types de filament',
filamentTrends: 'Tendances filament',
suppliers: {
title: 'Par fournisseur',
empty: 'Aucun achat enregistré pour le moment. Indiquez sur une bobine auprès de quel fournisseur elle a été achetée pour regrouper ici la consommation et les coûts.',
error: 'Impossible de charger les statistiques des fournisseurs',
supplier: 'Fournisseur',
spools: 'Bobines',
remaining: 'Restant',
consumed: 'Consommé',
cost: 'Coût',
},
failureAnalysis: 'Analyse des échecs',
timeAccuracy: 'Précision du temps',
successful: 'Succès :',
@@ -4803,6 +4813,47 @@ export default {
storageLocationNone: 'Aucun emplacement défini',
lowStockThresholdOverride: 'Seuil bas (cette bobine)',
lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
suppliers: {
label: 'Fournisseurs',
none: 'Aucun fournisseur',
addSupplier: 'Ajouter un fournisseur…',
searchPlaceholder: 'Rechercher des fournisseurs…',
noneLeft: 'Tous les fournisseurs sont déjà associés',
createNew: 'Créer « {{name}} »',
createFailed: 'Échec de la création du fournisseur',
purchaseSource: 'Acheté ici',
purchaseSourceHelp: 'Indique où cette bobine a réellement été achetée ; les autres entrées sont des sources alternatives.',
articleNumberPlaceholder: 'Réf. article fournisseur',
pricePlaceholder: 'Prix proposé/kg',
priceHelp: 'Prix proposé chez ce fournisseur, pour comparer les sources — le coût par kg propre à la bobine reste la base de coût.',
help: 'Où ce produit peut être acheté. L\'entrée mise en évidence est celle où cette bobine a été achetée.',
saveFailed: 'Échec de l\'enregistrement des fournisseurs associés',
title: 'Fournisseurs',
description: 'Gérez où vous achetez votre filament — à distinguer de la marque, qui désigne le fabricant. Associez les fournisseurs aux bobines dans la boîte de dialogue de la bobine ; un fournisseur encore associé à des bobines ne peut pas être supprimé.',
search: 'Rechercher des fournisseurs...',
addNew: 'Ajouter un nouveau fournisseur',
namePlaceholder: 'Nom (ex. Filament24)',
websitePlaceholder: 'Site web / URL de la boutique',
customerNumberPlaceholder: 'N° client',
notePlaceholder: 'Note',
nameRequired: 'Le nom est requis',
duplicateName: 'Un fournisseur portant ce nom existe déjà',
customerNumber: 'N° client',
spools: 'Bobines',
noMatch: 'Aucun fournisseur ne correspond à votre recherche',
empty: 'Aucun fournisseur pour le moment',
added: 'Fournisseur ajouté',
addFailed: 'Échec de l\'ajout du fournisseur',
updated: 'Fournisseur mis à jour',
updateFailed: 'Échec de la mise à jour du fournisseur',
deleted: 'Fournisseur supprimé',
deleteFailed: 'Échec de la suppression du fournisseur',
deleteBlocked: 'Suppression impossible : encore associé à {{count}} bobine(s)',
loadFailed: 'Échec du chargement des fournisseurs',
deleteTitle: 'Supprimer le fournisseur',
deleteConfirm: 'Supprimer « {{name}} » ?',
deleteConfirmReferenced: '« {{name}} » est associé à {{count}} bobine(s). La suppression est bloquée tant que ces associations ne sont pas retirées.',
},
clearRfid: 'Effacer le tag RFID',
rfidCleared: 'Tag RFID effacé',
archive: 'Archiver',
+51
View File
@@ -1670,6 +1670,16 @@ export default {
printActivity: 'Attivita di stampa',
filamentTypes: 'Tipi di filamento',
filamentTrends: 'Trend filamento',
suppliers: {
title: 'Per fornitore',
empty: 'Nessun acquisto registrato. Indica su una bobina da quale fornitore è stata acquistata per raggruppare qui consumi e costi.',
error: 'Impossibile caricare le statistiche dei fornitori',
supplier: 'Fornitore',
spools: 'Bobine',
remaining: 'Rimanente',
consumed: 'Consumato',
cost: 'Costo',
},
failureAnalysis: 'Analisi guasti',
timeAccuracy: 'Accuratezza tempo',
successful: 'Riuscite:',
@@ -4802,6 +4812,47 @@ export default {
storageLocationNone: 'Nessuna posizione impostata',
lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
suppliers: {
label: 'Fornitori',
none: 'Nessun fornitore',
addSupplier: 'Aggiungi fornitore…',
searchPlaceholder: 'Cerca fornitori…',
noneLeft: 'Tutti i fornitori sono già assegnati',
createNew: 'Crea "{{name}}"',
createFailed: 'Creazione del fornitore non riuscita',
purchaseSource: 'Acquistato qui',
purchaseSourceHelp: 'Indica dove questa bobina è stata effettivamente acquistata; le altre voci sono fonti alternative.',
articleNumberPlaceholder: 'Codice articolo del fornitore',
pricePlaceholder: 'Prezzo offerto/kg',
priceHelp: 'Prezzo offerto da questo fornitore, per confrontare le fonti — il costo al kg della bobina rimane la base di costo.',
help: 'Dove si può acquistare questo prodotto. La voce evidenziata è quella in cui è stata acquistata questa bobina.',
saveFailed: 'Salvataggio delle assegnazioni dei fornitori non riuscito',
title: 'Fornitori',
description: 'Gestisci dove acquisti il filamento — distinto dalla marca, che indica chi lo ha prodotto. Assegna i fornitori alle bobine nella finestra della bobina; un fornitore ancora assegnato a delle bobine non può essere eliminato.',
search: 'Cerca fornitori...',
addNew: 'Aggiungi nuovo fornitore',
namePlaceholder: 'Nome (es. Filament24)',
websitePlaceholder: 'Sito web / URL del negozio',
customerNumberPlaceholder: 'N. cliente',
notePlaceholder: 'Nota',
nameRequired: 'Il nome è obbligatorio',
duplicateName: 'Esiste già un fornitore con questo nome',
customerNumber: 'N. cliente',
spools: 'Bobine',
noMatch: 'Nessun fornitore corrisponde alla ricerca',
empty: 'Nessun fornitore presente',
added: 'Fornitore aggiunto',
addFailed: 'Aggiunta del fornitore non riuscita',
updated: 'Fornitore aggiornato',
updateFailed: 'Aggiornamento del fornitore non riuscito',
deleted: 'Fornitore eliminato',
deleteFailed: 'Eliminazione del fornitore non riuscita',
deleteBlocked: 'Impossibile eliminare: ancora assegnato a {{count}} bobina/e',
loadFailed: 'Caricamento dei fornitori non riuscito',
deleteTitle: 'Elimina fornitore',
deleteConfirm: 'Eliminare "{{name}}"?',
deleteConfirmReferenced: '"{{name}}" è assegnato a {{count}} bobina/e. L\'eliminazione è bloccata finché queste assegnazioni non vengono rimosse.',
},
clearRfid: 'Cancella tag RFID',
rfidCleared: 'Tag RFID cancellato',
archive: 'Archivia',
+53
View File
@@ -1669,6 +1669,17 @@ export default {
printActivity: '印刷アクティビティ',
filamentTypes: 'フィラメントタイプ',
filamentTrends: 'フィラメントトレンド',
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'サプライヤー別',
empty: 'まだ購入記録がありません。スプールにどのサプライヤーから購入したかを設定すると、ここに消費量とコストが集計されます。',
error: '仕入先の統計を読み込めませんでした',
supplier: 'サプライヤー',
spools: 'スプール',
remaining: '残量',
consumed: '消費量',
cost: 'コスト',
},
failureAnalysis: '失敗分析',
timeAccuracy: '時間精度',
successful: '成功',
@@ -4814,6 +4825,48 @@ export default {
storageLocationNone: '保管場所未設定',
lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'サプライヤー',
none: 'サプライヤーなし',
addSupplier: 'サプライヤーを追加…',
searchPlaceholder: 'サプライヤーを検索…',
noneLeft: 'すべてのサプライヤーが割り当て済みです',
createNew: '「{{name}}」を作成',
createFailed: 'サプライヤーの作成に失敗しました',
purchaseSource: 'ここで購入',
purchaseSourceHelp: 'このスプールを実際に購入した場所を示します。その他の項目は代替の購入先です。',
articleNumberPlaceholder: 'サプライヤーの品番',
pricePlaceholder: '提示価格/kg',
priceHelp: 'このサプライヤーでの提示価格です。購入先の比較用であり、スプール自体のkg単価が原価の基準のままです。',
help: 'この製品の購入先です。ハイライトされた項目がこのスプールを購入した場所です。',
saveFailed: 'サプライヤーの割り当ての保存に失敗しました',
title: 'サプライヤー',
description: 'フィラメントの購入先を管理します。製造元であるブランドとは別の概念です。スプールダイアログでスプールにサプライヤーを割り当てられます。スプールに割り当てられているサプライヤーは削除できません。',
search: 'サプライヤーを検索...',
addNew: '新しいサプライヤーを追加',
namePlaceholder: '名前(例:Filament24)',
websitePlaceholder: 'ウェブサイト / ショップURL',
customerNumberPlaceholder: '顧客番号',
notePlaceholder: 'メモ',
nameRequired: '名前は必須です',
duplicateName: 'この名前の仕入先はすでに存在します',
customerNumber: '顧客番号',
spools: 'スプール',
noMatch: '検索に一致するサプライヤーがありません',
empty: 'サプライヤーはまだありません',
added: 'サプライヤーを追加しました',
addFailed: 'サプライヤーの追加に失敗しました',
updated: 'サプライヤーを更新しました',
updateFailed: 'サプライヤーの更新に失敗しました',
deleted: 'サプライヤーを削除しました',
deleteFailed: 'サプライヤーの削除に失敗しました',
deleteBlocked: '削除できません:まだ{{count}}個のスプールに割り当てられています',
loadFailed: 'サプライヤーの読み込みに失敗しました',
deleteTitle: 'サプライヤーを削除',
deleteConfirm: '「{{name}}」を削除しますか?',
deleteConfirmReferenced: '「{{name}}」は{{count}}個のスプールに割り当てられています。割り当てを解除するまで削除できません。',
},
clearRfid: 'RFIDタグをクリア',
rfidCleared: 'RFIDタグをクリアしました',
archive: 'アーカイブ',
+51
View File
@@ -1603,6 +1603,16 @@ export default {
printActivity: '인쇄 활동',
filamentTypes: '필라멘트 종류',
filamentTrends: '필라멘트 추세',
suppliers: {
title: '공급업체별',
empty: '아직 기록된 구매가 없습니다. 스풀에 구매한 공급업체를 표시하면 여기에서 소비량과 비용이 그룹화됩니다.',
error: '공급업체 통계를 불러올 수 없습니다',
supplier: '공급업체',
spools: '스풀',
remaining: '남은 양',
consumed: '소비량',
cost: '비용',
},
failureAnalysis: '실패 분석',
timeAccuracy: '시간 정확도',
successful: '성공:',
@@ -4599,6 +4609,47 @@ export default {
categoryNone: '미분류',
lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)',
lowStockThresholdOverrideHelp: '전역 임계값({{global}}%)을 사용하려면 비워두세요.',
suppliers: {
label: '공급업체',
none: '공급업체 없음',
addSupplier: '공급업체 추가…',
searchPlaceholder: '공급업체 검색…',
noneLeft: '모든 공급업체가 이미 할당되었습니다',
createNew: '"{{name}}" 만들기',
createFailed: '공급업체 생성에 실패했습니다',
purchaseSource: '구매처',
purchaseSourceHelp: '이 스풀을 실제로 구매한 곳을 표시합니다. 나머지 항목은 대체 구매처입니다.',
articleNumberPlaceholder: '공급업체 품목 번호',
pricePlaceholder: '견적 가격/kg',
priceHelp: '이 공급업체의 견적 가격으로, 구매처 비교용입니다. 스풀 자체의 kg당 비용이 실제 원가 기준으로 유지됩니다.',
help: '이 제품을 구매할 수 있는 곳입니다. 강조 표시된 항목이 이 스풀을 구매한 곳입니다.',
saveFailed: '공급업체 할당 저장에 실패했습니다',
title: '공급업체',
description: '필라멘트 구매처를 관리합니다. 제조사를 뜻하는 브랜드와는 다른 개념입니다. 스풀 대화상자에서 스풀에 공급업체를 할당할 수 있으며, 스풀에 할당된 공급업체는 삭제할 수 없습니다.',
search: '공급업체 검색...',
addNew: '새 공급업체 추가',
namePlaceholder: '이름 (예: Filament24)',
websitePlaceholder: '웹사이트 / 쇼핑몰 URL',
customerNumberPlaceholder: '고객 번호',
notePlaceholder: '메모',
nameRequired: '이름은 필수입니다',
duplicateName: '같은 이름의 공급업체가 이미 있습니다',
customerNumber: '고객 번호',
spools: '스풀',
noMatch: '검색과 일치하는 공급업체가 없습니다',
empty: '아직 공급업체가 없습니다',
added: '공급업체가 추가되었습니다',
addFailed: '공급업체 추가에 실패했습니다',
updated: '공급업체가 수정되었습니다',
updateFailed: '공급업체 수정에 실패했습니다',
deleted: '공급업체가 삭제되었습니다',
deleteFailed: '공급업체 삭제에 실패했습니다',
deleteBlocked: '삭제할 수 없습니다: 아직 스풀 {{count}}개에 할당되어 있습니다',
loadFailed: '공급업체를 불러오지 못했습니다',
deleteTitle: '공급업체 삭제',
deleteConfirm: '"{{name}}"을(를) 삭제하시겠습니까?',
deleteConfirmReferenced: '"{{name}}"이(가) 스풀 {{count}}개에 할당되어 있습니다. 해당 할당을 제거할 때까지 삭제할 수 없습니다.',
},
clearRfid: 'RFID 태그 초기화',
rfidCleared: 'RFID 태그가 초기화되었습니다',
archive: '보관',
+53
View File
@@ -1687,6 +1687,17 @@ export default {
printActivity: 'Afdrukactiviteit',
filamentTypes: 'Filamenttypen',
filamentTrends: 'Filamenttrends',
// Consumption/cost grouped by purchase-source supplier (#2988).
suppliers: {
title: 'Per leverancier',
empty: 'Nog geen aankopen geregistreerd. Markeer op een spoel bij welke leverancier deze is gekocht om verbruik en kosten hier te groeperen.',
error: 'Leveranciersstatistieken konden niet worden geladen',
supplier: 'Leverancier',
spools: 'Spoelen',
remaining: 'Resterend',
consumed: 'Verbruikt',
cost: 'Kosten',
},
failureAnalysis: 'Foutenanalyse',
timeAccuracy: 'Tijdnauwkeurigheid',
successful: 'Geslaagd:',
@@ -4854,6 +4865,48 @@ export default {
storageLocationNone: 'Geen locatie ingesteld',
lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)',
lowStockThresholdOverrideHelp: 'Laat leeg om de globale drempel ({{global}}%) te gebruiken.',
// Suppliers (#2988): the master list and the per-spool assignments.
suppliers: {
label: 'Leveranciers',
none: 'Geen leverancier',
addSupplier: 'Leverancier toevoegen…',
searchPlaceholder: 'Leveranciers zoeken…',
noneLeft: 'Alle leveranciers zijn al toegewezen',
createNew: '"{{name}}" aanmaken',
createFailed: 'Leverancier aanmaken mislukt',
purchaseSource: 'Hier gekocht',
purchaseSourceHelp: 'Markeert waar deze spoel daadwerkelijk is gekocht; de overige vermeldingen zijn alternatieve bronnen.',
articleNumberPlaceholder: 'Artikelnr. leverancier',
pricePlaceholder: 'Offerteprijs/kg',
priceHelp: 'Offerteprijs bij deze leverancier, om bronnen te vergelijken — de eigen kosten per kg van de spoel blijven de kostenbasis.',
help: 'Waar dit product te koop is. De gemarkeerde vermelding is waar deze spoel is gekocht.',
saveFailed: 'Leverancierstoewijzingen opslaan mislukt',
title: 'Leveranciers',
description: 'Beheer waar je filament koopt — niet te verwarren met het merk, dat is wie het heeft gemaakt. Wijs leveranciers toe aan spoelen in het spoeldialoogvenster; een leverancier die nog aan spoelen is toegewezen kan niet worden verwijderd.',
search: 'Leveranciers zoeken...',
addNew: 'Nieuwe leverancier toevoegen',
namePlaceholder: 'Naam (bijv. Filament24)',
websitePlaceholder: 'Website / winkel-URL',
customerNumberPlaceholder: 'Klantnr.',
notePlaceholder: 'Notitie',
nameRequired: 'Naam is verplicht',
duplicateName: 'Er bestaat al een leverancier met deze naam',
customerNumber: 'Klantnr.',
spools: 'Spoelen',
noMatch: 'Geen leveranciers gevonden voor je zoekopdracht',
empty: 'Nog geen leveranciers',
added: 'Leverancier toegevoegd',
addFailed: 'Leverancier toevoegen mislukt',
updated: 'Leverancier bijgewerkt',
updateFailed: 'Leverancier bijwerken mislukt',
deleted: 'Leverancier verwijderd',
deleteFailed: 'Leverancier verwijderen mislukt',
deleteBlocked: 'Kan niet verwijderen: nog toegewezen aan {{count}} spoel(en)',
loadFailed: 'Leveranciers laden mislukt',
deleteTitle: 'Leverancier verwijderen',
deleteConfirm: '"{{name}}" verwijderen?',
deleteConfirmReferenced: '"{{name}}" is toegewezen aan {{count}} spoel(en). Verwijderen is geblokkeerd totdat die toewijzingen zijn verwijderd.',
},
// RFID button rename (was "Delete Tag" — confusing because it sounds like a
// taxonomy delete; this clears the RFID tag/UUID off the spool record)
clearRfid: 'RFID-tag wissen',
+51
View File
@@ -1670,6 +1670,16 @@ export default {
printActivity: 'Atividade de Impressão',
filamentTypes: 'Tipos de Filamento',
filamentTrends: 'Tendências de Filamento',
suppliers: {
title: 'Por Fornecedor',
empty: 'Nenhuma compra registrada ainda. Marque em um carretel de qual fornecedor ele foi comprado para agrupar consumo e custos aqui.',
error: 'Não foi possível carregar as estatísticas de fornecedores',
supplier: 'Fornecedor',
spools: 'Carretéis',
remaining: 'Restante',
consumed: 'Consumido',
cost: 'Custo',
},
failureAnalysis: 'Análise de Falhas',
timeAccuracy: 'Precisão do Tempo',
successful: 'Bem-sucedido:',
@@ -4802,6 +4812,47 @@ export default {
storageLocationNone: 'Sem local definido',
lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
suppliers: {
label: 'Fornecedores',
none: 'Sem fornecedor',
addSupplier: 'Adicionar fornecedor…',
searchPlaceholder: 'Pesquisar fornecedores…',
noneLeft: 'Todos os fornecedores já estão atribuídos',
createNew: 'Criar "{{name}}"',
createFailed: 'Falha ao criar fornecedor',
purchaseSource: 'Comprado aqui',
purchaseSourceHelp: 'Indica onde este carretel foi realmente comprado; as outras entradas são fontes alternativas.',
articleNumberPlaceholder: 'Nº do artigo no fornecedor',
pricePlaceholder: 'Preço cotado/kg',
priceHelp: 'Preço cotado neste fornecedor, para comparar fontes — o custo por kg do próprio carretel continua sendo a base de custo.',
help: 'Onde este produto pode ser comprado. A entrada destacada é onde este carretel foi comprado.',
saveFailed: 'Falha ao salvar as atribuições de fornecedor',
title: 'Fornecedores',
description: 'Gerencie onde você compra filamento — diferente da marca, que é quem o fabricou. Atribua fornecedores aos carretéis no diálogo do carretel; um fornecedor que ainda está atribuído a carretéis não pode ser excluído.',
search: 'Pesquisar fornecedores...',
addNew: 'Adicionar novo fornecedor',
namePlaceholder: 'Nome (ex. Filament24)',
websitePlaceholder: 'Site / URL da loja',
customerNumberPlaceholder: 'Nº de cliente',
notePlaceholder: 'Observação',
nameRequired: 'O nome é obrigatório',
duplicateName: 'Já existe um fornecedor com esse nome',
customerNumber: 'Nº de cliente',
spools: 'Carretéis',
noMatch: 'Nenhum fornecedor corresponde à sua pesquisa',
empty: 'Nenhum fornecedor ainda',
added: 'Fornecedor adicionado',
addFailed: 'Falha ao adicionar fornecedor',
updated: 'Fornecedor atualizado',
updateFailed: 'Falha ao atualizar fornecedor',
deleted: 'Fornecedor excluído',
deleteFailed: 'Falha ao excluir fornecedor',
deleteBlocked: 'Não é possível excluir: ainda atribuído a {{count}} carretel(éis)',
loadFailed: 'Falha ao carregar fornecedores',
deleteTitle: 'Excluir Fornecedor',
deleteConfirm: 'Excluir "{{name}}"?',
deleteConfirmReferenced: '"{{name}}" está atribuído a {{count}} carretel(éis). A exclusão fica bloqueada até que essas atribuições sejam removidas.',
},
clearRfid: 'Limpar tag RFID',
rfidCleared: 'Tag RFID limpa',
archive: 'Arquivar',
+51
View File
@@ -1600,6 +1600,16 @@ export default {
printActivity: "Активность печати",
filamentTypes: "Типы филамента",
filamentTrends: "Расход филамента",
suppliers: {
title: "По поставщикам",
empty: "Покупки пока не зарегистрированы. Отметьте на катушке, у какого поставщика она была куплена, чтобы сгруппировать здесь расход и затраты.",
error: 'Не удалось загрузить статистику по поставщикам',
supplier: "Поставщик",
spools: "Катушки",
remaining: "Остаток",
consumed: "Израсходовано",
cost: "Затраты",
},
failureAnalysis: "Анализ неудач",
timeAccuracy: "Точность оценки времени",
successful: "Успешно:",
@@ -4590,6 +4600,47 @@ export default {
storageLocationNone: "Место не указано",
lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).",
suppliers: {
label: "Поставщики",
none: "Без поставщика",
addSupplier: "Добавить поставщика…",
searchPlaceholder: "Поиск поставщиков…",
noneLeft: "Все поставщики уже назначены",
createNew: "Создать «{{name}}»",
createFailed: "Не удалось создать поставщика",
purchaseSource: "Куплено здесь",
purchaseSourceHelp: "Отмечает, где эта катушка была фактически куплена; остальные записи — альтернативные источники.",
articleNumberPlaceholder: "Артикул поставщика",
pricePlaceholder: "Предлагаемая цена/кг",
priceHelp: "Предлагаемая цена у этого поставщика для сравнения источников — базой затрат остаётся собственная стоимость катушки за кг.",
help: "Где можно купить этот товар. Выделенная запись — место покупки этой катушки.",
saveFailed: "Не удалось сохранить назначения поставщиков",
title: "Поставщики",
description: "Управляйте тем, где вы покупаете филамент — в отличие от производителя, который его изготовил. Назначайте поставщиков катушкам в диалоге катушки; поставщика, который всё ещё назначен катушкам, удалить нельзя.",
search: "Поиск поставщиков…",
addNew: "Добавить поставщика",
namePlaceholder: "Название (напр. Filament24)",
websitePlaceholder: "Сайт / URL магазина",
customerNumberPlaceholder: "Номер клиента",
notePlaceholder: "Заметка",
nameRequired: "Название обязательно",
duplicateName: 'Поставщик с таким названием уже существует',
customerNumber: "Номер клиента",
spools: "Катушки",
noMatch: "Поставщики по вашему запросу не найдены",
empty: "Пока нет поставщиков",
added: "Поставщик добавлен",
addFailed: "Не удалось добавить поставщика",
updated: "Поставщик обновлён",
updateFailed: "Не удалось обновить поставщика",
deleted: "Поставщик удалён",
deleteFailed: "Не удалось удалить поставщика",
deleteBlocked: "Удаление невозможно: всё ещё назначен катушкам ({{count}})",
loadFailed: "Не удалось загрузить поставщиков",
deleteTitle: "Удалить поставщика",
deleteConfirm: "Удалить «{{name}}»?",
deleteConfirmReferenced: "«{{name}}» назначен катушкам ({{count}}). Удаление заблокировано, пока эти назначения не будут сняты.",
},
clearRfid: "Очистить RFID-метку",
rfidCleared: "RFID-метка очищена",
archive: "Архивировать",
+51
View File
@@ -1687,6 +1687,16 @@ export default {
printActivity: 'Utskriftsaktivitet',
filamentTypes: 'Filamenttyper',
filamentTrends: 'Filamenttrender',
suppliers: {
title: 'Per leverantör',
empty: 'Inga inköp registrerade ännu. Ange på en rulle vilken leverantör den köptes från för att gruppera förbrukning och kostnader här.',
error: 'Kunde inte läsa in leverantörsstatistiken',
supplier: 'Leverantör',
spools: 'Rullar',
remaining: 'Kvar',
consumed: 'Förbrukat',
cost: 'Kostnad',
},
failureAnalysis: 'Felanalys',
timeAccuracy: 'Tidsnoggrannhet',
successful: 'Lyckade:',
@@ -4854,6 +4864,47 @@ errors: {
storageLocationNone: 'Ingen lagringsplats inställd',
lowStockThresholdOverride: 'Lågt lagertröskelvärde (denna rulle)',
lowStockThresholdOverrideHelp: 'Lämna tomt för att använda det globala tröskelvärdet ({{global}}%).',
suppliers: {
label: 'Leverantörer',
none: 'Ingen leverantör',
addSupplier: 'Lägg till leverantör…',
searchPlaceholder: 'Sök leverantörer…',
noneLeft: 'Alla leverantörer är redan tilldelade',
createNew: 'Skapa ”{{name}}”',
createFailed: 'Det gick inte att skapa leverantören',
purchaseSource: 'Köpt här',
purchaseSourceHelp: 'Markerar var den här rullen faktiskt köptes; övriga poster är alternativa inköpsställen.',
articleNumberPlaceholder: 'Leverantörens artikelnr',
pricePlaceholder: 'Offererat pris/kg',
priceHelp: 'Offererat pris hos den här leverantören, för att jämföra inköpsställen – rullens eget kilopris är fortfarande kostnadsunderlaget.',
help: 'Var den här produkten kan köpas. Den markerade posten är där rullen köptes.',
saveFailed: 'Det gick inte att spara leverantörstilldelningarna',
title: 'Leverantörer',
description: 'Hantera var du köper filament – till skillnad från märke, som är vem som tillverkat det. Tilldela leverantörer till rullar i rulldialogen; en leverantör som fortfarande är tilldelad rullar går inte att ta bort.',
search: 'Sök leverantörer …',
addNew: 'Lägg till ny leverantör',
namePlaceholder: 'Namn (t.ex. Filament24)',
websitePlaceholder: 'Webbplats/butikslänk',
customerNumberPlaceholder: 'Kundnr',
notePlaceholder: 'Anteckning',
nameRequired: 'Namn krävs',
duplicateName: 'Det finns redan en leverantör med det namnet',
customerNumber: 'Kundnr',
spools: 'Rullar',
noMatch: 'Inga leverantörer matchar din sökning',
empty: 'Inga leverantörer ännu',
added: 'Leverantören har lagts till',
addFailed: 'Det gick inte att lägga till leverantören',
updated: 'Leverantören har uppdaterats',
updateFailed: 'Det gick inte att uppdatera leverantören',
deleted: 'Leverantören har tagits bort',
deleteFailed: 'Det gick inte att ta bort leverantören',
deleteBlocked: 'Går inte att ta bort: fortfarande tilldelad {{count}} rulle/rullar',
loadFailed: 'Det gick inte att läsa in leverantörerna',
deleteTitle: 'Ta bort leverantör',
deleteConfirm: 'Ta bort ”{{name}}”?',
deleteConfirmReferenced: '”{{name}}” är tilldelad {{count}} rulle/rullar. Borttagning är blockerad tills de tilldelningarna har tagits bort.',
},
// RFID button rename (was "Delete Tag" — confusing because it sounds like a
// taxonomy delete; this clears the RFID tag/UUID off the spool record)
clearRfid: 'Rensa RFID-tagg',
+51
View File
@@ -1671,6 +1671,16 @@ export default {
printActivity: 'Baskı Etkinliği',
filamentTypes: 'Filament Türleri',
filamentTrends: 'Filament Trendleri',
suppliers: {
title: 'Tedarikçiye Göre',
empty: 'Henüz satın alma kaydı yok. Tüketim ve maliyetleri burada gruplamak için bir makarada hangi tedarikçiden alındığını işaretleyin.',
error: 'Tedarikçi istatistikleri yüklenemedi',
supplier: 'Tedarikçi',
spools: 'Makaralar',
remaining: 'Kalan',
consumed: 'Tüketilen',
cost: 'Maliyet',
},
failureAnalysis: 'Başarısızlık Analizi',
timeAccuracy: 'Süre Doğruluğu',
successful: 'Başarılı:',
@@ -4802,6 +4812,47 @@ export default {
storageLocationNone: 'Konum ayarlanmamış',
lowStockThresholdOverride: 'Düşük stok eşiği (bu makara)',
lowStockThresholdOverrideHelp: 'Global eşiği kullanmak için boş bırakın (%{{global}}).',
suppliers: {
label: 'Tedarikçiler',
none: 'Tedarikçi yok',
addSupplier: 'Tedarikçi ekle…',
searchPlaceholder: 'Tedarikçi ara…',
noneLeft: 'Tüm tedarikçiler zaten atanmış',
createNew: '"{{name}}" oluştur',
createFailed: 'Tedarikçi oluşturulamadı',
purchaseSource: 'Buradan satın alındı',
purchaseSourceHelp: 'Bu makaranın gerçekte nereden satın alındığını işaretler; diğer girişler alternatif kaynaklardır.',
articleNumberPlaceholder: 'Tedarikçi ürün no.',
pricePlaceholder: 'Teklif fiyatı/kg',
priceHelp: 'Bu tedarikçideki teklif fiyatı, kaynakları karşılaştırmak içindir — makaranın kendi kg başına maliyeti, maliyet esası olarak kalır.',
help: 'Bu ürünün satın alınabileceği yerler. Vurgulanan giriş, bu makaranın satın alındığı yerdir.',
saveFailed: 'Tedarikçi atamaları kaydedilemedi',
title: 'Tedarikçiler',
description: 'Filamenti nereden satın aldığınızı yönetin — bunu üreten firmayı belirten markadan farklıdır. Makara iletişim kutusunda makaralara tedarikçi atayın; hâlâ makaralara atanmış bir tedarikçi silinemez.',
search: 'Tedarikçi ara...',
addNew: 'Yeni tedarikçi ekle',
namePlaceholder: 'Ad (örn. Filament24)',
websitePlaceholder: 'Web sitesi / mağaza URL\'si',
customerNumberPlaceholder: 'Müşteri no.',
notePlaceholder: 'Not',
nameRequired: 'Ad gereklidir',
duplicateName: 'Bu ada sahip bir tedarikçi zaten var',
customerNumber: 'Müşteri no.',
spools: 'Makaralar',
noMatch: 'Aramanızla eşleşen tedarikçi yok',
empty: 'Henüz tedarikçi yok',
added: 'Tedarikçi eklendi',
addFailed: 'Tedarikçi eklenemedi',
updated: 'Tedarikçi güncellendi',
updateFailed: 'Tedarikçi güncellenemedi',
deleted: 'Tedarikçi silindi',
deleteFailed: 'Tedarikçi silinemedi',
deleteBlocked: 'Silinemez: hâlâ {{count}} makaraya atanmış',
loadFailed: 'Tedarikçiler yüklenemedi',
deleteTitle: 'Tedarikçiyi Sil',
deleteConfirm: '"{{name}}" silinsin mi?',
deleteConfirmReferenced: '"{{name}}" {{count}} makaraya atanmış. Bu atamalar kaldırılana kadar silme engellenir.',
},
clearRfid: 'RFID Etiketini Temizle',
rfidCleared: 'RFID etiketi temizlendi',
archive: 'Arşivle',
+51
View File
@@ -1686,6 +1686,16 @@ export default {
printActivity: "Активність друку",
filamentTypes: "Типи філаментів",
filamentTrends: "Тенденції філаменту",
suppliers: {
title: "За постачальником",
empty: "Покупок ще не зафіксовано. Позначте на котушці, у якого постачальника її придбано, щоб згрупувати тут споживання та витрати.",
error: 'Не вдалося завантажити статистику постачальників',
supplier: "Постачальник",
spools: "Котушки",
remaining: "Залишок",
consumed: "Спожито",
cost: "Вартість",
},
failureAnalysis: "Аналіз невдач",
timeAccuracy: "Точність оцінки часу",
successful: "Успішно:",
@@ -4851,6 +4861,47 @@ export default {
storageLocationNone: "Місцезнаходження не встановлено",
lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).",
suppliers: {
label: "Постачальники",
none: "Без постачальника",
addSupplier: "Додати постачальника…",
searchPlaceholder: "Пошук постачальників…",
noneLeft: "Усі постачальники вже призначені",
createNew: "Створити «{{name}}»",
createFailed: "Не вдалося створити постачальника",
purchaseSource: "Куплено тут",
purchaseSourceHelp: "Позначає, де цю котушку було фактично придбано; інші записи — альтернативні джерела.",
articleNumberPlaceholder: "Артикул постачальника",
pricePlaceholder: "Пропонована ціна/кг",
priceHelp: "Пропонована ціна в цього постачальника — для порівняння джерел; власна вартість котушки за кг залишається базою собівартості.",
help: "Де можна придбати цей товар. Виділений запис — це місце, де було куплено цю котушку.",
saveFailed: "Не вдалося зберегти призначення постачальників",
title: "Постачальники",
description: "Керуйте тим, де ви купуєте філамент — на відміну від бренду, який вказує виробника. Призначайте постачальників котушкам у діалозі котушки; постачальника, який усе ще призначений котушкам, видалити не можна.",
search: "Пошук постачальників...",
addNew: "Додати нового постачальника",
namePlaceholder: "Назва (напр. Filament24)",
websitePlaceholder: "Вебсайт / URL магазину",
customerNumberPlaceholder: "Номер клієнта",
notePlaceholder: "Нотатка",
nameRequired: "Назва є обов’язковою",
duplicateName: 'Постачальник із такою назвою вже існує',
customerNumber: "Номер клієнта",
spools: "Котушки",
noMatch: "Жоден постачальник не відповідає вашому запиту",
empty: "Постачальників поки немає",
added: "Постачальника додано",
addFailed: "Не вдалося додати постачальника",
updated: "Постачальника оновлено",
updateFailed: "Не вдалося оновити постачальника",
deleted: "Постачальника видалено",
deleteFailed: "Не вдалося видалити постачальника",
deleteBlocked: "Неможливо видалити: усе ще призначено {{count}} котушці(-ам)",
loadFailed: "Не вдалося завантажити постачальників",
deleteTitle: "Видалити постачальника",
deleteConfirm: "Видалити «{{name}}»?",
deleteConfirmReferenced: "«{{name}}» призначено {{count}} котушці(-ам). Видалення заблоковано, доки ці призначення не буде знято.",
},
// RFID button rename (was "Delete Tag" — confusing because it sounds like a
// taxonomy delete; this clears the RFID tag/UUID off the spool record)
clearRfid: "Очистити тег RFID.",
+51
View File
@@ -1670,6 +1670,16 @@ export default {
printActivity: '打印活动',
filamentTypes: '耗材类型',
filamentTrends: '耗材趋势',
suppliers: {
title: '按供应商',
empty: '暂无购买记录。在料盘上标记其购买来源供应商后,即可在此按供应商统计消耗和成本。',
error: '无法加载供应商统计数据',
supplier: '供应商',
spools: '料盘',
remaining: '剩余',
consumed: '已消耗',
cost: '成本',
},
failureAnalysis: '失败分析',
timeAccuracy: '时间准确度',
successful: '成功:',
@@ -4802,6 +4812,47 @@ export default {
storageLocationNone: '未设置位置',
lowStockThresholdOverride: '低库存阈值(此料盘)',
lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
suppliers: {
label: '供应商',
none: '无供应商',
addSupplier: '添加供应商…',
searchPlaceholder: '搜索供应商…',
noneLeft: '所有供应商均已分配',
createNew: '创建“{{name}}”',
createFailed: '创建供应商失败',
purchaseSource: '在此购买',
purchaseSourceHelp: '标记此料盘的实际购买来源;其他条目为备选购买渠道。',
articleNumberPlaceholder: '供应商货号',
pricePlaceholder: '报价/kg',
priceHelp: '此供应商的报价,用于比较购买渠道;料盘自身的每公斤成本仍作为成本依据。',
help: '此产品的可购买渠道。高亮条目为此料盘的实际购买来源。',
saveFailed: '保存供应商分配失败',
title: '供应商',
description: '管理耗材的购买渠道——不同于品牌,品牌是耗材的制造商。可在料盘对话框中为料盘分配供应商;仍被料盘引用的供应商无法删除。',
search: '搜索供应商...',
addNew: '添加新供应商',
namePlaceholder: '名称(例如 Filament24)',
websitePlaceholder: '网站/商店链接',
customerNumberPlaceholder: '客户编号',
notePlaceholder: '备注',
nameRequired: '名称为必填项',
duplicateName: '已存在同名供应商',
customerNumber: '客户编号',
spools: '料盘',
noMatch: '没有匹配的供应商',
empty: '暂无供应商',
added: '供应商已添加',
addFailed: '添加供应商失败',
updated: '供应商已更新',
updateFailed: '更新供应商失败',
deleted: '供应商已删除',
deleteFailed: '删除供应商失败',
deleteBlocked: '无法删除:仍分配给 {{count}} 个料盘',
loadFailed: '加载供应商失败',
deleteTitle: '删除供应商',
deleteConfirm: '确定删除“{{name}}”吗?',
deleteConfirmReferenced: '“{{name}}”已分配给 {{count}} 个料盘。请先移除这些分配后才能删除。',
},
clearRfid: '清除 RFID 标签',
rfidCleared: 'RFID 标签已清除',
archive: '归档',
+51
View File
@@ -1670,6 +1670,16 @@ export default {
printActivity: '列印活動',
filamentTypes: '耗材類型',
filamentTrends: '耗材趨勢',
suppliers: {
title: '依供應商',
empty: '尚未記錄任何購買。在料盤上標記其購買來源的供應商,即可在此依供應商彙整消耗量與成本。',
error: '無法載入供應商統計資料',
supplier: '供應商',
spools: '料盤',
remaining: '剩餘',
consumed: '已消耗',
cost: '成本',
},
failureAnalysis: '失敗分析',
timeAccuracy: '時間準確度',
successful: '成功:',
@@ -4802,6 +4812,47 @@ export default {
storageLocationNone: '未設定位置',
lowStockThresholdOverride: '低庫存閾值(此料盤)',
lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
suppliers: {
label: '供應商',
none: '無供應商',
addSupplier: '新增供應商…',
searchPlaceholder: '搜尋供應商…',
noneLeft: '所有供應商皆已指派',
createNew: '建立「{{name}}」',
createFailed: '建立供應商失敗',
purchaseSource: '於此購買',
purchaseSourceHelp: '標記此料盤實際購買的來源;其他項目為替代來源。',
articleNumberPlaceholder: '供應商商品編號',
pricePlaceholder: '報價/公斤',
priceHelp: '此供應商的報價,用於比較購買來源;料盤本身的每公斤成本仍為成本基準。',
help: '此產品可購買的地點。醒目標示的項目為此料盤的實際購買處。',
saveFailed: '儲存供應商指派失敗',
title: '供應商',
description: '管理您購買耗材的來源——與品牌不同,品牌是製造商。可在料盤對話框中為料盤指派供應商;仍指派給料盤的供應商無法刪除。',
search: '搜尋供應商...',
addNew: '新增供應商',
namePlaceholder: '名稱(例如 Filament24)',
websitePlaceholder: '網站/商店網址',
customerNumberPlaceholder: '客戶編號',
notePlaceholder: '備註',
nameRequired: '名稱為必填',
duplicateName: '已存在同名供應商',
customerNumber: '客戶編號',
spools: '料盤',
noMatch: '沒有符合搜尋的供應商',
empty: '尚無供應商',
added: '供應商已新增',
addFailed: '新增供應商失敗',
updated: '供應商已更新',
updateFailed: '更新供應商失敗',
deleted: '供應商已刪除',
deleteFailed: '刪除供應商失敗',
deleteBlocked: '無法刪除:仍指派給 {{count}} 個料盤',
loadFailed: '載入供應商失敗',
deleteTitle: '刪除供應商',
deleteConfirm: '刪除「{{name}}」?',
deleteConfirmReferenced: '「{{name}}」已指派給 {{count}} 個料盤。必須先移除這些指派才能刪除。',
},
clearRfid: '清除 RFID 標籤',
rfidCleared: 'RFID 標籤已清除',
archive: '歸檔',
+96 -4
View File
@@ -7,7 +7,7 @@ import {
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
Upload, Download,
Upload, Download, Store,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -22,6 +22,7 @@ import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfig
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
import { LocationsModal } from '../components/LocationsModal';
import { SuppliersModal } from '../components/SuppliersModal';
import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
@@ -98,6 +99,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'printed_total', label: 'Printed Total', visible: false },
{ id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
{ id: 'note', label: 'Note', visible: false },
{ id: 'suppliers', label: 'Suppliers', visible: false },
{ id: 'pa_k', label: 'PA(K)', visible: true },
{ id: 'tag_id', label: 'Tag ID', visible: false },
{ id: 'data_origin', label: 'Data Origin', visible: false },
@@ -227,6 +229,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
printed_total: () => 'Printed Total',
printed_since_weight: () => 'Printed Since Weight',
note: (t) => t('inventory.note'),
suppliers: (t) => t('inventory.suppliers.label'),
pa_k: () => 'PA(K)',
tag_id: () => 'Tag ID',
data_origin: () => 'Data Origin',
@@ -367,6 +370,31 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
note: ({ spool }) => (
<span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
),
// Supplier chips (#2988): purchase source first and highlighted; the
// others read as alternative sources. Tooltip carries the supplier's
// article number when set.
suppliers: ({ spool }) => {
const links = spool.suppliers ?? [];
if (links.length === 0) return <span className="text-sm text-bambu-gray">-</span>;
const sorted = [...links].sort((a, b) => Number(b.is_purchase_source) - Number(a.is_purchase_source));
return (
<div className="flex flex-wrap gap-1 max-w-[220px]">
{sorted.map((link) => (
<span
key={link.id}
title={link.supplier_article_number || undefined}
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
link.is_purchase_source
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-gray/10 text-bambu-gray'
}`}
>
{link.supplier_name}
</span>
))}
</div>
);
},
pa_k: ({ spool }) => {
const count = spool.k_profiles?.length ?? 0;
if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
@@ -513,6 +541,13 @@ const columnSortValues: Record<
used: (s) => s.weight_used,
remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
note: (s) => (s.note || '').toLowerCase(),
// Sorts on the purchase-source supplier, falling back to the first
// assignment — a spool has to sit in exactly one place in the list.
suppliers: (s) => {
const links = s.suppliers ?? [];
const primary = links.find((l) => l.is_purchase_source) ?? links[0];
return (primary?.supplier_name || '').toLowerCase();
},
data_origin: (s) => (s.data_origin || '').toLowerCase(),
tag_type: (s) => (s.tag_type || '').toLowerCase(),
stock: (s) => s.slicer_filament ? 1 : 0,
@@ -595,6 +630,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const [csvImportOpen, setCsvImportOpen] = useState(false);
const [exportingCsv, setExportingCsv] = useState(false);
const [locationsModalOpen, setLocationsModalOpen] = useState(false);
const [suppliersModalOpen, setSuppliersModalOpen] = useState(false);
// Filter state
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -602,6 +638,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const [materialFilter, setMaterialFilter] = useState('');
const [brandFilter, setBrandFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
// Filter on an assigned supplier (#2988), same shape as category.
const [supplierFilter, setSupplierFilter] = useState('');
const [spoolFilter, setSpoolFilter] = useState('');
const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
const [search, setSearch] = useState('');
@@ -636,7 +674,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
// honest vs. what the user is actually looking at.
useEffect(() => {
setSelectedIds(new Set());
}, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search]);
}, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, search]);
// Pagination state (pageSize persisted to localStorage)
const [pageIndex, setPageIndex] = useState(0);
@@ -1274,6 +1312,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}
}
// Supplier dropdown (#2988): "everything from supplier X" matches ANY
// assignment, purchase source or alternative; `__none__` finds spools
// without supplier assignments.
if (supplierFilter) {
if (supplierFilter === '__none__') {
filtered = filtered.filter((s) => (s.suppliers ?? []).length === 0);
} else {
const supplierId = Number(supplierFilter);
filtered = filtered.filter((s) => (s.suppliers ?? []).some((l) => l.supplier_id === supplierId));
}
}
// Spool name dropdown
if (spoolFilter) {
const catalogId = Number(spoolFilter);
@@ -1309,7 +1359,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
}
return filtered;
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
// Reset page on filter changes
const resetPage = () => setPageIndex(0);
@@ -1330,6 +1380,17 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort();
const hasUncategorized = (spools ?? []).some((s) => !s.category);
// #2988: suppliers seen across the inventory, for the filter dropdown.
const uniqueSuppliers = useMemo(() => {
const byId = new Map<number, string>();
for (const s of spools ?? []) {
for (const link of s.suppliers ?? []) {
byId.set(link.supplier_id, link.supplier_name);
}
}
return [...byId.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}, [spools]);
const hasUnsupplied = (spools ?? []).some((s) => (s.suppliers ?? []).length === 0);
const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => {
const nameA = (catalogMap[a]?.name || '').toLowerCase();
const nameB = (catalogMap[b]?.name || '').toLowerCase();
@@ -1340,7 +1401,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.location_id && !s.storage_location?.trim());
// Check if any filters are non-default
const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
const handleColumnConfigSave = (config: ColumnConfig[]) => {
setColumnConfig(config);
@@ -1462,6 +1523,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
setMaterialFilter('');
setBrandFilter('');
setCategoryFilter('');
setSupplierFilter('');
setSpoolFilter('');
setStockFilter('all');
setSearch('');
@@ -1515,6 +1577,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
<MapPin className="w-4 h-4" />
{t('locations.manage')}
</Button>
{/* Suppliers (#2988): inventory master data like Locations, so the
modal opens from the same toolbar — in both inventory modes. */}
<Button variant="secondary" onClick={() => setSuppliersModalOpen(true)}>
<Store className="w-4 h-4" />
{t('inventory.suppliers.title')}
</Button>
<Button
variant="secondary"
disabled={filteredSpools.length === 0}
@@ -1920,6 +1988,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
</select>
)}
{/* Supplier dropdown chip (#2988) — same render rule as the category
chip: hidden until at least one spool carries an assignment. */}
{(uniqueSuppliers.length > 0 || supplierFilter) && (
<select
value={supplierFilter}
onChange={(e) => { setSupplierFilter(e.target.value); resetPage(); }}
className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
supplierFilter
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
: 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
}`}
>
<option value="">{t('inventory.suppliers.label')}</option>
{uniqueSuppliers.map((supplier) => (
<option key={supplier.id} value={String(supplier.id)}>{supplier.name}</option>
))}
{hasUnsupplied && (
<option value="__none__">{t('inventory.suppliers.none')}</option>
)}
</select>
)}
{/* Spool name dropdown chip */}
{uniqueSpoolCatalogIds.length > 0 && (
<select
@@ -2520,6 +2610,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
onClose={() => setLocationsModalOpen(false)}
onPickLocation={(id) => setStorageLocationFilter(String(id))}
/>
<SuppliersModal open={suppliersModalOpen} onClose={() => setSuppliersModalOpen(false)} />
</div>
);
}
+23
View File
@@ -38,6 +38,7 @@ import { useAuth } from '../contexts/AuthContext';
import { api, type ArchiveSlim } from '../api/client';
import { PrintCalendar } from '../components/PrintCalendar';
import { FilamentTrends } from '../components/FilamentTrends';
import { SupplierStats } from '../components/SupplierStats';
import { Dashboard, type DashboardWidget } from '../components/Dashboard';
import { getCurrencySymbol } from '../utils/currency';
import { formatWeight } from '../utils/weight';
@@ -1066,6 +1067,22 @@ export function StatsPage() {
queryFn: api.getSettings,
});
// The supplier widget aggregates the internal spool table, which is empty
// in Spoolman mode — there the assignments live in the Spoolman twin table.
// Rather than show a permanently empty card next to an inventory that does
// display supplier chips, drop it (#2988).
const { data: spoolmanSettings, isPending: spoolmanSettingsPending } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: api.getSpoolmanSettings,
staleTime: 5 * 60 * 1000,
});
// The rest of the dashboard renders off the archive response, so the card
// would otherwise mount — and hit the aggregate endpoint — while the mode
// is still unknown. "Not loaded yet" is not "internal mode".
const spoolmanModeReady = !spoolmanSettingsPending;
const spoolmanMode =
spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
// Slim listing (#1894): the filter only needs id + username, and gating it
// on the admin-level users:read left the dropdown empty for exactly the
// operators who were granted stats:filter_by_user.
@@ -1190,6 +1207,12 @@ export function StatsPage() {
component: <FilamentTrendsWidget archives={archives || []} currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
defaultSize: 4,
},
...(!spoolmanModeReady || spoolmanMode ? [] : ([{
id: 'suppliers',
title: t('stats.suppliers.title'),
component: <SupplierStats currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
defaultSize: 2,
}] as DashboardWidget[])),
];
return (
+7
View File
@@ -7,6 +7,13 @@ export function invalidateInventoryLocations(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: inventoryLocationsQueryKey });
}
/** React Query key for GET /inventory/suppliers (master list + spool counts). */
export const inventorySuppliersQueryKey = ['inventory-suppliers'] as const;
export function invalidateInventorySuppliers(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
}
/** Refresh spool list and location counts after inventory mutations. */
export function invalidateSpoolAndLocationQueries(
queryClient: QueryClient,
+6 -1
View File
@@ -25,7 +25,12 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean
(spool.subtype?.toLowerCase().includes(q) ?? false) ||
(spool.note?.toLowerCase().includes(q) ?? false) ||
(spool.slicer_filament_name?.toLowerCase().includes(q) ?? false) ||
(spool.storage_location?.toLowerCase().includes(q) ?? false)
(spool.storage_location?.toLowerCase().includes(q) ?? false) ||
(spool.suppliers?.some(
(link) =>
link.supplier_name.toLowerCase().includes(q) ||
(link.supplier_article_number?.toLowerCase().includes(q) ?? false)
) ?? false)
);
}