spool_csv.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. """CSV import/export for the spool inventory (#1576).
  2. One module owns the round-trip: the same fixed column schema is used to
  3. serialise existing spools out and to parse + validate a user-supplied CSV
  4. back in. Validation reuses the `SpoolCreate` Pydantic model so the CSV path
  5. and the form path share a single source of truth — anything the form rejects,
  6. the import rejects too, with the same rules.
  7. The import flow is two-phase by design: `parse_and_validate()` never writes.
  8. The route calls it once for the dry-run preview (so the user sees per-row
  9. valid/error/skipped before committing) and again on confirm, then persists
  10. only the rows that came back `valid`.
  11. """
  12. import csv
  13. import io
  14. from datetime import datetime
  15. from pydantic import BaseModel, ValidationError
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from backend.app.models.color_catalog import ColorCatalogEntry
  19. from backend.app.models.spool import Spool
  20. from backend.app.models.supplier import Supplier, supplier_name_key
  21. from backend.app.schemas.spool import SpoolCreate
  22. # Fixed CSV header, in output order. Round-trips cleanly: export writes these
  23. # columns, import expects them. `material` is the only required field; the rest
  24. # are optional. Keep aligned with the SpoolCreate fields referenced below.
  25. #
  26. # `remaining` is a derived, export-only column (= label_weight - weight_used).
  27. # It's written out for human readability and round-trip clarity, but ignored on
  28. # import — `weight_used` is the source of truth, and accepting both would let
  29. # them contradict. `last_used` is a timestamp the model carries but SpoolCreate
  30. # does not, so import applies it to the ORM object directly (see persist path).
  31. # `storage_location`, `category`, `low_stock_threshold_pct` and
  32. # `material_number` (#2870) are SpoolCreate fields included so a round-trip
  33. # preserves them (they'd otherwise be lost).
  34. CSV_COLUMNS = [
  35. "material",
  36. "brand",
  37. "subtype",
  38. "color_name",
  39. "rgba",
  40. "extra_colors",
  41. "effect_type",
  42. "label_weight",
  43. "weight_used",
  44. "remaining",
  45. "cost_per_kg",
  46. "nozzle_temp_min",
  47. "nozzle_temp_max",
  48. "last_used",
  49. "note",
  50. "storage_location",
  51. "category",
  52. "low_stock_threshold_pct",
  53. "material_number",
  54. # Supplier assignments (#2988): `suppliers` is the "; "-joined names of
  55. # all assigned suppliers, `purchase_supplier` the one this spool was
  56. # actually bought from (or empty). Import matches names against the
  57. # existing supplier list — trimmed, case-insensitive — and NEVER creates
  58. # suppliers; an unknown name is a dry-run warning, not a row error, and
  59. # that assignment is dropped. Article number and quoted price stay out of
  60. # the CSV: they belong to the assignment, not the spool, and would break
  61. # the round-trip.
  62. "suppliers",
  63. "purchase_supplier",
  64. ]
  65. # Upload ceiling for the import endpoint. A spool inventory CSV is a few KB
  66. # even with thousands of rows; 5 MB is a generous cap that still refuses an
  67. # OOM-sized body before it's read into memory.
  68. MAX_CSV_IMPORT_BYTES = 5 * 1024 * 1024
  69. # Spreadsheet formula-injection guard. A cell whose first character is one of
  70. # these is treated as a formula by Excel / LibreOffice / Sheets; we prefix it
  71. # with a single quote on export so the value renders as literal text.
  72. _FORMULA_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
  73. # Columns whose CSV cell must be coerced to a number before SpoolCreate sees it.
  74. # DictReader hands us strings; SpoolCreate wants int/float. Empty cell → omit
  75. # the field (falls back to the schema default / None).
  76. _INT_COLUMNS = {"label_weight", "nozzle_temp_min", "nozzle_temp_max", "low_stock_threshold_pct"}
  77. _FLOAT_COLUMNS = {"cost_per_kg", "weight_used"}
  78. # label_weight default, pulled from the schema so the weight_used bounds check
  79. # stays in sync if the schema default ever changes.
  80. _DEFAULT_LABEL_WEIGHT = SpoolCreate.model_fields["label_weight"].default
  81. class ImportRowResult(BaseModel):
  82. """Per-row outcome of a parse+validate pass.
  83. `spool` carries the validated, SpoolCreate-shaped dict for `valid` rows so
  84. the route can persist without re-parsing. `resolved_color` flags rows whose
  85. rgba/extra_colors/effect_type were filled in from the Color Catalog rather
  86. than supplied in the CSV — surfaced in the preview so the user knows a
  87. colour was inferred.
  88. """
  89. row_number: int # 1-based data row (header is not counted)
  90. status: str # "valid" | "error" | "skipped"
  91. reason: str | None = None
  92. material: str | None = None
  93. brand: str | None = None
  94. color_name: str | None = None
  95. rgba: str | None = None
  96. resolved_color: bool = False
  97. # True when the colour was resolved from a catalog entry of a DIFFERENT
  98. # material (no exact material match existed). Surfaced so the preview can
  99. # warn the user the colour came from another material's variant.
  100. cross_material_color: bool = False
  101. # True when an active spool with the same material+brand+color_name already
  102. # exists. Informational only — the import still creates the row (there's no
  103. # unique constraint); the preview warns so a double-click / re-upload of the
  104. # same CSV doesn't silently duplicate the inventory.
  105. duplicate_of_existing: bool = False
  106. spool: dict | None = None
  107. # Resolved supplier assignments (#2988): ids matched by name from the
  108. # `suppliers` / `purchase_supplier` columns. Unknown names are dropped
  109. # with a preview warning — the import never creates suppliers.
  110. supplier_ids: list[int] = []
  111. purchase_supplier_id: int | None = None
  112. class ImportPreview(BaseModel):
  113. """Result of a dry-run (or the pre-write pass of a real import)."""
  114. columns: list[str]
  115. total: int
  116. valid_count: int
  117. error_count: int
  118. skipped_count: int
  119. rows: list[ImportRowResult]
  120. warnings: list[str] = []
  121. class ImportResult(BaseModel):
  122. """Summary returned after a real (non-dry-run) import."""
  123. created: int
  124. skipped: int
  125. errors: int
  126. error_rows: list[ImportRowResult] = []
  127. def _normalize_header(name: str) -> str:
  128. """Map a CSV header cell to a canonical field name.
  129. Case- and space-tolerant: "Color Name", "color-name", " COLOR_NAME "
  130. all collapse to "color_name".
  131. """
  132. return name.strip().lower().replace(" ", "_").replace("-", "_")
  133. def _normalize_rgba(value: str) -> str | None:
  134. """Coerce a user-supplied colour cell to 8-char RRGGBBAA hex, or None.
  135. Accepts an optional leading `#` and a 6-char RRGGBB form (alpha defaults to
  136. `ff`). Returns None if the value isn't valid hex of length 6 or 8 — the
  137. caller turns that into a row error so it isn't silently dropped.
  138. """
  139. raw = value.strip().lstrip("#")
  140. if len(raw) not in (6, 8):
  141. return None
  142. try:
  143. int(raw, 16)
  144. except ValueError:
  145. return None
  146. if len(raw) == 6:
  147. raw += "ff"
  148. return raw.lower()
  149. def _parse_datetime(value: str) -> datetime | None:
  150. """Parse an ISO-8601 timestamp, or None if it isn't valid.
  151. Accepts what `datetime.isoformat()` produces (what export writes) plus a
  152. trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
  153. """
  154. raw = value.strip()
  155. if not raw:
  156. return None
  157. if raw.endswith("Z"):
  158. raw = raw[:-1] + "+00:00"
  159. try:
  160. return datetime.fromisoformat(raw)
  161. except ValueError:
  162. return None
  163. async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:
  164. """Load the whole Color Catalog once so per-row resolution is in-memory.
  165. A CSV can hold hundreds of rows; resolving each with its own SELECT would
  166. be an N+1 against a small, rarely-changing table. We pull it once here and
  167. let `_resolve_color` match against the list.
  168. """
  169. result = await db.execute(select(ColorCatalogEntry))
  170. return list(result.scalars().all())
  171. def _spool_key(material: str | None, brand: str | None, color_name: str | None) -> tuple[str, str, str]:
  172. """Case/space-insensitive identity used for the duplicate soft-warn."""
  173. return (
  174. (material or "").strip().lower(),
  175. (brand or "").strip().lower(),
  176. (color_name or "").strip().lower(),
  177. )
  178. async def _load_supplier_map(db: AsyncSession) -> dict[str, int]:
  179. """Existing suppliers keyed by ``Supplier.name_key`` for CSV matching.
  180. Import resolves the `suppliers` / `purchase_supplier` columns against
  181. this map and never creates suppliers — the master list is curated in the
  182. UI, and a typo in a CSV must not silently mint a new supplier. The key is
  183. unambiguous because it is the same column the unique index is on, so two
  184. names that land on one entry here cannot both exist as rows.
  185. """
  186. result = await db.execute(select(Supplier.id, Supplier.name_key))
  187. return {name_key: supplier_id for supplier_id, name_key in result.all()}
  188. async def _load_existing_spool_keys(db: AsyncSession) -> set[tuple[str, str, str]]:
  189. """Load material+brand+color_name keys of active spools for the dup warning.
  190. Spool has no unique constraint, so a double-click or re-upload of the same
  191. CSV would silently duplicate the inventory. We pull the active spools' keys
  192. once and let the preview flag matching rows — informational only, the import
  193. still creates them.
  194. """
  195. result = await db.execute(select(Spool.material, Spool.brand, Spool.color_name).where(Spool.archived_at.is_(None)))
  196. return {_spool_key(m, b, c) for m, b, c in result.all()}
  197. def _resolve_color(
  198. catalog: list[ColorCatalogEntry], brand: str | None, color_name: str | None, material: str | None
  199. ) -> tuple[str, str | None, str | None, bool] | None:
  200. """Match brand + color_name against the preloaded catalog (case-insensitive).
  201. Returns (rgba, extra_colors, effect_type, cross_material) on a match, else
  202. None. Prefers an entry whose material matches the row; a catalog entry with
  203. a NULL material is the project's "matches any material" convention and counts
  204. as an exact match too. Only when neither exists does it fall back to another
  205. material's entry and set cross_material=True so the caller can warn that the
  206. colour came from a different material's variant.
  207. """
  208. if not brand or not color_name:
  209. return None
  210. brand_l = brand.strip().lower()
  211. name_l = color_name.strip().lower()
  212. material_l = material.strip().lower() if material else None
  213. matches = [
  214. entry
  215. for entry in catalog
  216. if entry.hex_color and entry.manufacturer.lower() == brand_l and entry.color_name.lower() == name_l
  217. ]
  218. if not matches:
  219. return None
  220. exact = next(
  221. (e for e in matches if e.material is None or (material_l and e.material.lower() == material_l)),
  222. None,
  223. )
  224. row = exact or matches[0]
  225. cross_material = exact is None
  226. rgba = _normalize_rgba(row.hex_color)
  227. if rgba is None:
  228. return None
  229. return rgba, row.extra_colors, row.effect_type, cross_material
  230. def _readable_validation_error(exc: ValidationError) -> str:
  231. """Flatten a Pydantic ValidationError into one short, user-facing line."""
  232. parts = []
  233. for err in exc.errors():
  234. loc = ".".join(str(p) for p in err.get("loc", ())) or "value"
  235. parts.append(f"{loc}: {err.get('msg', 'invalid')}")
  236. return "; ".join(parts)
  237. def _empty_preview(warnings: list[str]) -> ImportPreview:
  238. """A preview with no rows — used for the early-exit cases (bad/empty file)."""
  239. return ImportPreview(
  240. columns=CSV_COLUMNS,
  241. total=0,
  242. valid_count=0,
  243. error_count=0,
  244. skipped_count=0,
  245. rows=[],
  246. warnings=warnings,
  247. )
  248. async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPreview:
  249. """Parse a CSV blob, validate + colour-resolve each row. Never writes.
  250. Decodes UTF-8 (BOM tolerant), reads with DictReader against the fixed
  251. schema, and classifies each row as valid / error / skipped. Valid rows
  252. carry a SpoolCreate-shaped `spool` dict ready to persist.
  253. """
  254. warnings: list[str] = []
  255. try:
  256. text = raw_bytes.decode("utf-8-sig")
  257. except UnicodeDecodeError:
  258. return _empty_preview(["File is not valid UTF-8 text."])
  259. reader = csv.reader(io.StringIO(text))
  260. try:
  261. header = next(reader)
  262. except StopIteration:
  263. return _empty_preview(["CSV is empty."])
  264. norm_header = [_normalize_header(h) for h in header]
  265. known = set(CSV_COLUMNS)
  266. unknown = [h for h in norm_header if h and h not in known]
  267. if unknown:
  268. warnings.append(f"Ignoring unknown columns: {', '.join(unknown)}")
  269. # Map canonical field name → column index in this file (first occurrence).
  270. col_index: dict[str, int] = {}
  271. for idx, h in enumerate(norm_header):
  272. if h in known and h not in col_index:
  273. col_index[h] = idx
  274. if "material" not in col_index:
  275. return _empty_preview(warnings + ["Required column 'material' is missing from the header."])
  276. # Pull the catalog and the existing-spool keys once; per-row colour
  277. # resolution and the duplicate soft-warn both match in memory rather than
  278. # issuing a SELECT per row.
  279. catalog = await _load_color_catalog(db)
  280. existing_keys = await _load_existing_spool_keys(db)
  281. supplier_map = await _load_supplier_map(db)
  282. unknown_suppliers: set[str] = set()
  283. def cell(row: list[str], field: str) -> str:
  284. idx = col_index.get(field)
  285. if idx is None or idx >= len(row):
  286. return ""
  287. # Strip whitespace, then undo any export-side formula-injection quoting
  288. # so export → import round-trips without accumulating a leading quote.
  289. return _desanitize_cell(row[idx].strip())
  290. rows: list[ImportRowResult] = []
  291. valid = error = skipped = 0
  292. for row_number, raw_row in enumerate(reader, start=1):
  293. # Fully blank row (no non-empty cell) → skip silently.
  294. if not any(c.strip() for c in raw_row):
  295. rows.append(ImportRowResult(row_number=row_number, status="skipped", reason="Empty row"))
  296. skipped += 1
  297. continue
  298. material = cell(raw_row, "material")
  299. brand = cell(raw_row, "brand") or None
  300. color_name = cell(raw_row, "color_name") or None
  301. if not material:
  302. rows.append(
  303. ImportRowResult(
  304. row_number=row_number,
  305. status="error",
  306. reason="material is required",
  307. brand=brand,
  308. color_name=color_name,
  309. )
  310. )
  311. error += 1
  312. continue
  313. data: dict = {"material": material}
  314. if brand:
  315. data["brand"] = brand
  316. if color_name:
  317. data["color_name"] = color_name
  318. row_error: str | None = None
  319. # Plain text passthrough columns.
  320. for field in (
  321. "subtype",
  322. "effect_type",
  323. "extra_colors",
  324. "note",
  325. "storage_location",
  326. "category",
  327. "material_number",
  328. ):
  329. value = cell(raw_row, field)
  330. if value:
  331. data[field] = value
  332. # Numeric columns: parse only if present, else leave to schema defaults.
  333. for field in _INT_COLUMNS:
  334. value = cell(raw_row, field)
  335. if value:
  336. try:
  337. data[field] = int(value)
  338. except ValueError:
  339. row_error = f"{field} must be a whole number (got '{value}')"
  340. break
  341. if row_error is None:
  342. for field in _FLOAT_COLUMNS:
  343. value = cell(raw_row, field)
  344. if value:
  345. try:
  346. data[field] = float(value)
  347. except ValueError:
  348. row_error = f"{field} must be a number (got '{value}')"
  349. break
  350. # Bounds check: weight_used must be within [0, label_weight]. The schema
  351. # accepts any float, so a negative or over-full value would otherwise be
  352. # imported silently. label_weight falls back to the schema default when
  353. # the CSV omits it.
  354. if row_error is None and "weight_used" in data:
  355. used = data["weight_used"]
  356. label = data.get("label_weight", _DEFAULT_LABEL_WEIGHT)
  357. if used < 0:
  358. row_error = f"weight_used cannot be negative (got {used})"
  359. elif used > label:
  360. row_error = f"weight_used ({used}) exceeds label_weight ({label})"
  361. # `last_used` is an ORM-only timestamp (not on SpoolCreate); parse it
  362. # here and apply it to the validated dict after the SpoolCreate gate.
  363. last_used: datetime | None = None
  364. if row_error is None:
  365. last_used_cell = cell(raw_row, "last_used")
  366. if last_used_cell:
  367. last_used = _parse_datetime(last_used_cell)
  368. if last_used is None:
  369. row_error = f"last_used must be an ISO date/time (got '{last_used_cell}')"
  370. resolved_color = False
  371. cross_material_color = False
  372. if row_error is None:
  373. # Colour precedence: explicit rgba wins; else resolve brand+name
  374. # from the catalog; else leave blank.
  375. rgba_cell = cell(raw_row, "rgba")
  376. if rgba_cell:
  377. normalized = _normalize_rgba(rgba_cell)
  378. if normalized is None:
  379. row_error = f"rgba must be 6- or 8-char hex (got '{rgba_cell}')"
  380. else:
  381. data["rgba"] = normalized
  382. else:
  383. resolved = _resolve_color(catalog, brand, color_name, material)
  384. if resolved is not None:
  385. rgba_val, extra_val, effect_val, cross_material_color = resolved
  386. data["rgba"] = rgba_val
  387. # CSV-supplied extra_colors/effect_type take precedence over
  388. # the catalog's; only fill from catalog when absent.
  389. if extra_val and "extra_colors" not in data:
  390. data["extra_colors"] = extra_val
  391. if effect_val and "effect_type" not in data:
  392. data["effect_type"] = effect_val
  393. resolved_color = True
  394. if row_error is not None:
  395. rows.append(
  396. ImportRowResult(
  397. row_number=row_number,
  398. status="error",
  399. reason=row_error,
  400. material=material,
  401. brand=brand,
  402. color_name=color_name,
  403. )
  404. )
  405. error += 1
  406. continue
  407. # Final gate: SpoolCreate runs the same validators the form uses
  408. # (rgba pattern, extra_colors/effect_type normalisation, bounds).
  409. try:
  410. spool = SpoolCreate(**data)
  411. except ValidationError as exc:
  412. rows.append(
  413. ImportRowResult(
  414. row_number=row_number,
  415. status="error",
  416. reason=_readable_validation_error(exc),
  417. material=material,
  418. brand=brand,
  419. color_name=color_name,
  420. )
  421. )
  422. error += 1
  423. continue
  424. spool_data = spool.model_dump()
  425. if last_used is not None:
  426. # last_used isn't a SpoolCreate field; graft it onto the persisted
  427. # dict so the ORM object carries it.
  428. spool_data["last_used"] = last_used
  429. # Supplier assignments (#2988): match names against the existing list,
  430. # never create. Unknown names are warnings, not row errors — the row
  431. # imports and the unmatched assignment is dropped. The purchase source
  432. # counts as an assignment even when the `suppliers` cell omits it.
  433. supplier_ids: list[int] = []
  434. purchase_supplier_id: int | None = None
  435. names = [n.strip() for n in cell(raw_row, "suppliers").split(";") if n.strip()]
  436. purchase_name = cell(raw_row, "purchase_supplier").strip()
  437. if purchase_name and supplier_name_key(purchase_name) not in {supplier_name_key(n) for n in names}:
  438. names.append(purchase_name)
  439. for name in names:
  440. key = supplier_name_key(name)
  441. supplier_id = supplier_map.get(key)
  442. if supplier_id is None:
  443. # Once per name, not once per row: a 500-row export against an
  444. # empty supplier list is one missing supplier, not 500 problems.
  445. if key not in unknown_suppliers:
  446. unknown_suppliers.add(key)
  447. warnings.append(f"Unknown supplier '{name}' — assignments dropped")
  448. elif supplier_id not in supplier_ids:
  449. supplier_ids.append(supplier_id)
  450. if purchase_name:
  451. purchase_supplier_id = supplier_map.get(supplier_name_key(purchase_name))
  452. rows.append(
  453. ImportRowResult(
  454. row_number=row_number,
  455. status="valid",
  456. material=material,
  457. brand=brand,
  458. color_name=color_name,
  459. rgba=spool.rgba,
  460. resolved_color=resolved_color,
  461. cross_material_color=cross_material_color,
  462. duplicate_of_existing=_spool_key(material, brand, color_name) in existing_keys,
  463. spool=spool_data,
  464. supplier_ids=supplier_ids,
  465. purchase_supplier_id=purchase_supplier_id,
  466. )
  467. )
  468. valid += 1
  469. return ImportPreview(
  470. columns=CSV_COLUMNS,
  471. total=valid + error + skipped,
  472. valid_count=valid,
  473. error_count=error,
  474. skipped_count=skipped,
  475. rows=rows,
  476. warnings=warnings,
  477. )
  478. def serialize(spools: list[Spool]) -> bytes:
  479. """Render spools to CSV bytes using the fixed schema (export side).
  480. rgba is written without a leading `#`, matching the import-side
  481. normalisation, so export → import round-trips without transformation.
  482. `remaining` is derived (label_weight - weight_used) and `last_used` is
  483. written as ISO-8601; empty/None fields become empty cells.
  484. """
  485. output = io.StringIO()
  486. writer = csv.writer(output)
  487. writer.writerow(CSV_COLUMNS)
  488. for spool in spools:
  489. writer.writerow([_sanitize_cell(_cell_value(spool, col)) for col in CSV_COLUMNS])
  490. return output.getvalue().encode("utf-8")
  491. def _sanitize_cell(value: str) -> str:
  492. """Neutralise spreadsheet formula injection.
  493. A free-text field (note, color_name) starting with =, +, -, @, tab, or CR
  494. is evaluated as a formula by Excel/Sheets/LibreOffice when the CSV is
  495. opened. Prefixing with a single quote forces it to render as literal text.
  496. `_desanitize_cell` is the exact inverse, applied on import.
  497. """
  498. if value and value[0] in _FORMULA_INJECTION_PREFIXES:
  499. return "'" + value
  500. return value
  501. def _desanitize_cell(value: str) -> str:
  502. """Undo `_sanitize_cell` on import so the round-trip is lossless.
  503. Export prefixes formula-looking cells with a single quote; strip exactly
  504. that quote back off when the next character is one of the guarded prefixes,
  505. so `'=SUM(A1)` reads back as `=SUM(A1)` and the value doesn't accumulate a
  506. leading quote on every export→import cycle. A quote followed by anything
  507. else is left untouched — only the prefix `_sanitize_cell` could have added
  508. is removed.
  509. """
  510. if len(value) >= 2 and value[0] == "'" and value[1] in _FORMULA_INJECTION_PREFIXES:
  511. return value[1:]
  512. return value
  513. def _cell_value(spool: Spool, col: str) -> str:
  514. """Render one spool field for export. Handles the derived `remaining`
  515. column and ISO-formats `last_used`; everything else is str() of the value."""
  516. if col == "remaining":
  517. # Derived for display: label_weight - weight_used, clamped at 0.
  518. return str(max(0, round((spool.label_weight or 0) - (spool.weight_used or 0))))
  519. if col == "suppliers":
  520. # Derived (#2988): "; "-joined supplier names, no decoration — the
  521. # purchase source has its own column so import can match plain names.
  522. # The export query loads supplier_links explicitly.
  523. return "; ".join(link.supplier_name for link in spool.supplier_links)
  524. if col == "purchase_supplier":
  525. return next((link.supplier_name for link in spool.supplier_links if link.is_purchase_source), "")
  526. value = getattr(spool, col, None)
  527. if value is None:
  528. return ""
  529. if isinstance(value, datetime):
  530. return value.isoformat()
  531. # Whole-number floats (weight_used, cost_per_kg) export as ints — "300",
  532. # not "300.0" — for a cleaner, human-friendly CSV. import re-parses fine.
  533. if isinstance(value, float) and value.is_integer():
  534. return str(int(value))
  535. return str(value)