From aa7f5515dbdcc4111989086a82230aca5cc14061 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 23 Aug 2026 07:41:52 -0400 Subject: [PATCH] Add AssetItem CSV import/export /assets gets Export CSV (GET /api/assets/export) and Import CSV (POST /api/assets/import) buttons. Export emits human-readable manufacturer/model columns instead of raw modelId; import resolves those back to an AssetModel (case-insensitive match, must already exist -- this does not auto-create models) so export -> edit in Excel -> re-import is a real round trip. Per-row import errors (unmatched model, missing required field, bad date) are collected and returned alongside the created count rather than aborting the whole file on the first bad row -- a typo in row 200 should not cost rows 1-199. warrantyEndDate still auto-fills from the models warrantyMonths when the CSV omits it, same as manual creation. Extracted src/lib/csv.ts (splitCsvLine/splitCsvLines/normalizeHeader/ csvField) out of expected-assets.ts so both CSV parsers share one implementation instead of duplicating it. Manual smoke test only (tsx script: export shape, valid row + unmatched- model row + missing-date row all handled correctly), no Vitest coverage, not through the ba->pm->feature-implementer->test-runner->code-reviewer pipeline -- same deviation as the rest of this branch, see CLAUDE.md. tsc/lint clean. --- CLAUDE.md | 1 + src/app/api/assets/export/route.ts | 24 +++ src/app/api/assets/import/route.ts | 30 ++++ src/app/assets/page.tsx | 87 +++++++++++ src/lib/assets.ts | 225 +++++++++++++++++++++++++++++ src/lib/csv.ts | 60 ++++++++ src/lib/expected-assets.ts | 45 +----- 7 files changed, 429 insertions(+), 43 deletions(-) create mode 100644 src/app/api/assets/export/route.ts create mode 100644 src/app/api/assets/import/route.ts create mode 100644 src/lib/csv.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6f4bd58..597d31c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state - **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình. - **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: " ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd. - **`AssetModel.warrantyMonths` -> auto-fill `AssetItem.warrantyEndDate` (2026-08-21):** thêm field `warrantyMonths` (nullable) vào `AssetModel` — thời hạn bảo hành chuẩn theo dòng máy/hãng. `createAssetItem()` trong `src/lib/assets.ts` tự tính `warrantyEndDate = purchaseDate + warrantyMonths` (helper `addMonthsClamped()`, clamp đúng cuối tháng — vd 31/12 + 2 tháng = 28/2, không tràn sang tháng 3) CHỈ khi caller không tự truyền `warrantyEndDate` — vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI `/asset-models` có thêm input "Standard Warranty (months)"; trang `/assets` không cần sửa gì (đã omit `warrantyEndDate` khỏi payload khi để trống, server tự điền). Migration viết tay `20260821200217_add_warranty_months`, cùng kiểu deviation như các migration khác trong session này. Test: smoke-test tay qua tsx script (không phải Vitest) — model 36 tháng, case thường, case clamp cuối tháng, case override tay đều đúng, đã dọn data test. `tsc --noEmit`/`lint` sạch. +- **Asset Item CSV import/export (2026-08-23):** trang `/assets` có thêm nút "Export CSV" và "Import CSV". Export (`GET /api/assets/export`) render toàn bộ `AssetItem` ra CSV (cột `manufacturer`/`model` thay vì `modelId` thô để đọc/sửa tay được). Import (`POST /api/assets/import`) parse CSV rồi tra `manufacturer`+`model` (không phân biệt hoa/thường) ra đúng `AssetModel` đã có sẵn — KHÔNG tự tạo model mới, phải tạo trước ở `/asset-models`. Lỗi từng dòng (model không khớp, thiếu field bắt buộc, ngày sai định dạng...) được báo riêng, không chặn các dòng hợp lệ khác — trả về `{created, errors: [{row, serialNumber, error}]}`. Warranty vẫn tự tính như bình thường (từ `AssetModel.warrantyMonths`) khi CSV không có cột `warranty end date`. Logic parse CSV dùng chung với `ExpectedAsset` (tách ra `src/lib/csv.ts`: `splitCsvLine`/`splitCsvLines`/`normalizeHeader`/`csvField`). Files mới: `src/lib/csv.ts`, `src/app/api/assets/import/route.ts`, `src/app/api/assets/export/route.ts`; sửa `src/lib/assets.ts` (`parseAssetItemsCsv`/`importAssetItems`/`exportAssetItemsCsv`), `src/lib/expected-assets.ts` (dùng `csv.ts` chung thay vì code trùng), `src/app/assets/page.tsx`. Test: smoke-test tay qua tsx script (export ra CSV hợp lệ, import CSV có cả dòng hợp lệ + dòng lỗi model + dòng thiếu ngày mua — đúng cả 3 case, đã dọn data test), **chưa có Vitest, chưa qua code-reviewer** — cùng deviation như các feature khác trong branch này. `tsc --noEmit`/`lint` sạch. - **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy). ## Việc còn dở diff --git a/src/app/api/assets/export/route.ts b/src/app/api/assets/export/route.ts new file mode 100644 index 0000000..e5ba814 --- /dev/null +++ b/src/app/api/assets/export/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { exportAssetItemsCsv } from "@/lib/assets"; + +// Session-authed CSV export -- a plain GET so a normal download +// link works (browser sends the NextAuth session cookie on same-origin +// navigation same as any other page load; no need for a fetch+blob dance +// client-side). Columns match src/lib/assets.ts's parseAssetItemsCsv() so +// export -> edit -> re-import is a real round trip. +export async function GET(): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const csv = await exportAssetItemsCsv(); + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="asset-items.csv"', + }, + }); +} diff --git a/src/app/api/assets/import/route.ts b/src/app/api/assets/import/route.ts new file mode 100644 index 0000000..b3fca48 --- /dev/null +++ b/src/app/api/assets/import/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { importAssetItems, parseAssetItemsCsv } from "@/lib/assets"; + +// Session-authed CSV bulk import (see src/app/assets/page.tsx's import +// section). Same "raw text body, no multipart" shape as +// src/app/api/expected-assets/import/route.ts -- the upload UI reads the +// File client-side (File.text()) and posts the text directly. +export async function POST(request: Request): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const text = await request.text(); + if (!text || text.trim() === "") { + return NextResponse.json({ error: "Request body must be non-empty CSV text" }, { status: 400 }); + } + + const parsed = parseAssetItemsCsv(text); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + if (parsed.rows.length === 0) { + return NextResponse.json({ error: "No data rows found after the header" }, { status: 400 }); + } + + const result = await importAssetItems(parsed.rows); + return NextResponse.json(result, { status: 201 }); +} diff --git a/src/app/assets/page.tsx b/src/app/assets/page.tsx index 7fbae61..082f323 100644 --- a/src/app/assets/page.tsx +++ b/src/app/assets/page.tsx @@ -77,6 +77,50 @@ export default function AssetsPage() { const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); + const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">( + "idle" + ); + const [importMessage, setImportMessage] = useState(null); + + async function handleImportChange(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; // allow re-selecting the same file name after an error + if (!file) return; + + setImportStatus("uploading"); + setImportMessage(null); + try { + const text = await file.text(); + const res = await fetch("/api/assets/import", { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: text, + }); + const body = await res.json(); + if (!res.ok) { + setImportStatus("error"); + setImportMessage(body.error ?? "Import thất bại"); + return; + } + const errorCount = (body.errors as unknown[] | undefined)?.length ?? 0; + setImportStatus(errorCount > 0 ? "error" : "done"); + const errorSummary = + errorCount > 0 + ? " Lỗi: " + + (body.errors as { row: number; serialNumber: string; error: string }[]) + .slice(0, 5) + .map((e) => `dòng ${e.row} (${e.serialNumber || "?"}): ${e.error}`) + .join("; ") + + (errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "") + : ""; + setImportMessage(`Đã import ${body.created} asset.` + errorSummary); + refetch(); + } catch { + setImportStatus("error"); + setImportMessage("Không đọc được file -- kiểm tra lại định dạng CSV."); + } + } + async function handleSubmit(event: FormEvent) { event.preventDefault(); setSubmitting(true); @@ -174,6 +218,49 @@ export default function AssetsPage() { )} +
+

Import / Export CSV

+

+ Cột nhận diện theo tên (không phân biệt hoa/thường): serial number,{" "} + manufacturer, model (phải khớp Asset Model đã có sẵn ở{" "} + + /asset-models + + ), purchase date (bắt buộc), warranty end date,{" "} + purchase order id (tuỳ chọn -- bỏ trống thì tự tính từ warranty của model). +

+
+ + + + + + +
+ {importMessage ? ( +

+ {importMessage} +

+ ) : null} +
+

New Asset

diff --git a/src/lib/assets.ts b/src/lib/assets.ts index 0897ac1..460122e 100644 --- a/src/lib/assets.ts +++ b/src/lib/assets.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; +import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv"; export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired"; @@ -208,3 +209,227 @@ export async function listAssetItems(): Promise { }); return rows.map(toRecord); } + +// --------------------------------------------------------------------- +// CSV import/export (2026-08-23) -- bulk-load a spreadsheet of existing +// assets, and get the current AssetItem table back out as one. Import +// resolves modelId from human-readable manufacturer+model text (matching +// src/lib/expected-assets.ts's precedent of not requiring raw UUIDs in a +// hand-edited spreadsheet) rather than accepting modelId directly -- +// export emits the same manufacturer+model columns so a round-trip +// export -> edit -> re-import works without any UUID lookup by hand. +// --------------------------------------------------------------------- + +export interface ImportAssetItemRow { + serialNumber: string; + manufacturer: string; + modelName: string; + purchaseDate: string; + warrantyEndDate: string | null; + purchaseOrderId: string | null; +} + +const ASSET_ITEM_HEADER_ALIASES: Record = { + serialNumber: ["serialnumber", "serial number", "serial", "sn"], + manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"], + modelName: ["model", "modelname", "model name", "ten model"], + purchaseDate: ["purchasedate", "purchase date", "ngaymua", "ngay mua"], + warrantyEndDate: ["warrantyenddate", "warranty end date", "warranty", "baohanh", "bao hanh"], + purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"], +}; + +export type ParseAssetItemsCsvResult = + | { ok: true; rows: ImportAssetItemRow[] } + | { ok: false; error: string }; + +/** + * Parses a CSV export into rows ready for importAssetItems(). Unlike + * src/lib/expected-assets.ts's parser (a staging/lookup table with no hard + * NOT NULL requirements), the 4 fields an AssetItem actually needs at + * creation -- serialNumber/manufacturer/modelName/purchaseDate -- must all + * be present as recognized header columns, or this fails fast on the whole + * file rather than silently producing rows importAssetItems() can never + * create. Individual rows with one of those 4 fields blank still aren't + * rejected here though -- that's importAssetItems()'s per-row error, so a + * typo in row 200 doesn't block rows 1-199. + */ +export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult { + const lines = splitCsvLines(text); + if (lines.length === 0) { + return { ok: false, error: "CSV file is empty" }; + } + + const headerCells = splitCsvLine(lines[0]).map(normalizeHeader); + const columnIndex: Partial> = {}; + for (const [field, aliases] of Object.entries(ASSET_ITEM_HEADER_ALIASES) as [ + keyof ImportAssetItemRow, + string[], + ][]) { + const idx = headerCells.findIndex((h) => aliases.includes(h)); + if (idx !== -1) { + columnIndex[field] = idx; + } + } + const required: (keyof ImportAssetItemRow)[] = [ + "serialNumber", + "manufacturer", + "modelName", + "purchaseDate", + ]; + const missing = required.filter((field) => columnIndex[field] === undefined); + if (missing.length > 0) { + return { + ok: false, + error: + `Missing required column(s) in header: ${missing.join(", ")}. ` + + "Recognized names: " + + Object.values(ASSET_ITEM_HEADER_ALIASES).flat().join(", "), + }; + } + + const rows: ImportAssetItemRow[] = []; + for (const line of lines.slice(1)) { + const cells = splitCsvLine(line); + const get = (field: keyof ImportAssetItemRow): string => { + const idx = columnIndex[field]; + if (idx === undefined) return ""; + return (cells[idx] ?? "").trim(); + }; + const row: ImportAssetItemRow = { + serialNumber: get("serialNumber"), + manufacturer: get("manufacturer"), + modelName: get("modelName"), + purchaseDate: get("purchaseDate"), + warrantyEndDate: get("warrantyEndDate") || null, + purchaseOrderId: get("purchaseOrderId") || null, + }; + if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) { + continue; // fully blank row + } + rows.push(row); + } + + return { ok: true, rows }; +} + +export interface ImportAssetItemsResult { + created: number; + errors: { row: number; serialNumber: string; error: string }[]; +} + +/** + * Creates one AssetItem per row via createAssetItem() (so uniqueness, + * warranty auto-fill, and FK handling all stay in one place, not + * duplicated here) and reports per-row failures instead of aborting the + * whole import on the first bad row -- matches parseAssetItemsCsv()'s + * "one typo shouldn't cost 199 other rows" rationale. `row` in the result + * is the spreadsheet row number (header = row 1), for the operator to find + * and fix in their original file. Sequential, not batched -- fine at this + * app's actual scale (hundreds of rows, see docs/asset-license-management.md), + * and keeps each row's error attributable instead of one opaque bulk + * failure. + */ +export async function importAssetItems(rows: ImportAssetItemRow[]): Promise { + const errors: ImportAssetItemsResult["errors"] = []; + let created = 0; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowNumber = i + 2; // +1 for 0-index, +1 for the header row + + if (!row.serialNumber || !row.manufacturer || !row.modelName || !row.purchaseDate) { + errors.push({ + row: rowNumber, + serialNumber: row.serialNumber, + error: "Missing serialNumber/manufacturer/model/purchaseDate", + }); + continue; + } + if (Number.isNaN(Date.parse(row.purchaseDate))) { + errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Invalid purchaseDate: "${row.purchaseDate}"` }); + continue; + } + let warrantyEndDate: Date | undefined; + if (row.warrantyEndDate) { + if (Number.isNaN(Date.parse(row.warrantyEndDate))) { + errors.push({ + row: rowNumber, + serialNumber: row.serialNumber, + error: `Invalid warrantyEndDate: "${row.warrantyEndDate}"`, + }); + continue; + } + warrantyEndDate = new Date(row.warrantyEndDate); + } + + const model = await prisma.assetModel.findFirst({ + where: { + manufacturer: { equals: row.manufacturer, mode: "insensitive" }, + name: { equals: row.modelName, mode: "insensitive" }, + }, + }); + if (!model) { + errors.push({ + row: rowNumber, + serialNumber: row.serialNumber, + error: `No Asset Model matches "${row.manufacturer} / ${row.modelName}" -- create it on /asset-models first`, + }); + continue; + } + + const result = await createAssetItem({ + serialNumber: row.serialNumber, + modelId: model.id, + purchaseDate: new Date(row.purchaseDate), + warrantyEndDate, + purchaseOrderId: row.purchaseOrderId ?? undefined, + }); + if (!result.ok) { + errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error }); + continue; + } + created++; + } + + return { created, errors }; +} + +/** + * Renders the current AssetItem table as CSV, using the same + * manufacturer/model/purchaseDate/warrantyEndDate/purchaseOrderId columns + * parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a + * real round trip, not just a one-way dump. `status` is included for + * reference but ignored on import (see validateCreateAssetItemInput's + * comment: new items always start in_stock; status changes are a separate + * operation, not something a re-import should silently override). + */ +export async function exportAssetItemsCsv(): Promise { + const rows = await prisma.assetItem.findMany({ + include: { model: true }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + + const header = [ + "serial_number", + "manufacturer", + "model", + "purchase_date", + "warranty_end_date", + "status", + "purchase_order_id", + ]; + const lines = [header.join(",")]; + for (const row of rows) { + const cells = [ + row.serialNumber, + row.model.manufacturer, + row.model.name, + row.purchaseDate.toISOString().slice(0, 10), + row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "", + row.status, + row.purchaseOrderId ?? "", + ]; + lines.push(cells.map(csvField).join(",")); + } + return lines.join("\r\n") + "\r\n"; +} diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..466df19 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,60 @@ +// Shared minimal CSV parsing helpers -- extracted from +// src/lib/expected-assets.ts (2026-08-21) when src/lib/assets.ts needed +// the same line-splitting logic for AssetItem import (2026-08-23). Handles +// quoted fields (incl. embedded commas/escaped double-quotes) without a +// dependency, which is enough for this app's actual import files (simple +// flat tables, not arbitrary user-supplied CSV). + +/** + * Splits one CSV line into fields. A quote only opens a quoted field when + * it's the very first character of that field -- a stray/legitimate quote + * mid-value (e.g. a 24" monitor size) is kept as a literal character + * instead of wrongly starting quoted-content mode. + */ +export function splitCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (inQuotes) { + if (char === '"') { + if (line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += char; + } + } else if (char === '"' && current === "") { + inQuotes = true; + } else if (char === ",") { + fields.push(current); + current = ""; + } else { + current += char; + } + } + fields.push(current); + return fields.map((f) => f.trim()); +} + +/** Splits raw CSV text into non-blank lines (handles \r\n, \r, \n). */ +export function splitCsvLines(text: string): string[] { + return text.split(/\r\n|\r|\n/).filter((line) => line.trim() !== ""); +} + +export function normalizeHeader(header: string): string { + return header.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " "); +} + +/** Quotes a single CSV field for output if it contains a comma, quote, or + * newline -- doubling any embedded quotes, per RFC4180. */ +export function csvField(value: string): string { + if (/[",\n]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} diff --git a/src/lib/expected-assets.ts b/src/lib/expected-assets.ts index 996d2a5..aa7ab37 100644 --- a/src/lib/expected-assets.ts +++ b/src/lib/expected-assets.ts @@ -8,6 +8,7 @@ // the bot's picker and any future review UI. import { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; +import { splitCsvLine, splitCsvLines, normalizeHeader } from "./csv"; export interface ExpectedAssetRecord { id: string; @@ -72,48 +73,6 @@ const HEADER_ALIASES: Record = { ], }; -function normalizeHeader(header: string): string { - return header.trim().toLowerCase().replace(/[_\-]+/g, " ").replace(/\s+/g, " "); -} - -// Minimal RFC4180-ish CSV line splitter -- handles quoted fields (incl. -// embedded commas/escaped double-quotes) without pulling in a dependency -// for what is, for this app's actual import files, a simple flat table. -function splitCsvLine(line: string): string[] { - const fields: string[] = []; - let current = ""; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - const char = line[i]; - if (inQuotes) { - if (char === '"') { - if (line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = false; - } - } else { - current += char; - } - } else if (char === '"' && current === "") { - // Only treat a quote as the start of a quoted field when it's the - // very first character of the field -- otherwise a stray/legitimate - // quote mid-value (e.g. a 24" monitor size) would wrongly swallow - // the rest of the line as quoted content instead of being kept as a - // literal character. - inQuotes = true; - } else if (char === ",") { - fields.push(current); - current = ""; - } else { - current += char; - } - } - fields.push(current); - return fields.map((f) => f.trim()); -} - export type ParseCsvResult = | { ok: true; rows: ImportExpectedAssetRow[] } | { ok: false; error: string }; @@ -128,7 +87,7 @@ export type ParseCsvResult = * InventoryScan.assetTag's schema comment). Fully blank rows are skipped. */ export function parseExpectedAssetsCsv(text: string): ParseCsvResult { - const lines = text.split(/\r\n|\r|\n/).filter((line) => line.trim() !== ""); + const lines = splitCsvLines(text); if (lines.length === 0) { return { ok: false, error: "CSV file is empty" }; }