Просмотр исходного кода

feat(inventory): CSV import/export for the inventory page (#1576) (#1659)

Samed Yüksel 3 месяцев назад
Родитель
Сommit
66c09dff2d

+ 99 - 2
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,

+ 549 - 0
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)

+ 520 - 0
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

+ 87 - 0
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(<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();
+  });
+});

+ 88 - 0
frontend/src/api/client.ts

@@ -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',

+ 242 - 0
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<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>
+  );
+}

+ 25 - 0
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',

+ 25 - 0
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',

+ 25 - 0
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',

+ 25 - 0
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',

+ 25 - 0
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',

+ 25 - 0
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: 'スプールをコピー',

+ 25 - 0
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: '스풀 복사',

+ 25 - 0
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',

+ 25 - 0
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',

+ 25 - 0
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: '复制耗材',

+ 25 - 0
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: '複製耗材',

+ 58 - 0
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<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>
   );
 }