Add AssetItem CSV import/export
CI / test (push) Failing after 8s

/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.
This commit is contained in:
toanhoang
2026-08-23 07:41:52 -04:00
parent d9b360c1df
commit aa7f5515db
7 changed files with 429 additions and 43 deletions
+24
View File
@@ -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 <a href> 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<NextResponse> {
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"',
},
});
}
+30
View File
@@ -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<NextResponse> {
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 });
}
+87
View File
@@ -77,6 +77,50 @@ export default function AssetsPage() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
"idle"
);
const [importMessage, setImportMessage] = useState<string | null>(null);
async function handleImportChange(event: React.ChangeEvent<HTMLInputElement>) {
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<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
@@ -174,6 +218,49 @@ export default function AssetsPage() {
)}
</section>
<section className="flex max-w-2xl flex-col gap-3">
<h2 className="text-lg font-semibold">Import / Export CSV</h2>
<p className="text-sm text-muted-foreground">
Cột nhận diện theo tên (không phân biệt hoa/thường): <code>serial number</code>,{" "}
<code>manufacturer</code>, <code>model</code> (phải khớp Asset Model đã có sẵn ở{" "}
<a href="/asset-models" className="underline">
/asset-models
</a>
), <code>purchase date</code> (bắt buộc), <code>warranty end date</code>,{" "}
<code>purchase order id</code> (tuỳ chọn -- bỏ trống thì tự tính từ warranty của model).
</p>
<div className="flex flex-wrap items-center gap-3">
<a href="/api/assets/export">
<Button type="button" variant="outline">
Export CSV
</Button>
</a>
<input
type="file"
accept=".csv,text/csv"
onChange={handleImportChange}
disabled={importStatus === "uploading"}
className="hidden"
id="assets-csv-input"
/>
<Button asChild>
<label htmlFor="assets-csv-input" className="cursor-pointer">
{importStatus === "uploading" ? "Đang import..." : "Import CSV"}
</label>
</Button>
</div>
{importMessage ? (
<p
className={
importStatus === "error" ? "text-sm text-destructive" : "text-sm text-foreground"
}
>
{importMessage}
</p>
) : null}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset</h2>
+225
View File
@@ -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<AssetItemRecord[]> {
});
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<keyof ImportAssetItemRow, string[]> = {
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<Record<keyof ImportAssetItemRow, number>> = {};
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<ImportAssetItemsResult> {
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<string> {
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";
}
+60
View File
@@ -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;
}
+2 -43
View File
@@ -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<keyof ImportExpectedAssetRow, string[]> = {
],
};
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" };
}