mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
This commit is contained in:
@@ -2,8 +2,8 @@ import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
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.ext.asyncio import AsyncSession
|
||||
@@ -38,6 +38,13 @@ from backend.app.schemas.spool import (
|
||||
normalize_extra_colors,
|
||||
)
|
||||
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
|
||||
from backend.app.services.spool_csv import (
|
||||
MAX_CSV_IMPORT_BYTES,
|
||||
ImportPreview,
|
||||
ImportResult,
|
||||
parse_and_validate,
|
||||
serialize,
|
||||
)
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
@@ -52,6 +59,10 @@ _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["inventory"])
|
||||
|
||||
# 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
|
||||
|
||||
# FilamentColors.xyz API
|
||||
FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
|
||||
|
||||
@@ -952,6 +963,92 @@ async def list_spools(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ── CSV import / export (#1576) ──────────────────────────────────────────────
|
||||
# Declared before the dynamic `/spools/{spool_id}` route below so the literal
|
||||
# `export` / `import` segments match here instead of being parsed as an int id.
|
||||
|
||||
|
||||
@router.get("/spools/export")
|
||||
async def export_spools_csv(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
||||
):
|
||||
"""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)
|
||||
result = await db.execute(query)
|
||||
spools = list(result.scalars().all())
|
||||
content = serialize(spools)
|
||||
# Date-stamp the filename so repeat exports don't overwrite each other in
|
||||
# the browser's default download folder.
|
||||
filename = f"bambuddy_inventory_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/spools/import", response_model=ImportPreview | ImportResult)
|
||||
async def import_spools_csv(
|
||||
file: UploadFile = File(...),
|
||||
dry_run: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Import spools from a CSV file.
|
||||
|
||||
With ``dry_run=true`` returns an ImportPreview (per-row valid/error/skipped,
|
||||
colours resolved) and writes nothing — the UI shows this before the user
|
||||
confirms. With ``dry_run=false`` it validates the same way and then persists
|
||||
only the valid rows in a single transaction (invalid rows are skipped, the
|
||||
user fixes the CSV and re-uploads), returning an ImportResult summary.
|
||||
"""
|
||||
|
||||
def _too_large() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"code": "csv_import_too_large",
|
||||
"message": f"CSV file exceeds the {MAX_CSV_IMPORT_BYTES // (1024 * 1024)} MB limit.",
|
||||
},
|
||||
)
|
||||
|
||||
# Reject by declared size first (fast path when Content-Length is set), then
|
||||
# read in bounded chunks and bail the moment the accumulated body crosses the
|
||||
# cap — file.size is None for chunked uploads, so the loop is what actually
|
||||
# keeps an oversized stream from filling memory.
|
||||
if file.size is not None and file.size > MAX_CSV_IMPORT_BYTES:
|
||||
raise _too_large()
|
||||
raw = bytearray()
|
||||
while chunk := await file.read(_CSV_UPLOAD_CHUNK_BYTES):
|
||||
raw.extend(chunk)
|
||||
if len(raw) > MAX_CSV_IMPORT_BYTES:
|
||||
raise _too_large()
|
||||
preview = await parse_and_validate(bytes(raw), db)
|
||||
|
||||
if dry_run:
|
||||
return preview
|
||||
|
||||
created = 0
|
||||
for row in preview.rows:
|
||||
if row.status == "valid" and row.spool is not None:
|
||||
db.add(Spool(**row.spool))
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
await db.commit()
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
|
||||
return ImportResult(
|
||||
created=created,
|
||||
skipped=preview.skipped_count,
|
||||
errors=preview.error_count,
|
||||
error_rows=[r for r in preview.rows if r.status == "error"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/spools/{spool_id}", response_model=SpoolResponse)
|
||||
async def get_spool(
|
||||
spool_id: int,
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
"""CSV import/export for the spool inventory (#1576).
|
||||
|
||||
One module owns the round-trip: the same fixed column schema is used to
|
||||
serialise existing spools out and to parse + validate a user-supplied CSV
|
||||
back in. Validation reuses the `SpoolCreate` Pydantic model so the CSV path
|
||||
and the form path share a single source of truth — anything the form rejects,
|
||||
the import rejects too, with the same rules.
|
||||
|
||||
The import flow is two-phase by design: `parse_and_validate()` never writes.
|
||||
The route calls it once for the dry-run preview (so the user sees per-row
|
||||
valid/error/skipped before committing) and again on confirm, then persists
|
||||
only the rows that came back `valid`.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.color_catalog import ColorCatalogEntry
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.schemas.spool import SpoolCreate
|
||||
|
||||
# Fixed CSV header, in output order. Round-trips cleanly: export writes these
|
||||
# columns, import expects them. `material` is the only required field; the rest
|
||||
# are optional. Keep aligned with the SpoolCreate fields referenced below.
|
||||
#
|
||||
# `remaining` is a derived, export-only column (= label_weight - weight_used).
|
||||
# It's written out for human readability and round-trip clarity, but ignored on
|
||||
# import — `weight_used` is the source of truth, and accepting both would let
|
||||
# them contradict. `last_used` is a timestamp the model carries but SpoolCreate
|
||||
# does not, so import applies it to the ORM object directly (see persist path).
|
||||
# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate
|
||||
# fields included so a round-trip preserves them (they'd otherwise be lost).
|
||||
CSV_COLUMNS = [
|
||||
"material",
|
||||
"brand",
|
||||
"subtype",
|
||||
"color_name",
|
||||
"rgba",
|
||||
"extra_colors",
|
||||
"effect_type",
|
||||
"label_weight",
|
||||
"weight_used",
|
||||
"remaining",
|
||||
"cost_per_kg",
|
||||
"nozzle_temp_min",
|
||||
"nozzle_temp_max",
|
||||
"last_used",
|
||||
"note",
|
||||
"storage_location",
|
||||
"category",
|
||||
"low_stock_threshold_pct",
|
||||
]
|
||||
|
||||
# Upload ceiling for the import endpoint. A spool inventory CSV is a few KB
|
||||
# even with thousands of rows; 5 MB is a generous cap that still refuses an
|
||||
# OOM-sized body before it's read into memory.
|
||||
MAX_CSV_IMPORT_BYTES = 5 * 1024 * 1024
|
||||
|
||||
# Spreadsheet formula-injection guard. A cell whose first character is one of
|
||||
# these is treated as a formula by Excel / LibreOffice / Sheets; we prefix it
|
||||
# with a single quote on export so the value renders as literal text.
|
||||
_FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
||||
|
||||
# Columns whose CSV cell must be coerced to a number before SpoolCreate sees it.
|
||||
# DictReader hands us strings; SpoolCreate wants int/float. Empty cell → omit
|
||||
# the field (falls back to the schema default / None).
|
||||
_INT_COLUMNS = {"label_weight", "nozzle_temp_min", "nozzle_temp_max", "low_stock_threshold_pct"}
|
||||
_FLOAT_COLUMNS = {"cost_per_kg", "weight_used"}
|
||||
|
||||
# label_weight default, pulled from the schema so the weight_used bounds check
|
||||
# stays in sync if the schema default ever changes.
|
||||
_DEFAULT_LABEL_WEIGHT = SpoolCreate.model_fields["label_weight"].default
|
||||
|
||||
|
||||
class ImportRowResult(BaseModel):
|
||||
"""Per-row outcome of a parse+validate pass.
|
||||
|
||||
`spool` carries the validated, SpoolCreate-shaped dict for `valid` rows so
|
||||
the route can persist without re-parsing. `resolved_color` flags rows whose
|
||||
rgba/extra_colors/effect_type were filled in from the Color Catalog rather
|
||||
than supplied in the CSV — surfaced in the preview so the user knows a
|
||||
colour was inferred.
|
||||
"""
|
||||
|
||||
row_number: int # 1-based data row (header is not counted)
|
||||
status: str # "valid" | "error" | "skipped"
|
||||
reason: str | None = None
|
||||
material: str | None = None
|
||||
brand: str | None = None
|
||||
color_name: str | None = None
|
||||
rgba: str | None = None
|
||||
resolved_color: bool = False
|
||||
# True when the colour was resolved from a catalog entry of a DIFFERENT
|
||||
# material (no exact material match existed). Surfaced so the preview can
|
||||
# warn the user the colour came from another material's variant.
|
||||
cross_material_color: bool = False
|
||||
# True when an active spool with the same material+brand+color_name already
|
||||
# exists. Informational only — the import still creates the row (there's no
|
||||
# unique constraint); the preview warns so a double-click / re-upload of the
|
||||
# same CSV doesn't silently duplicate the inventory.
|
||||
duplicate_of_existing: bool = False
|
||||
spool: dict | None = None
|
||||
|
||||
|
||||
class ImportPreview(BaseModel):
|
||||
"""Result of a dry-run (or the pre-write pass of a real import)."""
|
||||
|
||||
columns: list[str]
|
||||
total: int
|
||||
valid_count: int
|
||||
error_count: int
|
||||
skipped_count: int
|
||||
rows: list[ImportRowResult]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""Summary returned after a real (non-dry-run) import."""
|
||||
|
||||
created: int
|
||||
skipped: int
|
||||
errors: int
|
||||
error_rows: list[ImportRowResult] = []
|
||||
|
||||
|
||||
def _normalize_header(name: str) -> str:
|
||||
"""Map a CSV header cell to a canonical field name.
|
||||
|
||||
Case- and space-tolerant: "Color Name", "color-name", " COLOR_NAME "
|
||||
all collapse to "color_name".
|
||||
"""
|
||||
return name.strip().lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
|
||||
def _normalize_rgba(value: str) -> str | None:
|
||||
"""Coerce a user-supplied colour cell to 8-char RRGGBBAA hex, or None.
|
||||
|
||||
Accepts an optional leading `#` and a 6-char RRGGBB form (alpha defaults to
|
||||
`ff`). Returns None if the value isn't valid hex of length 6 or 8 — the
|
||||
caller turns that into a row error so it isn't silently dropped.
|
||||
"""
|
||||
raw = value.strip().lstrip("#")
|
||||
if len(raw) not in (6, 8):
|
||||
return None
|
||||
try:
|
||||
int(raw, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
if len(raw) == 6:
|
||||
raw += "ff"
|
||||
return raw.lower()
|
||||
|
||||
|
||||
def _parse_datetime(value: str) -> datetime | None:
|
||||
"""Parse an ISO-8601 timestamp, or None if it isn't valid.
|
||||
|
||||
Accepts what `datetime.isoformat()` produces (what export writes) plus a
|
||||
trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
|
||||
"""
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:
|
||||
"""Load the whole Color Catalog once so per-row resolution is in-memory.
|
||||
|
||||
A CSV can hold hundreds of rows; resolving each with its own SELECT would
|
||||
be an N+1 against a small, rarely-changing table. We pull it once here and
|
||||
let `_resolve_color` match against the list.
|
||||
"""
|
||||
result = await db.execute(select(ColorCatalogEntry))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _spool_key(material: str | None, brand: str | None, color_name: str | None) -> tuple[str, str, str]:
|
||||
"""Case/space-insensitive identity used for the duplicate soft-warn."""
|
||||
return (
|
||||
(material or "").strip().lower(),
|
||||
(brand or "").strip().lower(),
|
||||
(color_name or "").strip().lower(),
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Spool has no unique constraint, so a double-click or re-upload of the same
|
||||
CSV would silently duplicate the inventory. We pull the active spools' keys
|
||||
once and let the preview flag matching rows — informational only, the import
|
||||
still creates them.
|
||||
"""
|
||||
result = await db.execute(select(Spool.material, Spool.brand, Spool.color_name).where(Spool.archived_at.is_(None)))
|
||||
return {_spool_key(m, b, c) for m, b, c in result.all()}
|
||||
|
||||
|
||||
def _resolve_color(
|
||||
catalog: list[ColorCatalogEntry], brand: str | None, color_name: str | None, material: str | None
|
||||
) -> tuple[str, str | None, str | None, bool] | None:
|
||||
"""Match brand + color_name against the preloaded catalog (case-insensitive).
|
||||
|
||||
Returns (rgba, extra_colors, effect_type, cross_material) on a match, else
|
||||
None. Prefers an entry whose material matches the row; a catalog entry with
|
||||
a NULL material is the project's "matches any material" convention and counts
|
||||
as an exact match too. Only when neither exists does it fall back to another
|
||||
material's entry and set cross_material=True so the caller can warn that the
|
||||
colour came from a different material's variant.
|
||||
"""
|
||||
if not brand or not color_name:
|
||||
return None
|
||||
|
||||
brand_l = brand.strip().lower()
|
||||
name_l = color_name.strip().lower()
|
||||
material_l = material.strip().lower() if material else None
|
||||
|
||||
matches = [
|
||||
entry
|
||||
for entry in catalog
|
||||
if entry.hex_color and entry.manufacturer.lower() == brand_l and entry.color_name.lower() == name_l
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
exact = next(
|
||||
(e for e in matches if e.material is None or (material_l and e.material.lower() == material_l)),
|
||||
None,
|
||||
)
|
||||
row = exact or matches[0]
|
||||
cross_material = exact is None
|
||||
|
||||
rgba = _normalize_rgba(row.hex_color)
|
||||
if rgba is None:
|
||||
return None
|
||||
return rgba, row.extra_colors, row.effect_type, cross_material
|
||||
|
||||
|
||||
def _readable_validation_error(exc: ValidationError) -> str:
|
||||
"""Flatten a Pydantic ValidationError into one short, user-facing line."""
|
||||
parts = []
|
||||
for err in exc.errors():
|
||||
loc = ".".join(str(p) for p in err.get("loc", ())) or "value"
|
||||
parts.append(f"{loc}: {err.get('msg', 'invalid')}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _empty_preview(warnings: list[str]) -> ImportPreview:
|
||||
"""A preview with no rows — used for the early-exit cases (bad/empty file)."""
|
||||
return ImportPreview(
|
||||
columns=CSV_COLUMNS,
|
||||
total=0,
|
||||
valid_count=0,
|
||||
error_count=0,
|
||||
skipped_count=0,
|
||||
rows=[],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPreview:
|
||||
"""Parse a CSV blob, validate + colour-resolve each row. Never writes.
|
||||
|
||||
Decodes UTF-8 (BOM tolerant), reads with DictReader against the fixed
|
||||
schema, and classifies each row as valid / error / skipped. Valid rows
|
||||
carry a SpoolCreate-shaped `spool` dict ready to persist.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
text = raw_bytes.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
return _empty_preview(["File is not valid UTF-8 text."])
|
||||
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
try:
|
||||
header = next(reader)
|
||||
except StopIteration:
|
||||
return _empty_preview(["CSV is empty."])
|
||||
|
||||
norm_header = [_normalize_header(h) for h in header]
|
||||
known = set(CSV_COLUMNS)
|
||||
unknown = [h for h in norm_header if h and h not in known]
|
||||
if unknown:
|
||||
warnings.append(f"Ignoring unknown columns: {', '.join(unknown)}")
|
||||
# Map canonical field name → column index in this file (first occurrence).
|
||||
col_index: dict[str, int] = {}
|
||||
for idx, h in enumerate(norm_header):
|
||||
if h in known and h not in col_index:
|
||||
col_index[h] = idx
|
||||
|
||||
if "material" not in col_index:
|
||||
return _empty_preview(warnings + ["Required column 'material' is missing from the header."])
|
||||
|
||||
# Pull the catalog and the existing-spool keys once; per-row colour
|
||||
# resolution and the duplicate soft-warn both match in memory rather than
|
||||
# issuing a SELECT per row.
|
||||
catalog = await _load_color_catalog(db)
|
||||
existing_keys = await _load_existing_spool_keys(db)
|
||||
|
||||
def cell(row: list[str], field: str) -> str:
|
||||
idx = col_index.get(field)
|
||||
if idx is None or idx >= len(row):
|
||||
return ""
|
||||
# Strip whitespace, then undo any export-side formula-injection quoting
|
||||
# so export → import round-trips without accumulating a leading quote.
|
||||
return _desanitize_cell(row[idx].strip())
|
||||
|
||||
rows: list[ImportRowResult] = []
|
||||
valid = error = skipped = 0
|
||||
|
||||
for row_number, raw_row in enumerate(reader, start=1):
|
||||
# Fully blank row (no non-empty cell) → skip silently.
|
||||
if not any(c.strip() for c in raw_row):
|
||||
rows.append(ImportRowResult(row_number=row_number, status="skipped", reason="Empty row"))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
material = cell(raw_row, "material")
|
||||
brand = cell(raw_row, "brand") or None
|
||||
color_name = cell(raw_row, "color_name") or None
|
||||
|
||||
if not material:
|
||||
rows.append(
|
||||
ImportRowResult(
|
||||
row_number=row_number,
|
||||
status="error",
|
||||
reason="material is required",
|
||||
brand=brand,
|
||||
color_name=color_name,
|
||||
)
|
||||
)
|
||||
error += 1
|
||||
continue
|
||||
|
||||
data: dict = {"material": material}
|
||||
if brand:
|
||||
data["brand"] = brand
|
||||
if color_name:
|
||||
data["color_name"] = color_name
|
||||
|
||||
row_error: str | None = None
|
||||
|
||||
# Plain text passthrough columns.
|
||||
for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"):
|
||||
value = cell(raw_row, field)
|
||||
if value:
|
||||
data[field] = value
|
||||
|
||||
# Numeric columns: parse only if present, else leave to schema defaults.
|
||||
for field in _INT_COLUMNS:
|
||||
value = cell(raw_row, field)
|
||||
if value:
|
||||
try:
|
||||
data[field] = int(value)
|
||||
except ValueError:
|
||||
row_error = f"{field} must be a whole number (got '{value}')"
|
||||
break
|
||||
if row_error is None:
|
||||
for field in _FLOAT_COLUMNS:
|
||||
value = cell(raw_row, field)
|
||||
if value:
|
||||
try:
|
||||
data[field] = float(value)
|
||||
except ValueError:
|
||||
row_error = f"{field} must be a number (got '{value}')"
|
||||
break
|
||||
|
||||
# Bounds check: weight_used must be within [0, label_weight]. The schema
|
||||
# accepts any float, so a negative or over-full value would otherwise be
|
||||
# imported silently. label_weight falls back to the schema default when
|
||||
# the CSV omits it.
|
||||
if row_error is None and "weight_used" in data:
|
||||
used = data["weight_used"]
|
||||
label = data.get("label_weight", _DEFAULT_LABEL_WEIGHT)
|
||||
if used < 0:
|
||||
row_error = f"weight_used cannot be negative (got {used})"
|
||||
elif used > label:
|
||||
row_error = f"weight_used ({used}) exceeds label_weight ({label})"
|
||||
|
||||
# `last_used` is an ORM-only timestamp (not on SpoolCreate); parse it
|
||||
# here and apply it to the validated dict after the SpoolCreate gate.
|
||||
last_used: datetime | None = None
|
||||
if row_error is None:
|
||||
last_used_cell = cell(raw_row, "last_used")
|
||||
if last_used_cell:
|
||||
last_used = _parse_datetime(last_used_cell)
|
||||
if last_used is None:
|
||||
row_error = f"last_used must be an ISO date/time (got '{last_used_cell}')"
|
||||
|
||||
resolved_color = False
|
||||
cross_material_color = False
|
||||
if row_error is None:
|
||||
# Colour precedence: explicit rgba wins; else resolve brand+name
|
||||
# from the catalog; else leave blank.
|
||||
rgba_cell = cell(raw_row, "rgba")
|
||||
if rgba_cell:
|
||||
normalized = _normalize_rgba(rgba_cell)
|
||||
if normalized is None:
|
||||
row_error = f"rgba must be 6- or 8-char hex (got '{rgba_cell}')"
|
||||
else:
|
||||
data["rgba"] = normalized
|
||||
else:
|
||||
resolved = _resolve_color(catalog, brand, color_name, material)
|
||||
if resolved is not None:
|
||||
rgba_val, extra_val, effect_val, cross_material_color = resolved
|
||||
data["rgba"] = rgba_val
|
||||
# CSV-supplied extra_colors/effect_type take precedence over
|
||||
# the catalog's; only fill from catalog when absent.
|
||||
if extra_val and "extra_colors" not in data:
|
||||
data["extra_colors"] = extra_val
|
||||
if effect_val and "effect_type" not in data:
|
||||
data["effect_type"] = effect_val
|
||||
resolved_color = True
|
||||
|
||||
if row_error is not None:
|
||||
rows.append(
|
||||
ImportRowResult(
|
||||
row_number=row_number,
|
||||
status="error",
|
||||
reason=row_error,
|
||||
material=material,
|
||||
brand=brand,
|
||||
color_name=color_name,
|
||||
)
|
||||
)
|
||||
error += 1
|
||||
continue
|
||||
|
||||
# Final gate: SpoolCreate runs the same validators the form uses
|
||||
# (rgba pattern, extra_colors/effect_type normalisation, bounds).
|
||||
try:
|
||||
spool = SpoolCreate(**data)
|
||||
except ValidationError as exc:
|
||||
rows.append(
|
||||
ImportRowResult(
|
||||
row_number=row_number,
|
||||
status="error",
|
||||
reason=_readable_validation_error(exc),
|
||||
material=material,
|
||||
brand=brand,
|
||||
color_name=color_name,
|
||||
)
|
||||
)
|
||||
error += 1
|
||||
continue
|
||||
|
||||
spool_data = spool.model_dump()
|
||||
if last_used is not None:
|
||||
# last_used isn't a SpoolCreate field; graft it onto the persisted
|
||||
# dict so the ORM object carries it.
|
||||
spool_data["last_used"] = last_used
|
||||
|
||||
rows.append(
|
||||
ImportRowResult(
|
||||
row_number=row_number,
|
||||
status="valid",
|
||||
material=material,
|
||||
brand=brand,
|
||||
color_name=color_name,
|
||||
rgba=spool.rgba,
|
||||
resolved_color=resolved_color,
|
||||
cross_material_color=cross_material_color,
|
||||
duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
|
||||
spool=spool_data,
|
||||
)
|
||||
)
|
||||
valid += 1
|
||||
|
||||
return ImportPreview(
|
||||
columns=CSV_COLUMNS,
|
||||
total=valid + error + skipped,
|
||||
valid_count=valid,
|
||||
error_count=error,
|
||||
skipped_count=skipped,
|
||||
rows=rows,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def serialize(spools: list[Spool]) -> bytes:
|
||||
"""Render spools to CSV bytes using the fixed schema (export side).
|
||||
|
||||
rgba is written without a leading `#`, matching the import-side
|
||||
normalisation, so export → import round-trips without transformation.
|
||||
`remaining` is derived (label_weight - weight_used) and `last_used` is
|
||||
written as ISO-8601; empty/None fields become empty cells.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(CSV_COLUMNS)
|
||||
for spool in spools:
|
||||
writer.writerow([_sanitize_cell(_cell_value(spool, col)) for col in CSV_COLUMNS])
|
||||
return output.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _sanitize_cell(value: str) -> str:
|
||||
"""Neutralise spreadsheet formula injection.
|
||||
|
||||
A free-text field (note, color_name) starting with =, +, -, @, tab, or CR
|
||||
is evaluated as a formula by Excel/Sheets/LibreOffice when the CSV is
|
||||
opened. Prefixing with a single quote forces it to render as literal text.
|
||||
`_desanitize_cell` is the exact inverse, applied on import.
|
||||
"""
|
||||
if value and value[0] in _FORMULA_INJECTION_PREFIXES:
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
def _desanitize_cell(value: str) -> str:
|
||||
"""Undo `_sanitize_cell` on import so the round-trip is lossless.
|
||||
|
||||
Export prefixes formula-looking cells with a single quote; strip exactly
|
||||
that quote back off when the next character is one of the guarded prefixes,
|
||||
so `'=SUM(A1)` reads back as `=SUM(A1)` and the value doesn't accumulate a
|
||||
leading quote on every export→import cycle. A quote followed by anything
|
||||
else is left untouched — only the prefix `_sanitize_cell` could have added
|
||||
is removed.
|
||||
"""
|
||||
if len(value) >= 2 and value[0] == "'" and value[1] in _FORMULA_INJECTION_PREFIXES:
|
||||
return value[1:]
|
||||
return value
|
||||
|
||||
|
||||
def _cell_value(spool: Spool, col: str) -> str:
|
||||
"""Render one spool field for export. Handles the derived `remaining`
|
||||
column and ISO-formats `last_used`; everything else is str() of the value."""
|
||||
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))))
|
||||
value = getattr(spool, col, None)
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
# Whole-number floats (weight_used, cost_per_kg) export as ints — "300",
|
||||
# not "300.0" — for a cleaner, human-friendly CSV. import re-parses fine.
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Integration tests for inventory CSV import/export (#1576).
|
||||
|
||||
Covers the export → import round-trip, dry-run preview (no writes), real
|
||||
import (only valid rows persisted, atomically), and Color Catalog resolution
|
||||
of brand + color_name → rgba.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.color_catalog import ColorCatalogEntry
|
||||
from backend.app.models.spool import Spool
|
||||
|
||||
|
||||
def _csv_upload(text: str):
|
||||
"""Build the multipart `files=` payload for the import endpoint."""
|
||||
return {"file": ("inventory.csv", text.encode("utf-8"), "text/csv")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvExport:
|
||||
async def test_export_returns_csv_with_header_and_rows(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
db_session.add(
|
||||
Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff", label_weight=1000)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/api/v1/inventory/spools/export")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"].startswith("text/csv")
|
||||
body = response.text
|
||||
lines = body.strip().splitlines()
|
||||
# Header row uses the fixed schema.
|
||||
assert lines[0].split(",")[0] == "material"
|
||||
assert "rgba" in lines[0]
|
||||
# Data row present, rgba written without leading '#'.
|
||||
assert "Polymaker" in body
|
||||
assert "e8e8e8ff" in body
|
||||
assert "#e8e8e8ff" not in body
|
||||
|
||||
async def test_export_excludes_archived(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
db_session.add(Spool(material="PLA", brand="Active", color_name="A", rgba="ffffffff"))
|
||||
db_session.add(
|
||||
Spool(
|
||||
material="PETG",
|
||||
brand="Archived",
|
||||
color_name="B",
|
||||
rgba="000000ff",
|
||||
archived_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/api/v1/inventory/spools/export")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert "Active" in response.text
|
||||
assert "Archived" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvImportDryRun:
|
||||
async def test_dry_run_classifies_rows_and_writes_nothing(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
csv_text = (
|
||||
"material,brand,color_name,rgba,label_weight\n"
|
||||
"PLA,Polymaker,Jade White,e8e8e8ff,1000\n" # valid
|
||||
",Polymaker,No Material,ffffffff,1000\n" # error: material missing
|
||||
"PETG,Brand,Bad Hex,zzzz,1000\n" # error: invalid rgba
|
||||
"\n" # skipped: blank
|
||||
"ABS,Brand,Color,#00ff00,500\n" # valid: 6-char + '#' tolerated
|
||||
)
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["valid_count"] == 2
|
||||
assert data["error_count"] == 2
|
||||
assert data["skipped_count"] == 1
|
||||
# 6-char hex got normalised to 8-char.
|
||||
valid_rows = [r for r in data["rows"] if r["status"] == "valid"]
|
||||
green = next(r for r in valid_rows if r["color_name"] == "Color")
|
||||
assert green["rgba"] == "00ff00ff"
|
||||
|
||||
# Nothing was written.
|
||||
result = await db_session.execute(select(Spool))
|
||||
assert result.scalars().first() is None
|
||||
|
||||
async def test_missing_material_column_fails_whole_file(self, async_client: AsyncClient):
|
||||
csv_text = "brand,color_name\nPolymaker,Jade White\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["valid_count"] == 0
|
||||
assert any("material" in w for w in data["warnings"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvImportReal:
|
||||
async def test_import_persists_only_valid_rows(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
csv_text = (
|
||||
"material,brand,color_name,rgba\n"
|
||||
"PLA,Polymaker,White,ffffffff\n" # valid
|
||||
",Polymaker,No Material,ffffffff\n" # error
|
||||
"PETG,Brand,Color,ff0000ff\n" # valid
|
||||
)
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["created"] == 2
|
||||
assert data["errors"] == 1
|
||||
assert len(data["error_rows"]) == 1
|
||||
|
||||
result = await db_session.execute(select(Spool).order_by(Spool.material))
|
||||
spools = result.scalars().all()
|
||||
assert len(spools) == 2
|
||||
assert {s.material for s in spools} == {"PLA", "PETG"}
|
||||
|
||||
async def test_case_and_space_tolerant_headers(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
csv_text = "Material, Color Name ,RGBA\nPLA,Snow,ffffffff\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["created"] == 1
|
||||
result = await db_session.execute(select(Spool))
|
||||
spool = result.scalars().one()
|
||||
assert spool.color_name == "Snow"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvColorResolution:
|
||||
async def test_brand_and_color_resolve_rgba_from_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
db_session.add(
|
||||
ColorCatalogEntry(
|
||||
manufacturer="Polymaker",
|
||||
color_name="Jade White",
|
||||
hex_color="#E8E8E8",
|
||||
material="PLA",
|
||||
is_default=False,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# No rgba in CSV — resolved from catalog (case-insensitive match).
|
||||
csv_text = "material,brand,color_name\nPLA,polymaker,jade white\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["valid_count"] == 1
|
||||
row = data["rows"][0]
|
||||
assert row["resolved_color"] is True
|
||||
assert row["rgba"] == "e8e8e8ff"
|
||||
|
||||
async def test_explicit_rgba_wins_over_catalog(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
db_session.add(
|
||||
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
csv_text = "material,brand,color_name,rgba\nPLA,Polymaker,Jade White,123456ff\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
row = response.json()["rows"][0]
|
||||
assert row["rgba"] == "123456ff"
|
||||
assert row["resolved_color"] is False
|
||||
|
||||
async def test_cross_material_fallback_is_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
# Catalog only has a PLA variant of this colour; a PETG row resolves it
|
||||
# via cross-material fallback and must be flagged.
|
||||
db_session.add(
|
||||
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PLA")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
row = response.json()["rows"][0]
|
||||
assert row["resolved_color"] is True
|
||||
assert row["cross_material_color"] is True
|
||||
assert row["rgba"] == "e8e8e8ff"
|
||||
|
||||
async def test_exact_material_match_not_flagged(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
db_session.add(
|
||||
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material="PETG")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
csv_text = "material,brand,color_name\nPETG,Polymaker,Jade White\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
row = response.json()["rows"][0]
|
||||
assert row["resolved_color"] is True
|
||||
assert row["cross_material_color"] is False
|
||||
|
||||
async def test_generic_material_catalog_entry_not_flagged(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
# A NULL-material catalog entry is the project's "matches any material"
|
||||
# convention — resolving a PLA row from it is an exact match, not a
|
||||
# cross-material fallback, so it must not raise the yellow warning.
|
||||
db_session.add(
|
||||
ColorCatalogEntry(manufacturer="Polymaker", color_name="Jade White", hex_color="#E8E8E8", material=None)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
csv_text = "material,brand,color_name\nPLA,Polymaker,Jade White\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
row = response.json()["rows"][0]
|
||||
assert row["resolved_color"] is True
|
||||
assert row["cross_material_color"] is False
|
||||
assert row["rgba"] == "e8e8e8ff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvReviewFollowups:
|
||||
"""Covers the maintainer-requested hardening (PR #1659 review)."""
|
||||
|
||||
async def test_oversized_upload_rejected_413(self, async_client: AsyncClient):
|
||||
# Build a body just over the 5 MB cap.
|
||||
from backend.app.services.spool_csv import MAX_CSV_IMPORT_BYTES
|
||||
|
||||
header = "material\n"
|
||||
filler = "PLA\n" * ((MAX_CSV_IMPORT_BYTES // 4) + 10)
|
||||
big = header + filler
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(big))
|
||||
|
||||
assert response.status_code == 413, response.text
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "csv_import_too_large"
|
||||
|
||||
async def test_weight_used_negative_is_error(self, async_client: AsyncClient):
|
||||
csv_text = "material,color_name,rgba,weight_used\nPLA,X,ffffffff,-5\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["error_count"] == 1
|
||||
assert "weight_used" in data["rows"][0]["reason"]
|
||||
|
||||
async def test_weight_used_exceeds_label_is_error(self, async_client: AsyncClient):
|
||||
csv_text = "material,color_name,rgba,label_weight,weight_used\nPLA,X,ffffffff,1000,1500\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["error_count"] == 1
|
||||
assert "exceeds" in data["rows"][0]["reason"]
|
||||
|
||||
async def test_export_neutralises_formula_injection(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
# A note starting with '=' must be prefixed with a quote on export so
|
||||
# spreadsheets don't evaluate it as a formula.
|
||||
db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1:A9)"))
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/api/v1/inventory/spools/export")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert "'=SUM(A1:A9)" in response.text
|
||||
|
||||
async def test_formula_injection_round_trips_without_quote_accumulation(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
# The export quote-guard must be undone on import so a formula-looking
|
||||
# note survives export → import unchanged (no accumulating leading ').
|
||||
db_session.add(Spool(material="PLA", color_name="X", rgba="ffffffff", note="=SUM(A1)"))
|
||||
await db_session.commit()
|
||||
|
||||
export = await async_client.get("/api/v1/inventory/spools/export")
|
||||
assert export.status_code == 200, export.text
|
||||
assert "'=SUM(A1)" in export.text # guarded on export
|
||||
|
||||
# Wipe, re-import the exact export, and confirm the note is restored
|
||||
# to its original value (not "'=SUM(A1)").
|
||||
existing = await db_session.execute(select(Spool))
|
||||
for spool in existing.scalars().all():
|
||||
await db_session.delete(spool)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(export.text))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["created"] == 1
|
||||
|
||||
result = await db_session.execute(select(Spool))
|
||||
spool = result.scalars().one()
|
||||
assert spool.note == "=SUM(A1)" # original value, no leading quote
|
||||
|
||||
async def test_export_filename_is_date_stamped(self, async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/inventory/spools/export")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
disposition = response.headers.get("content-disposition", "")
|
||||
assert "bambuddy_inventory_" in disposition
|
||||
assert disposition.rstrip('"').endswith(".csv")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvRoundTrip:
|
||||
async def test_export_then_import_recreates_spools(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
db_session.add(
|
||||
Spool(
|
||||
material="PLA",
|
||||
brand="Polymaker",
|
||||
subtype="Matte",
|
||||
color_name="Jade White",
|
||||
rgba="e8e8e8ff",
|
||||
label_weight=1000,
|
||||
weight_used=250,
|
||||
cost_per_kg=24.99,
|
||||
note="batch order",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
export = await async_client.get("/api/v1/inventory/spools/export")
|
||||
assert export.status_code == 200, export.text
|
||||
csv_text = export.text
|
||||
|
||||
# Wipe and re-import the exact export.
|
||||
existing = await db_session.execute(select(Spool))
|
||||
for spool in existing.scalars().all():
|
||||
await db_session.delete(spool)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["created"] == 1
|
||||
|
||||
result = await db_session.execute(select(Spool))
|
||||
spool = result.scalars().one()
|
||||
assert spool.material == "PLA"
|
||||
assert spool.brand == "Polymaker"
|
||||
assert spool.subtype == "Matte"
|
||||
assert spool.color_name == "Jade White"
|
||||
assert spool.rgba == "e8e8e8ff"
|
||||
assert spool.label_weight == 1000
|
||||
assert spool.weight_used == 250 # usage round-trips
|
||||
assert spool.cost_per_kg == 24.99
|
||||
assert spool.note == "batch order"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvUsageColumns:
|
||||
async def test_export_writes_weight_used_and_derived_remaining(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
db_session.add(
|
||||
Spool(
|
||||
material="PLA",
|
||||
brand="Polymaker",
|
||||
color_name="White",
|
||||
rgba="ffffffff",
|
||||
label_weight=1000,
|
||||
weight_used=300,
|
||||
last_used=datetime(2026, 6, 1, 12, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/api/v1/inventory/spools/export")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
header, row = response.text.strip().splitlines()[:2]
|
||||
cols = header.split(",")
|
||||
cells = row.split(",")
|
||||
record = dict(zip(cols, cells, strict=False))
|
||||
assert record["weight_used"] == "300"
|
||||
assert record["remaining"] == "700" # 1000 - 300, derived
|
||||
assert record["last_used"].startswith("2026-06-01T12:30")
|
||||
|
||||
async def test_import_reads_weight_used_ignores_remaining(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
# remaining is intentionally contradictory — it must be ignored; only
|
||||
# weight_used is read back.
|
||||
csv_text = (
|
||||
"material,brand,color_name,rgba,label_weight,weight_used,remaining\nPLA,Brand,White,ffffffff,1000,400,999\n"
|
||||
)
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["created"] == 1
|
||||
result = await db_session.execute(select(Spool))
|
||||
spool = result.scalars().one()
|
||||
assert spool.weight_used == 400 # from CSV
|
||||
assert spool.label_weight == 1000 # remaining=999 ignored, not used to back-compute
|
||||
|
||||
async def test_import_parses_last_used_iso(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,2026-06-01T12:30:00+00:00\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["created"] == 1
|
||||
result = await db_session.execute(select(Spool))
|
||||
spool = result.scalars().one()
|
||||
assert spool.last_used is not None
|
||||
assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
|
||||
|
||||
async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
|
||||
csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["error_count"] == 1
|
||||
assert "last_used" in data["rows"][0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvExtraColumns:
|
||||
async def test_storage_category_threshold_round_trip(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
# storage_location / category / low_stock_threshold_pct must survive an
|
||||
# export → import cycle (would otherwise be silently lost).
|
||||
db_session.add(
|
||||
Spool(
|
||||
material="PLA",
|
||||
brand="Polymaker",
|
||||
color_name="White",
|
||||
rgba="ffffffff",
|
||||
storage_location="Shelf B3",
|
||||
category="Production",
|
||||
low_stock_threshold_pct=20,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
csv_text = (await async_client.get("/api/v1/inventory/spools/export")).text
|
||||
for spool in (await db_session.execute(select(Spool))).scalars().all():
|
||||
await db_session.delete(spool)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["created"] == 1
|
||||
|
||||
spool = (await db_session.execute(select(Spool))).scalars().one()
|
||||
assert spool.storage_location == "Shelf B3"
|
||||
assert spool.category == "Production"
|
||||
assert spool.low_stock_threshold_pct == 20
|
||||
|
||||
async def test_low_stock_threshold_out_of_range_is_error(self, async_client: AsyncClient):
|
||||
# SpoolCreate bounds low_stock_threshold_pct to 1..99; the CSV path must
|
||||
# reject an out-of-range value rather than persist it.
|
||||
csv_text = "material,color_name,rgba,low_stock_threshold_pct\nPLA,White,ffffffff,150\n"
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["error_count"] == 1
|
||||
assert "low_stock_threshold_pct" in data["rows"][0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
class TestInventoryCsvDuplicateWarning:
|
||||
async def test_existing_spool_flags_duplicate_but_still_imports(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
db_session.add(Spool(material="PLA", brand="Polymaker", color_name="Jade White", rgba="e8e8e8ff"))
|
||||
await db_session.commit()
|
||||
|
||||
# Row 1 matches the existing spool (case-insensitively); row 2 is new.
|
||||
csv_text = (
|
||||
"material,brand,color_name,rgba\n"
|
||||
"pla,polymaker,jade white,e8e8e8ff\n" # duplicate of existing
|
||||
"PETG,OtherBrand,Black,000000ff\n" # new
|
||||
)
|
||||
|
||||
response = await async_client.post("/api/v1/inventory/spools/import?dry_run=true", files=_csv_upload(csv_text))
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
rows = response.json()["rows"]
|
||||
assert rows[0]["duplicate_of_existing"] is True
|
||||
assert rows[1]["duplicate_of_existing"] is False
|
||||
|
||||
# Soft-warn only: a real import still creates the duplicate row.
|
||||
real = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
|
||||
assert real.json()["created"] == 2
|
||||
all_spools = (await db_session.execute(select(Spool))).scalars().all()
|
||||
assert len(all_spools) == 3 # 1 pre-existing + 2 imported
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { SpoolCsvImportModal } from '../../components/SpoolCsvImportModal';
|
||||
import { api, type CsvImportPreview } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
importSpoolsCsvPreview: vi.fn(),
|
||||
importSpoolsCsv: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
||||
},
|
||||
}));
|
||||
|
||||
const preview: CsvImportPreview = {
|
||||
columns: ['material', 'brand', 'color_name', 'rgba'],
|
||||
total: 3,
|
||||
valid_count: 2,
|
||||
error_count: 1,
|
||||
skipped_count: 0,
|
||||
warnings: [],
|
||||
rows: [
|
||||
{ row_number: 1, status: 'valid', reason: null, material: 'PLA', brand: 'Polymaker', color_name: 'White', rgba: 'ffffffff', resolved_color: false, cross_material_color: false, duplicate_of_existing: false },
|
||||
{ row_number: 2, status: 'error', reason: 'material is required', material: null, brand: 'Polymaker', color_name: 'X', rgba: null, resolved_color: false, cross_material_color: false, duplicate_of_existing: false },
|
||||
{ row_number: 3, status: 'valid', reason: null, material: 'PETG', brand: 'Brand', color_name: 'Jade', rgba: 'e8e8e8ff', resolved_color: true, cross_material_color: true, duplicate_of_existing: false },
|
||||
],
|
||||
};
|
||||
|
||||
function selectFile() {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['material\nPLA\n'], 'inventory.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
return file;
|
||||
}
|
||||
|
||||
describe('SpoolCsvImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the preview table with per-row status after a file is chosen', async () => {
|
||||
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue(preview);
|
||||
|
||||
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
selectFile();
|
||||
|
||||
await waitFor(() => expect(api.importSpoolsCsvPreview).toHaveBeenCalledOnce());
|
||||
// Summary counts surfaced.
|
||||
expect(await screen.findByText('2 valid')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 error')).toBeInTheDocument();
|
||||
// Error reason rendered inline.
|
||||
expect(screen.getByText('material is required')).toBeInTheDocument();
|
||||
// Import button reflects the valid count.
|
||||
expect(screen.getByText('Import 2 valid rows')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('imports only when there are valid rows and reports the created count', async () => {
|
||||
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue(preview);
|
||||
vi.mocked(api.importSpoolsCsv).mockResolvedValue({ created: 2, skipped: 0, errors: 1, error_rows: [] });
|
||||
const onImported = vi.fn();
|
||||
|
||||
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={onImported} />);
|
||||
selectFile();
|
||||
|
||||
const importBtn = await screen.findByText('Import 2 valid rows');
|
||||
fireEvent.click(importBtn);
|
||||
|
||||
await waitFor(() => expect(api.importSpoolsCsv).toHaveBeenCalledOnce());
|
||||
expect(onImported).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('disables import when no rows are valid', async () => {
|
||||
vi.mocked(api.importSpoolsCsvPreview).mockResolvedValue({
|
||||
...preview,
|
||||
valid_count: 0,
|
||||
error_count: 1,
|
||||
rows: [preview.rows[1]],
|
||||
});
|
||||
|
||||
render(<SpoolCsvImportModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
selectFile();
|
||||
|
||||
const noValid = await screen.findByText('No valid rows');
|
||||
expect(noValid.closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,30 @@ async function request<T>(
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/** Upload a CSV to the spool import endpoint (#1576). Multipart, so it bypasses
|
||||
* `request<T>()` (which sends JSON): the browser must set the form-data
|
||||
* boundary itself. `dryRun` toggles preview-only vs. real import. */
|
||||
async function uploadSpoolsCsv<T>(file: File, dryRun: boolean): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const response = await fetch(`${API_BASE}/inventory/spools/import${dryRun ? '?dry_run=true' : ''}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
// detail may be a plain string or a structured {code, message} object
|
||||
// (e.g. the 413 too-large response). Surface the human message either way.
|
||||
const detail = error?.detail;
|
||||
const message = typeof detail === 'string' ? detail : detail?.message;
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Camera diagnostic result (#1395 follow-up). Returned by
|
||||
// POST /printers/{id}/camera/diagnose; the frontend modal renders one
|
||||
// row per stage and looks up the summary code in i18n for the user-
|
||||
@@ -2626,6 +2650,45 @@ export interface SpoolmanBulkCreateResult {
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
// ── CSV import/export (#1576) ──────────────────────────────────────────────
|
||||
/** One row's outcome from the import preview / real import. */
|
||||
export interface CsvImportRow {
|
||||
row_number: number;
|
||||
status: 'valid' | 'error' | 'skipped';
|
||||
reason: string | null;
|
||||
material: string | null;
|
||||
brand: string | null;
|
||||
color_name: string | null;
|
||||
rgba: string | null;
|
||||
/** rgba/extra_colors/effect_type were filled from the Color Catalog. */
|
||||
resolved_color: boolean;
|
||||
/** The catalog match came from a different material's variant (no exact
|
||||
* material match). Shown as a warning in the preview. */
|
||||
cross_material_color: boolean;
|
||||
/** An active spool with the same material+brand+color already exists.
|
||||
* Informational only — the import still creates the row. */
|
||||
duplicate_of_existing: boolean;
|
||||
}
|
||||
|
||||
/** Dry-run preview: per-row classification, no rows written. */
|
||||
export interface CsvImportPreview {
|
||||
columns: string[];
|
||||
total: number;
|
||||
valid_count: number;
|
||||
error_count: number;
|
||||
skipped_count: number;
|
||||
rows: CsvImportRow[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Summary returned after a real (non-dry-run) import. */
|
||||
export interface CsvImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
error_rows: CsvImportRow[];
|
||||
}
|
||||
|
||||
export interface SpoolUsageRecord {
|
||||
id: number;
|
||||
spool_id: number;
|
||||
@@ -4878,6 +4941,31 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ spool: data, quantity }),
|
||||
}),
|
||||
// ── CSV import/export (#1576) ────────────────────────────────────────────
|
||||
// dry_run=true → preview (no write); omitted → real import. Both share one
|
||||
// multipart upload helper; see `uploadSpoolsCsv` below.
|
||||
importSpoolsCsvPreview: (file: File): Promise<CsvImportPreview> => uploadSpoolsCsv<CsvImportPreview>(file, true),
|
||||
importSpoolsCsv: (file: File): Promise<CsvImportResult> => uploadSpoolsCsv<CsvImportResult>(file, false),
|
||||
exportSpoolsCsv: async (): Promise<void> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const response = await fetch(`${API_BASE}/inventory/spools/export`, { headers });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
const filename = parseContentDispositionFilename(disposition) || 'bambuddy_inventory.csv';
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
|
||||
request<InventorySpool>(`/inventory/spools/${id}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useRef, type DragEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, X, FileText, Loader2, CheckCircle, XCircle, MinusCircle, Wand2, AlertTriangle, Copy } from 'lucide-react';
|
||||
import { api, type CsvImportPreview, type CsvImportRow } from '../api/client';
|
||||
import { getSwatchStyle } from '../utils/colors';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SpoolCsvImportModalProps {
|
||||
onClose: () => void;
|
||||
/** Called after a successful import so the page can refetch the inventory. */
|
||||
onImported: (created: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV import flow (#1576): pick a file → backend dry-run preview (per-row
|
||||
* valid/error/skipped, colours resolved) → user reviews → confirm imports only
|
||||
* the valid rows. Nothing is written until confirm.
|
||||
*/
|
||||
export function SpoolCsvImportModal({ onClose, onImported }: SpoolCsvImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [preview, setPreview] = useState<CsvImportPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadPreview = async (selected: File) => {
|
||||
setFile(selected);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.importSpoolsCsvPreview(selected);
|
||||
setPreview(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('inventory.csv.previewError', 'Could not read the CSV file'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (selected) loadPreview(selected);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const dropped = e.dataTransfer.files?.[0];
|
||||
if (dropped) loadPreview(dropped);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.importSpoolsCsv(file);
|
||||
onImported(result.created);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('inventory.csv.importError', 'Import failed'));
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = (status: CsvImportRow['status']) => {
|
||||
if (status === 'valid') return <CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />;
|
||||
if (status === 'error') return <XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />;
|
||||
return <MinusCircle className="w-4 h-4 text-bambu-gray flex-shrink-0" />;
|
||||
};
|
||||
|
||||
const validCount = preview?.valid_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-3xl border border-bambu-dark-tertiary flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">{t('inventory.csv.modalTitle', 'Import spools from CSV')}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
|
||||
<X className="w-5 h-5 text-bambu-gray" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
{/* Drop zone / file picker */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`w-9 h-9 mx-auto mb-2 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
||||
{file ? (
|
||||
<p className="text-white font-medium flex items-center justify-center gap-2">
|
||||
<FileText className="w-4 h-4" /> {file.name}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-white font-medium">{t('inventory.csv.selectFile', 'Choose a CSV file or drag it here')}</p>
|
||||
<p className="text-xs text-bambu-gray/70 mt-1">{t('inventory.csv.dragHint', 'Header: material (required), brand, subtype, color_name, rgba, …')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileSelect} />
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 text-bambu-gray py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('inventory.csv.parsing', 'Reading file…')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-3">
|
||||
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-red-300 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span className="px-2 py-1 rounded bg-green-500/10 text-green-400">
|
||||
{t('inventory.csv.validCount', '{{count}} valid', { count: preview.valid_count })}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400">
|
||||
{t('inventory.csv.errorCount', '{{count}} error', { count: preview.error_count })}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded bg-bambu-dark text-bambu-gray">
|
||||
{t('inventory.csv.skippedCount', '{{count}} skipped', { count: preview.skipped_count })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg space-y-1">
|
||||
{preview.warnings.map((w, i) => (
|
||||
<p key={i} className="text-xs text-yellow-300">{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview table */}
|
||||
{preview.rows.length > 0 && (
|
||||
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-bambu-dark sticky top-0">
|
||||
<tr className="text-left text-bambu-gray">
|
||||
<th className="px-3 py-2 font-medium">{t('inventory.csv.colRow', 'Row')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('inventory.csv.colStatus', 'Status')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('inventory.material', 'Material')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('inventory.brand', 'Brand')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('inventory.csv.colColor', 'Color')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.rows.map((row) => (
|
||||
<tr key={row.row_number} className="border-t border-bambu-dark-tertiary">
|
||||
<td className="px-3 py-2 text-bambu-gray">{row.row_number}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIcon(row.status)}
|
||||
{row.status === 'error' && row.reason && (
|
||||
<span className="text-xs text-red-400 break-words">{row.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white">{row.material || '—'}</td>
|
||||
<td className="px-3 py-2 text-white">{row.brand || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.rgba && (
|
||||
<span
|
||||
className="inline-block w-4 h-4 rounded-full border border-bambu-dark-tertiary flex-shrink-0"
|
||||
style={getSwatchStyle(row.rgba)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-white">{row.color_name || '—'}</span>
|
||||
{row.resolved_color && !row.cross_material_color && (
|
||||
<span title={t('inventory.csv.colorResolved', 'Color filled from catalog')}>
|
||||
<Wand2 className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
{row.cross_material_color && (
|
||||
<span title={t('inventory.csv.colorCrossMaterial', 'Color taken from a different material — no exact match in catalog')}>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
{row.duplicate_of_existing && (
|
||||
<span title={t('inventory.csv.duplicateExisting', 'A spool with this material, brand and color already exists — it will still be imported as a new spool')}>
|
||||
<Copy className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose} disabled={importing}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={!preview || validCount === 0 || importing}>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('inventory.csv.importing', 'Importing…')}
|
||||
</>
|
||||
) : validCount > 0 ? (
|
||||
t('inventory.csv.importValidRows', 'Import {{count}} valid rows', { count: validCount })
|
||||
) : (
|
||||
t('inventory.csv.noValidRows', 'No valid rows')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3679,6 +3679,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'CSV importieren',
|
||||
exportButton: 'CSV exportieren',
|
||||
modalTitle: 'Spulen aus CSV importieren',
|
||||
selectFile: 'CSV-Datei auswählen oder hierher ziehen',
|
||||
dragHint: 'Kopfzeile: material (erforderlich), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Datei wird gelesen…',
|
||||
previewError: 'CSV-Datei konnte nicht gelesen werden',
|
||||
validCount: '{{count}} gültig',
|
||||
errorCount: '{{count}} Fehler',
|
||||
skippedCount: '{{count}} übersprungen',
|
||||
colRow: 'Zeile',
|
||||
colStatus: 'Status',
|
||||
colColor: 'Farbe',
|
||||
colorResolved: 'Farbe aus Katalog übernommen',
|
||||
colorCrossMaterial: 'Farbe von einem anderen Material übernommen — keine exakte Übereinstimmung im Katalog',
|
||||
duplicateExisting: 'Eine Spule mit diesem Material, dieser Marke und Farbe existiert bereits — sie wird trotzdem als neue Spule importiert',
|
||||
spoolmanHint: 'Im Spoolman-Modus den integrierten CSV-Import/-Export von Spoolman verwenden.',
|
||||
importValidRows: '{{count}} gültige Zeilen importieren',
|
||||
noValidRows: 'Keine gültigen Zeilen',
|
||||
importing: 'Wird importiert…',
|
||||
importSuccess: '{{count}} Spulen importiert',
|
||||
importError: 'Import fehlgeschlagen',
|
||||
exportError: 'Export fehlgeschlagen',
|
||||
},
|
||||
addSpool: 'Spule hinzufügen',
|
||||
copySpool: 'Spule kopieren',
|
||||
editSpool: 'Spule bearbeiten',
|
||||
|
||||
@@ -3682,6 +3682,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'Import CSV',
|
||||
exportButton: 'Export CSV',
|
||||
modalTitle: 'Import spools from CSV',
|
||||
selectFile: 'Choose a CSV file or drag it here',
|
||||
dragHint: 'Header: material (required), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Reading file…',
|
||||
previewError: 'Could not read the CSV file',
|
||||
validCount: '{{count}} valid',
|
||||
errorCount: '{{count}} error',
|
||||
skippedCount: '{{count}} skipped',
|
||||
colRow: 'Row',
|
||||
colStatus: 'Status',
|
||||
colColor: 'Color',
|
||||
colorResolved: 'Color filled from catalog',
|
||||
colorCrossMaterial: 'Color taken from a different material — no exact match in catalog',
|
||||
duplicateExisting: 'A spool with this material, brand and color already exists — it will still be imported as a new spool',
|
||||
spoolmanHint: 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.',
|
||||
importValidRows: 'Import {{count}} valid rows',
|
||||
noValidRows: 'No valid rows',
|
||||
importing: 'Importing…',
|
||||
importSuccess: '{{count}} spools imported',
|
||||
importError: 'Import failed',
|
||||
exportError: 'Export failed',
|
||||
},
|
||||
addSpool: 'Add Spool',
|
||||
editSpool: 'Edit Spool',
|
||||
copySpool: 'Copy Spool',
|
||||
|
||||
@@ -3682,6 +3682,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'Importar CSV',
|
||||
exportButton: 'Exportar CSV',
|
||||
modalTitle: 'Importar bobinas desde CSV',
|
||||
selectFile: 'Elige un archivo CSV o arrástralo aquí',
|
||||
dragHint: 'Encabezado: material (obligatorio), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Leyendo archivo…',
|
||||
previewError: 'No se pudo leer el archivo CSV',
|
||||
validCount: '{{count}} válidas',
|
||||
errorCount: '{{count}} con error',
|
||||
skippedCount: '{{count}} omitidas',
|
||||
colRow: 'Fila',
|
||||
colStatus: 'Estado',
|
||||
colColor: 'Color',
|
||||
colorResolved: 'Color rellenado desde el catálogo',
|
||||
colorCrossMaterial: 'Color tomado de un material diferente — sin coincidencia exacta en el catálogo',
|
||||
duplicateExisting: 'Ya existe una bobina con este material, marca y color — se importará igualmente como una bobina nueva',
|
||||
spoolmanHint: 'En modo Spoolman, usa la importación/exportación CSV integrada de Spoolman.',
|
||||
importValidRows: 'Importar {{count}} filas válidas',
|
||||
noValidRows: 'Sin filas válidas',
|
||||
importing: 'Importando…',
|
||||
importSuccess: '{{count}} bobinas importadas',
|
||||
importError: 'Error al importar',
|
||||
exportError: 'Error al exportar',
|
||||
},
|
||||
addSpool: 'Añadir bobina',
|
||||
editSpool: 'Editar bobina',
|
||||
copySpool: 'Copiar bobina',
|
||||
|
||||
@@ -3668,6 +3668,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'Importer CSV',
|
||||
exportButton: 'Exporter CSV',
|
||||
modalTitle: 'Importer des bobines depuis un CSV',
|
||||
selectFile: 'Choisissez un fichier CSV ou glissez-le ici',
|
||||
dragHint: 'En-tête : material (requis), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Lecture du fichier…',
|
||||
previewError: 'Impossible de lire le fichier CSV',
|
||||
validCount: '{{count}} valides',
|
||||
errorCount: '{{count}} en erreur',
|
||||
skippedCount: '{{count}} ignorées',
|
||||
colRow: 'Ligne',
|
||||
colStatus: 'Statut',
|
||||
colColor: 'Couleur',
|
||||
colorResolved: 'Couleur remplie depuis le catalogue',
|
||||
colorCrossMaterial: 'Couleur reprise d\'un autre matériau — aucune correspondance exacte dans le catalogue',
|
||||
duplicateExisting: 'Une bobine avec ce matériau, cette marque et cette couleur existe déjà — elle sera tout de même importée comme une nouvelle bobine',
|
||||
spoolmanHint: 'En mode Spoolman, utilisez l\'import/export CSV intégré de Spoolman.',
|
||||
importValidRows: 'Importer {{count}} lignes valides',
|
||||
noValidRows: 'Aucune ligne valide',
|
||||
importing: 'Importation…',
|
||||
importSuccess: '{{count}} bobines importées',
|
||||
importError: 'Échec de l\'importation',
|
||||
exportError: 'Échec de l\'exportation',
|
||||
},
|
||||
addSpool: 'Ajouter Bobine',
|
||||
editSpool: 'Modifier Bobine',
|
||||
copySpool: 'Copier Bobine',
|
||||
|
||||
@@ -3667,6 +3667,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'Importa CSV',
|
||||
exportButton: 'Esporta CSV',
|
||||
modalTitle: 'Importa bobine da CSV',
|
||||
selectFile: 'Scegli un file CSV o trascinalo qui',
|
||||
dragHint: 'Intestazione: material (obbligatorio), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Lettura del file…',
|
||||
previewError: 'Impossibile leggere il file CSV',
|
||||
validCount: '{{count}} valide',
|
||||
errorCount: '{{count}} con errore',
|
||||
skippedCount: '{{count}} ignorate',
|
||||
colRow: 'Riga',
|
||||
colStatus: 'Stato',
|
||||
colColor: 'Colore',
|
||||
colorResolved: 'Colore compilato dal catalogo',
|
||||
colorCrossMaterial: 'Colore preso da un materiale diverso — nessuna corrispondenza esatta nel catalogo',
|
||||
duplicateExisting: 'Esiste già una bobina con questo materiale, marca e colore — verrà comunque importata come nuova bobina',
|
||||
spoolmanHint: 'In modalità Spoolman, usa l\'importazione/esportazione CSV integrata di Spoolman.',
|
||||
importValidRows: 'Importa {{count}} righe valide',
|
||||
noValidRows: 'Nessuna riga valida',
|
||||
importing: 'Importazione…',
|
||||
importSuccess: '{{count}} bobine importate',
|
||||
importError: 'Importazione non riuscita',
|
||||
exportError: 'Esportazione non riuscita',
|
||||
},
|
||||
addSpool: 'Aggiungi Bobina',
|
||||
editSpool: 'Modifica Bobina',
|
||||
copySpool: 'Copia Bobina',
|
||||
|
||||
@@ -3679,6 +3679,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'CSVインポート',
|
||||
exportButton: 'CSVエクスポート',
|
||||
modalTitle: 'CSVからスプールをインポート',
|
||||
selectFile: 'CSVファイルを選択するか、ここにドラッグ',
|
||||
dragHint: 'ヘッダー: material(必須)、brand、subtype、color_name、rgba、…',
|
||||
parsing: 'ファイルを読み込み中…',
|
||||
previewError: 'CSVファイルを読み込めませんでした',
|
||||
validCount: '有効{{count}}件',
|
||||
errorCount: 'エラー{{count}}件',
|
||||
skippedCount: 'スキップ{{count}}件',
|
||||
colRow: '行',
|
||||
colStatus: 'ステータス',
|
||||
colColor: '色',
|
||||
colorResolved: 'カタログから色を補完',
|
||||
colorCrossMaterial: '別の素材から色を取得しました — カタログに完全一致なし',
|
||||
duplicateExisting: 'この素材・ブランド・色のスプールは既に存在します — それでも新しいスプールとしてインポートされます',
|
||||
spoolmanHint: 'Spoolmanモードでは、Spoolman内蔵のCSVインポート/エクスポートを使用してください。',
|
||||
importValidRows: '有効な{{count}}行をインポート',
|
||||
noValidRows: '有効な行がありません',
|
||||
importing: 'インポート中…',
|
||||
importSuccess: '{{count}}個のスプールをインポートしました',
|
||||
importError: 'インポートに失敗しました',
|
||||
exportError: 'エクスポートに失敗しました',
|
||||
},
|
||||
addSpool: 'スプールを追加',
|
||||
editSpool: 'スプールを編集',
|
||||
copySpool: 'スプールをコピー',
|
||||
|
||||
@@ -3470,6 +3470,31 @@ export default {
|
||||
color: '색상 순'
|
||||
}
|
||||
},
|
||||
csv: {
|
||||
importButton: 'CSV 가져오기',
|
||||
exportButton: 'CSV 내보내기',
|
||||
modalTitle: 'CSV에서 스풀 가져오기',
|
||||
selectFile: 'CSV 파일을 선택하거나 여기로 끌어다 놓으세요',
|
||||
dragHint: '헤더: material(필수), brand, subtype, color_name, rgba, …',
|
||||
parsing: '파일을 읽는 중…',
|
||||
previewError: 'CSV 파일을 읽을 수 없습니다',
|
||||
validCount: '유효 {{count}}개',
|
||||
errorCount: '오류 {{count}}개',
|
||||
skippedCount: '건너뜀 {{count}}개',
|
||||
colRow: '행',
|
||||
colStatus: '상태',
|
||||
colColor: '색상',
|
||||
colorResolved: '카탈로그에서 색상 채움',
|
||||
colorCrossMaterial: '다른 재료에서 색상을 가져옴 — 카탈로그에 정확히 일치하는 항목 없음',
|
||||
duplicateExisting: '이 재료, 브랜드, 색상의 스풀이 이미 존재합니다 — 그래도 새 스풀로 가져옵니다',
|
||||
spoolmanHint: 'Spoolman 모드에서는 Spoolman의 기본 CSV 가져오기/내보내기를 사용하세요.',
|
||||
importValidRows: '유효한 {{count}}개 행 가져오기',
|
||||
noValidRows: '유효한 행 없음',
|
||||
importing: '가져오는 중…',
|
||||
importSuccess: '{{count}}개 스풀을 가져왔습니다',
|
||||
importError: '가져오기 실패',
|
||||
exportError: '내보내기 실패',
|
||||
},
|
||||
addSpool: '스풀 추가',
|
||||
editSpool: '스풀 편집',
|
||||
copySpool: '스풀 복사',
|
||||
|
||||
@@ -3667,6 +3667,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'Importar CSV',
|
||||
exportButton: 'Exportar CSV',
|
||||
modalTitle: 'Importar carretéis de CSV',
|
||||
selectFile: 'Escolha um arquivo CSV ou arraste-o aqui',
|
||||
dragHint: 'Cabeçalho: material (obrigatório), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Lendo arquivo…',
|
||||
previewError: 'Não foi possível ler o arquivo CSV',
|
||||
validCount: '{{count}} válidos',
|
||||
errorCount: '{{count}} com erro',
|
||||
skippedCount: '{{count}} ignorados',
|
||||
colRow: 'Linha',
|
||||
colStatus: 'Status',
|
||||
colColor: 'Cor',
|
||||
colorResolved: 'Cor preenchida pelo catálogo',
|
||||
colorCrossMaterial: 'Cor obtida de um material diferente — sem correspondência exata no catálogo',
|
||||
duplicateExisting: 'Já existe uma bobina com este material, marca e cor — ela será importada mesmo assim como uma nova bobina',
|
||||
spoolmanHint: 'No modo Spoolman, use a importação/exportação CSV integrada do Spoolman.',
|
||||
importValidRows: 'Importar {{count}} linhas válidas',
|
||||
noValidRows: 'Nenhuma linha válida',
|
||||
importing: 'Importando…',
|
||||
importSuccess: '{{count}} carretéis importados',
|
||||
importError: 'Falha na importação',
|
||||
exportError: 'Falha na exportação',
|
||||
},
|
||||
addSpool: 'Adicionar Carretel',
|
||||
editSpool: 'Editar Carretel',
|
||||
copySpool: 'Copiar Carretel',
|
||||
|
||||
@@ -3668,6 +3668,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: 'CSV İçe Aktar',
|
||||
exportButton: 'CSV Dışa Aktar',
|
||||
modalTitle: 'CSV\'den makara içe aktar',
|
||||
selectFile: 'Bir CSV dosyası seçin veya buraya sürükleyin',
|
||||
dragHint: 'Başlık: material (zorunlu), brand, subtype, color_name, rgba, …',
|
||||
parsing: 'Dosya okunuyor…',
|
||||
previewError: 'CSV dosyası okunamadı',
|
||||
validCount: '{{count}} geçerli',
|
||||
errorCount: '{{count}} hatalı',
|
||||
skippedCount: '{{count}} atlandı',
|
||||
colRow: 'Satır',
|
||||
colStatus: 'Durum',
|
||||
colColor: 'Renk',
|
||||
colorResolved: 'Renk katalogdan dolduruldu',
|
||||
colorCrossMaterial: 'Renk farklı bir malzemeden alındı — katalogda tam eşleşme yok',
|
||||
duplicateExisting: 'Bu malzeme, marka ve renkte bir makara zaten var — yine de yeni makara olarak içe aktarılacak',
|
||||
spoolmanHint: 'Spoolman modunda Spoolman\'ın yerleşik CSV içe/dışa aktarımını kullanın.',
|
||||
importValidRows: '{{count}} geçerli satırı içe aktar',
|
||||
noValidRows: 'Geçerli satır yok',
|
||||
importing: 'İçe aktarılıyor…',
|
||||
importSuccess: '{{count}} makara içe aktarıldı',
|
||||
importError: 'İçe aktarma başarısız',
|
||||
exportError: 'Dışa aktarma başarısız',
|
||||
},
|
||||
addSpool: 'Makara Ekle',
|
||||
editSpool: 'Makarayı Düzenle',
|
||||
copySpool: 'Makarayı Kopyala',
|
||||
|
||||
@@ -3667,6 +3667,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: '导入 CSV',
|
||||
exportButton: '导出 CSV',
|
||||
modalTitle: '从 CSV 导入耗材',
|
||||
selectFile: '选择 CSV 文件或拖放到此处',
|
||||
dragHint: '表头:material(必填)、brand、subtype、color_name、rgba、…',
|
||||
parsing: '正在读取文件…',
|
||||
previewError: '无法读取 CSV 文件',
|
||||
validCount: '{{count}} 个有效',
|
||||
errorCount: '{{count}} 个错误',
|
||||
skippedCount: '{{count}} 个跳过',
|
||||
colRow: '行',
|
||||
colStatus: '状态',
|
||||
colColor: '颜色',
|
||||
colorResolved: '已从目录填充颜色',
|
||||
colorCrossMaterial: '颜色取自其他材料 — 目录中无精确匹配',
|
||||
duplicateExisting: '已存在具有此材料、品牌和颜色的料卷 — 仍会作为新料卷导入',
|
||||
spoolmanHint: '在 Spoolman 模式下,请使用 Spoolman 内置的 CSV 导入/导出。',
|
||||
importValidRows: '导入 {{count}} 个有效行',
|
||||
noValidRows: '没有有效行',
|
||||
importing: '正在导入…',
|
||||
importSuccess: '已导入 {{count}} 个耗材',
|
||||
importError: '导入失败',
|
||||
exportError: '导出失败',
|
||||
},
|
||||
addSpool: '添加耗材',
|
||||
editSpool: '编辑耗材',
|
||||
copySpool: '复制耗材',
|
||||
|
||||
@@ -3667,6 +3667,31 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
csv: {
|
||||
importButton: '匯入 CSV',
|
||||
exportButton: '匯出 CSV',
|
||||
modalTitle: '從 CSV 匯入耗材',
|
||||
selectFile: '選擇 CSV 檔案或拖放到此處',
|
||||
dragHint: '標頭:material(必填)、brand、subtype、color_name、rgba、…',
|
||||
parsing: '正在讀取檔案…',
|
||||
previewError: '無法讀取 CSV 檔案',
|
||||
validCount: '{{count}} 個有效',
|
||||
errorCount: '{{count}} 個錯誤',
|
||||
skippedCount: '{{count}} 個略過',
|
||||
colRow: '列',
|
||||
colStatus: '狀態',
|
||||
colColor: '顏色',
|
||||
colorResolved: '已從目錄填入顏色',
|
||||
colorCrossMaterial: '顏色取自其他材料 — 目錄中無精確匹配',
|
||||
duplicateExisting: '已存在具有此材料、品牌和顏色的線材卷 — 仍會作為新線材卷匯入',
|
||||
spoolmanHint: '在 Spoolman 模式下,請使用 Spoolman 內建的 CSV 匯入/匯出。',
|
||||
importValidRows: '匯入 {{count}} 個有效列',
|
||||
noValidRows: '沒有有效列',
|
||||
importing: '正在匯入…',
|
||||
importSuccess: '已匯入 {{count}} 個耗材',
|
||||
importError: '匯入失敗',
|
||||
exportError: '匯出失敗',
|
||||
},
|
||||
addSpool: '新增耗材',
|
||||
editSpool: '編輯耗材',
|
||||
copySpool: '複製耗材',
|
||||
|
||||
@@ -7,6 +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,
|
||||
Upload, Download,
|
||||
} from 'lucide-react';
|
||||
import { ForecastPanel } from '../components/ForecastPanel';
|
||||
import { api, spoolbuddyApi, ApiError } from '../api/client';
|
||||
@@ -18,6 +19,7 @@ import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
|
||||
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
|
||||
import { SpoolCsvImportModal } from '../components/SpoolCsvImportModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { resolveSpoolColorName } from '../utils/colors';
|
||||
@@ -473,6 +475,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
>(null);
|
||||
// Label printing (#809). null = closed; otherwise the IDs to print labels for.
|
||||
const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState<number[] | null>(null);
|
||||
// CSV import/export (#1576). Local inventory only — hidden in Spoolman mode.
|
||||
const [csvImportOpen, setCsvImportOpen] = useState(false);
|
||||
const [exportingCsv, setExportingCsv] = useState(false);
|
||||
|
||||
// Filter state
|
||||
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
|
||||
@@ -527,6 +532,26 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// CSV export (#1576) — downloads the active inventory as a CSV file.
|
||||
const handleExportCsv = useCallback(async () => {
|
||||
setExportingCsv(true);
|
||||
try {
|
||||
await api.exportSpoolsCsv();
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? err.message : t('inventory.csv.exportError', 'Export failed'),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setExportingCsv(false);
|
||||
}
|
||||
}, [showToast, t]);
|
||||
|
||||
// Shown as the tooltip on both CSV buttons when they're disabled in Spoolman mode.
|
||||
const spoolmanCsvHint = spoolmanMode
|
||||
? t('inventory.csv.spoolmanHint', 'In Spoolman mode, use Spoolman\'s built-in CSV import/export.')
|
||||
: undefined;
|
||||
|
||||
// Deep-link: open edit modal for ?spool=<id>
|
||||
// Prefer the already-loaded spool list (no extra API call); fall back to a
|
||||
// targeted fetch for the rare case where the full list hasn't arrived yet.
|
||||
@@ -1104,6 +1129,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
<p className="text-bambu-gray mt-1">{t('inventory.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* CSV import/export (#1576). Operates on Bambuddy's local inventory.
|
||||
In Spoolman mode the buttons stay visible (feature parity) but are
|
||||
disabled with a hint pointing at Spoolman's own CSV export, since
|
||||
Spoolman owns the data store in that mode. */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={spoolmanMode}
|
||||
onClick={() => setCsvImportOpen(true)}
|
||||
title={spoolmanCsvHint}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{t('inventory.csv.importButton', 'Import CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={spoolmanMode || exportingCsv}
|
||||
onClick={handleExportCsv}
|
||||
title={spoolmanCsvHint}
|
||||
>
|
||||
{exportingCsv ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
{t('inventory.csv.exportButton', 'Export CSV')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={filteredSpools.length === 0}
|
||||
@@ -1931,6 +1978,17 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
||||
initialSelectedIds={labelPickerSpoolIds ?? []}
|
||||
spoolmanMode={spoolmanMode}
|
||||
/>
|
||||
|
||||
{csvImportOpen && (
|
||||
<SpoolCsvImportModal
|
||||
onClose={() => setCsvImportOpen(false)}
|
||||
onImported={(created) => {
|
||||
setCsvImportOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
|
||||
showToast(t('inventory.csv.importSuccess', '{{count}} spools imported', { count: created }), 'success');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user