/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:
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user