From 66c09dff2db46839c1e21ee83cfcc6244e21a10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samed=20Y=C3=BCksel?= Date: Mon, 8 Jun 2026 10:30:16 +0300 Subject: [PATCH] feat(inventory): CSV import/export for the inventory page (#1576) (#1659) --- backend/app/api/routes/inventory.py | 101 +++- backend/app/services/spool_csv.py | 549 ++++++++++++++++++ .../tests/integration/test_inventory_csv.py | 520 +++++++++++++++++ .../components/SpoolCsvImportModal.test.tsx | 87 +++ frontend/src/api/client.ts | 88 +++ .../src/components/SpoolCsvImportModal.tsx | 242 ++++++++ frontend/src/i18n/locales/de.ts | 25 + frontend/src/i18n/locales/en.ts | 25 + frontend/src/i18n/locales/es.ts | 25 + frontend/src/i18n/locales/fr.ts | 25 + frontend/src/i18n/locales/it.ts | 25 + frontend/src/i18n/locales/ja.ts | 25 + frontend/src/i18n/locales/ko.ts | 25 + frontend/src/i18n/locales/pt-BR.ts | 25 + frontend/src/i18n/locales/tr.ts | 25 + frontend/src/i18n/locales/zh-CN.ts | 25 + frontend/src/i18n/locales/zh-TW.ts | 25 + frontend/src/pages/InventoryPage.tsx | 58 ++ 18 files changed, 1918 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/spool_csv.py create mode 100644 backend/tests/integration/test_inventory_csv.py create mode 100644 frontend/src/__tests__/components/SpoolCsvImportModal.test.tsx create mode 100644 frontend/src/components/SpoolCsvImportModal.tsx diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 425316174..edd8c3ba6 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -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, diff --git a/backend/app/services/spool_csv.py b/backend/app/services/spool_csv.py new file mode 100644 index 000000000..cc6f4a84a --- /dev/null +++ b/backend/app/services/spool_csv.py @@ -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) diff --git a/backend/tests/integration/test_inventory_csv.py b/backend/tests/integration/test_inventory_csv.py new file mode 100644 index 000000000..20ee155a4 --- /dev/null +++ b/backend/tests/integration/test_inventory_csv.py @@ -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 diff --git a/frontend/src/__tests__/components/SpoolCsvImportModal.test.tsx b/frontend/src/__tests__/components/SpoolCsvImportModal.test.tsx new file mode 100644 index 000000000..f6ae8a2ba --- /dev/null +++ b/frontend/src/__tests__/components/SpoolCsvImportModal.test.tsx @@ -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(); + 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(); + 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(); + selectFile(); + + const noValid = await screen.findByText('No valid rows'); + expect(noValid.closest('button')).toBeDisabled(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3a89bc95b..6964e37ea 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -178,6 +178,30 @@ async function request( return await response.json(); } +/** Upload a CSV to the spool import endpoint (#1576). Multipart, so it bypasses + * `request()` (which sends JSON): the browser must set the form-data + * boundary itself. `dryRun` toggles preview-only vs. real import. */ +async function uploadSpoolsCsv(file: File, dryRun: boolean): Promise { + const form = new FormData(); + form.append('file', file); + const headers: Record = {}; + 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 => uploadSpoolsCsv(file, true), + importSpoolsCsv: (file: File): Promise => uploadSpoolsCsv(file, false), + exportSpoolsCsv: async (): Promise => { + const headers: Record = {}; + 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>) => request(`/inventory/spools/${id}`, { method: 'PATCH', diff --git a/frontend/src/components/SpoolCsvImportModal.tsx b/frontend/src/components/SpoolCsvImportModal.tsx new file mode 100644 index 000000000..c363a8f2d --- /dev/null +++ b/frontend/src/components/SpoolCsvImportModal.tsx @@ -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(null); + const [isDragging, setIsDragging] = useState(false); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(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) => { + const selected = e.target.files?.[0]; + if (selected) loadPreview(selected); + }; + + const handleDrop = (e: DragEvent) => { + 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 ; + if (status === 'error') return ; + return ; + }; + + const validCount = preview?.valid_count ?? 0; + + return ( +
+
+
+

{t('inventory.csv.modalTitle', 'Import spools from CSV')}

+ +
+ +
+ {/* Drop zone / file picker */} +
{ + 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' + }`} + > + + {file ? ( +

+ {file.name} +

+ ) : ( + <> +

{t('inventory.csv.selectFile', 'Choose a CSV file or drag it here')}

+

{t('inventory.csv.dragHint', 'Header: material (required), brand, subtype, color_name, rgba, …')}

+ + )} +
+ + + {loading && ( +
+ + {t('inventory.csv.parsing', 'Reading file…')} +
+ )} + + {error && ( +
+ +

{error}

+
+ )} + + {preview && ( + <> + {/* Summary */} +
+ + {t('inventory.csv.validCount', '{{count}} valid', { count: preview.valid_count })} + + + {t('inventory.csv.errorCount', '{{count}} error', { count: preview.error_count })} + + + {t('inventory.csv.skippedCount', '{{count}} skipped', { count: preview.skipped_count })} + +
+ + {preview.warnings.length > 0 && ( +
+ {preview.warnings.map((w, i) => ( +

{w}

+ ))} +
+ )} + + {/* Preview table */} + {preview.rows.length > 0 && ( +
+
+ + + + + + + + + + + + {preview.rows.map((row) => ( + + + + + + + + ))} + +
{t('inventory.csv.colRow', 'Row')}{t('inventory.csv.colStatus', 'Status')}{t('inventory.material', 'Material')}{t('inventory.brand', 'Brand')}{t('inventory.csv.colColor', 'Color')}
{row.row_number} +
+ {statusIcon(row.status)} + {row.status === 'error' && row.reason && ( + {row.reason} + )} +
+
{row.material || '—'}{row.brand || '—'} +
+ {row.rgba && ( + + )} + {row.color_name || '—'} + {row.resolved_color && !row.cross_material_color && ( + + + + )} + {row.cross_material_color && ( + + + + )} + {row.duplicate_of_existing && ( + + + + )} +
+
+
+
+ )} + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 32d4a9883..f5bce96a4 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0339b6eca..45ed6b347 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index f20db8621..e38ac8208 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 582295ae3..8b6861499 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 5a0aa8783..e3a20833a 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 88e4c2608..d6c73873d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -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: 'スプールをコピー', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index b49fe3a45..6df37bee5 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -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: '스풀 복사', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index c4b298de6..9d90cbe0f 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 18b274f0f..f7868b2b3 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 062ca1e64..6eb258a4a 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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: '复制耗材', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 435e4ddf1..16120c254 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -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: '複製耗材', diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 6699be1d6..2eb83868f 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -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(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('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= // 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

{t('inventory.subtitle')}

+ {/* 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. */} + +
); }