mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Suppliers as a managed list with per-spool assignments (#2996)
This commit is contained in:
@@ -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()]
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'アーカイブ',
|
||||
|
||||
@@ -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: '보관',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "Архивировать",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: '归档',
|
||||
|
||||
@@ -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: '歸檔',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user