import { z } from "zod"; import { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv"; import { createOrGetArea } from "./areas"; export type AssetItemStatus = "in_stock" | "in_use" | "repair" | "retired" | "lost"; export interface AssetItemRecord { id: string; serial_number: string; asset_tag: string | null; model_id: string; purchase_date: string | null; warranty_end_date: string | null; status: AssetItemStatus; cost: number | null; cost_currency: string | null; area_id: string | null; purchase_order_id: string | null; // Free-text PO number from an external system -- NOT purchase_order_id // above (a real FK). See schema.prisma's AssetItem.externalPoNumber // comment. external_po_number: string | null; created_at: string; } export interface CreateAssetItemInput { serialNumber: string; assetTag?: string | null; modelId: string; // Optional as of 2026-08-24 (was required) -- some bulk-imported legacy // rows have no known purchase date at all. The manual /assets create // form still marks its date input required (a NEW purchase should // always have a known date); this only relaxes the underlying type for // historical/imported data. See schema.prisma's AssetItem.purchaseDate // comment. purchaseDate?: Date; warrantyEndDate?: Date; cost?: number | null; costCurrency?: string | null; areaId?: string | null; purchaseOrderId?: string | null; externalPoNumber?: string | null; // Only set by importAssetItems() (bulk-loading already-deployed // inventory from a real export -- see docs/asset-license-management.md's // 2026-08-24 section). The manual /assets create form never sends this, // so a plain createAssetItem() call keeps defaulting to "in_stock" same // as before this field existed -- see validateCreateAssetItemInput's // comment for why that stays the rule for *new* purchases entered one at // a time. status?: AssetItemStatus; } export type ValidationResult = | { ok: true; value: CreateAssetItemInput } | { ok: false; error: string }; // Mirrors src/lib/employees.ts's nonEmptyString helper. function nonEmptyString(message: string) { return z.string({ required_error: message, invalid_type_error: message }).refine( (value) => value.trim() !== "", { message } ); } // Accepts any string Date.parse() can handle (e.g. "2026-07-31") and // transforms it into a Date for Prisma — validation and normalization in // one step, same spirit as the uuid() checks elsewhere in this codebase. function dateString(message: string) { return z .string({ required_error: message, invalid_type_error: message }) .refine((value) => !Number.isNaN(Date.parse(value)), { message }) .transform((value) => new Date(value)); } const createAssetItemSchema = z.object({ serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"), assetTag: z .string({ invalid_type_error: "assetTag must be a string" }) .optional() .nullable(), // Required FK to AssetModel — replaced the free-text `model` String (see // schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02 // (2)" section). Only a UUID *format* check here, whether it references a // real AssetModel is left to Prisma's own FK constraint at insert time // (see createAssetItem()'s P2003 handling below). Mirrors // src/lib/licenses.ts's vendorId handling. modelId: z .string({ required_error: "modelId is required and must be a valid UUID string" }) .uuid("modelId is required and must be a valid UUID string"), purchaseDate: dateString("purchaseDate must be a valid date string").optional(), warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), // FK to PurchaseOrder — nullable/optional, supply-side traceability (not // every AssetItem goes through a formal PO). Only a UUID *format* check // here, whether it references a real PurchaseOrder is left to Prisma's // own FK constraint at insert time (see createAssetItem()'s P2003 // handling below). Mirrors src/lib/purchase-orders.ts's receivedById // optional().nullable() pattern. purchaseOrderId: z .string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" }) .uuid("purchaseOrderId must be a valid UUID string") .optional() .nullable(), cost: z .number({ invalid_type_error: "cost must be a non-negative number" }) .nonnegative("cost must be a non-negative number") .optional() .nullable(), costCurrency: z .string({ invalid_type_error: "costCurrency must be a string" }) .optional() .nullable(), externalPoNumber: z .string({ invalid_type_error: "externalPoNumber must be a string" }) .optional() .nullable(), areaId: z .string({ invalid_type_error: "areaId must be a valid UUID string" }) .uuid("areaId must be a valid UUID string") .optional() .nullable(), }); /** * Validates raw (untyped) asset-item-creation input. serialNumber must be a * non-empty string, assetTag is optional/nullable free text (no format * check -- company tag schemes vary), modelId must be a valid UUID string, * purchaseDate must be a valid date string, warrantyEndDate is optional * but must be a valid date string if present. purchaseOrderId is * optional/nullable but must be a valid UUID if present. status is not * accepted here — new assets entered one at a time via the manual /assets * form always start "in_stock" (same pattern as Request always starting * "Draft"). importAssetItems() below is the one exception: it calls * createAssetItem() directly (bypassing this validator) with a real status * resolved from the import file, because it's loading already-deployed * inventory, not new purchases -- see docs/asset-license-management.md's * 2026-08-24 section. */ export function validateCreateAssetItemInput(input: { serialNumber?: unknown; assetTag?: unknown; modelId?: unknown; purchaseDate?: unknown; warrantyEndDate?: unknown; purchaseOrderId?: unknown; cost?: unknown; costCurrency?: unknown; externalPoNumber?: unknown; areaId?: unknown; }): ValidationResult { const result = createAssetItemSchema.safeParse(input); if (!result.success) { const message = result.error.issues[0]?.message ?? "Invalid input"; return { ok: false, error: message }; } return { ok: true, value: result.data }; } function toRecord(row: { id: string; serialNumber: string; assetTag: string | null; modelId: string; purchaseDate: Date | null; warrantyEndDate: Date | null; status: AssetItemStatus; cost: Prisma.Decimal | null; costCurrency: string | null; areaId: string | null; purchaseOrderId: string | null; externalPoNumber: string | null; createdAt: Date; }): AssetItemRecord { return { id: row.id, serial_number: row.serialNumber, asset_tag: row.assetTag, model_id: row.modelId, purchase_date: row.purchaseDate ? row.purchaseDate.toISOString() : null, warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, status: row.status, cost: row.cost ? row.cost.toNumber() : null, cost_currency: row.costCurrency, area_id: row.areaId, purchase_order_id: row.purchaseOrderId, external_po_number: row.externalPoNumber, created_at: row.createdAt.toISOString(), }; } export type CreateAssetItemResult = | { ok: true; value: AssetItemRecord } | { ok: false; error: string }; /** * Creates an AssetItem. If serialNumber collides with an existing row, * Prisma raises a known error (code P2002, unique constraint violation) — * caught here and translated into the same `{ok:false,error}` shape as * input validation failures, rather than letting a raw Prisma error leak * to the API layer. Mirrors createEmployee()/createRequest()'s P2003 * handling for FK violations. Two separate FKs can fail here (modelId -> * AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same * error code (P2003) for either, so the FK constraint name in * `error.meta.field_name` is used to tell them apart and return a targeted * message, rather than a raw Prisma error leaking to the API layer. Mirrors * src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's * createPurchaseOrder() pattern for disambiguating between two FKs on the * same model. */ // Adds `months` calendar months to `date`, clamping to the last day of the // target month rather than letting JS's native Date rollover push into the // month after (e.g. Jan 31 + 1 month must land on Feb 28/29, not Mar // 2/3 -- `date.setMonth()` alone does the latter). UTC throughout: Prisma // @db.Date fields round-trip as UTC-midnight Dates (see dateString() // above), and doing this in UTC avoids the host server's local timezone // shifting the day near midnight. function addMonthsClamped(date: Date, months: number): Date { const targetMonthIndex = date.getUTCMonth() + months; const daysInTargetMonth = new Date( Date.UTC(date.getUTCFullYear(), targetMonthIndex + 1, 0) ).getUTCDate(); return new Date( Date.UTC( date.getUTCFullYear(), targetMonthIndex, Math.min(date.getUTCDate(), daysInTargetMonth) ) ); } export async function createAssetItem( input: CreateAssetItemInput ): Promise { try { // warrantyEndDate defaults from the model's standard warranty term // (AssetModel.warrantyMonths) when the caller doesn't supply one // explicitly -- see schema.prisma's AssetModel.warrantyMonths comment // for why this stays a suggestion (still overridable per item) rather // than a hard-computed column. A modelId that doesn't exist is left for // the insert below's own P2003 handling, same as before this lookup was // added -- this just also opportunistically uses the model row if it's // there. let warrantyEndDate = input.warrantyEndDate ?? null; if (!warrantyEndDate && input.purchaseDate) { const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } }); if (model?.warrantyMonths) { warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths); } } const created = await prisma.assetItem.create({ data: { serialNumber: input.serialNumber, assetTag: input.assetTag ?? null, modelId: input.modelId, purchaseDate: input.purchaseDate ?? null, warrantyEndDate, cost: input.cost ?? null, costCurrency: input.costCurrency ?? null, areaId: input.areaId ?? null, purchaseOrderId: input.purchaseOrderId ?? null, externalPoNumber: input.externalPoNumber ?? null, ...(input.status ? { status: input.status } : {}), }, }); return { ok: true, value: toRecord(created) }; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { // serialNumber and assetTag are both unique now -- error.meta.target // says which constraint actually fired. const target = Array.isArray(error.meta?.target) ? error.meta.target.join(",") : ""; if (target.includes("asset_tag")) { return { ok: false, error: "assetTag already exists" }; } return { ok: false, error: "serialNumber already exists" }; } if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { // Three FKs can fail here now (modelId, purchaseOrderId, areaId) -- // field_name says which constraint actually fired. const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : ""; if (fieldName.includes("purchase_order")) { return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" }; } if (fieldName.includes("area")) { return { ok: false, error: "areaId does not reference an existing area" }; } return { ok: false, error: "modelId does not reference an existing asset model" }; } throw error; } } export interface ListAssetItemsOptions { page?: number; pageSize?: number; status?: AssetItemStatus; modelId?: string; categoryId?: string; areaId?: string; // Matches serialNumber, assetTag, or the name of the AssetItem's current // (non-returned) assignee -- via the assignments relation, not a // separate query, so this stays one round trip. search?: string; } export interface ListAssetItemsResult { items: AssetItemRecord[]; total: number; } const DEFAULT_PAGE_SIZE = 50; /** * Lists AssetItems with server-side filtering + pagination (2026-08-25 -- * was a bare unpaginated findMany(); at ~600+ rows post the 2026-08-24 * real-data import, shipping the whole table to the client on every page * load was wasteful, and /assets's client-side filtering couldn't scale * past "the whole table is already in memory" anyway). Filtering and * pagination both happen in the same WHERE clause -- `total` reflects the * FILTERED count, not the table's overall size, so pagination controls * built from it are correct regardless of which filters are active. */ export async function listAssetItems( options: ListAssetItemsOptions = {} ): Promise { const page = options.page && options.page > 0 ? Math.floor(options.page) : 1; const pageSize = options.pageSize && options.pageSize > 0 ? Math.floor(options.pageSize) : DEFAULT_PAGE_SIZE; const where: Prisma.AssetItemWhereInput = {}; if (options.status) where.status = options.status; if (options.modelId) where.modelId = options.modelId; if (options.categoryId) where.model = { categoryId: options.categoryId }; if (options.areaId) where.areaId = options.areaId; if (options.search && options.search.trim() !== "") { const q = options.search.trim(); where.OR = [ { serialNumber: { contains: q, mode: "insensitive" } }, { assetTag: { contains: q, mode: "insensitive" } }, { assignments: { some: { returnedAt: null, employee: { name: { contains: q, mode: "insensitive" } } }, }, }, ]; } const [rows, total] = await Promise.all([ prisma.assetItem.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], skip: (page - 1) * pageSize, take: pageSize, }), prisma.assetItem.count({ where }), ]); return { items: rows.map(toRecord), total }; } // --------------------------------------------------------------------- // 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; assetTag: string | null; manufacturer: string; modelName: string; purchaseDate: string; warrantyEndDate: string | null; purchaseOrderId: string | null; // Raw text from the CSV's status-ish column (e.g. a real ServiceNow // export's "State": "In use"/"In stock") -- translated to a real // AssetItemStatus by statusFromImportText() in importAssetItems() below, // not here (parsing stays dumb string-in/string-out, same as every other // field on this row). status: string | null; // Raw text -- parsed to a number by importAssetItems() below (also // treats common placeholder junk like "Not available"/"Pending"/"N/A" as // absent, same spirit as externalPoNumber below -- real exports are // messy). cost: string | null; costCurrency: string | null; // NOT the same as purchaseOrderId above -- see schema.prisma's // AssetItem.externalPoNumber comment. externalPoNumber: string | null; // Area name, resolved/created via src/lib/areas.ts's createOrGetArea() // (idempotent by name, same as the bot's Area picker) -- not a raw UUID // column like the other FK-ish fields here, since a hand-edited // spreadsheet realistically has area names, not this app's internal ids. areaName: string | null; } const ASSET_ITEM_HEADER_ALIASES: Record = { serialNumber: ["serialnumber", "serial number", "serial", "sn"], assetTag: ["assettag", "asset tag", "tag", "matag"], manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"], modelName: ["model", "modelname", "model name", "ten model"], purchaseDate: [ "purchasedate", "purchase date", "ngaymua", "ngay mua", // "Order received" is the closest proxy for purchase date in a // ServiceNow-style export -- there's no cleaner "date we bought this" // field there (see docs/asset-license-management.md's 2026-08-24 // section for why "Assigned Date" is NOT used here -- that's when it // was handed to whoever has it now, a different date entirely). "order received", ], warrantyEndDate: [ "warrantyenddate", "warranty end date", "warranty", "baohanh", "bao hanh", "warranty expiration", ], purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"], status: ["status", "state", "tinh trang"], cost: ["cost", "gia", "gia tri"], costCurrency: ["costcurrency", "cost currency", "currency", "don vi tien te"], // "po number" is deliberately distinct from purchaseOrderId's aliases // above ("po"/"poid"/"po id") -- matches a real ServiceNow export's // actual "PO number" column name 1:1, and keeps the two fields // unambiguous from each other by header name alone. externalPoNumber: ["ponumber", "po number", "external po number", "so po"], areaName: ["area", "khu vuc", "location", "stockroom", "vi tri"], }; 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 fields an AssetItem actually needs at * creation -- serialNumber/manufacturer/modelName (purchaseDate is * optional as of 2026-08-24, see schema.prisma's AssetItem.purchaseDate * comment) -- 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 * required 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; } } // purchaseDate is NOT required here (as of 2026-08-24 -- see // schema.prisma's AssetItem.purchaseDate comment): some legacy rows in // a real bulk import genuinely have no known purchase date, and that's // fine to import with a null purchaseDate rather than skipping the row. const required: (keyof ImportAssetItemRow)[] = ["serialNumber", "manufacturer", "modelName"]; 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"), assetTag: get("assetTag") || null, manufacturer: get("manufacturer"), modelName: get("modelName"), purchaseDate: get("purchaseDate"), warrantyEndDate: get("warrantyEndDate") || null, purchaseOrderId: get("purchaseOrderId") || null, status: get("status") || null, cost: get("cost") || null, costCurrency: get("costCurrency") || null, externalPoNumber: get("externalPoNumber") || null, areaName: get("areaName") || 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; updated: number; errors: { row: number; serialNumber: string; error: string }[]; } // Maps free-text status values from a real export (e.g. ServiceNow's // "State" column: "In use"/"In stock") onto this app's AssetItemStatus // enum -- plus this app's own enum values, in case a re-exported CSV // round-trips one back in. "In use" -> "in_use" is a simplification: it // doesn't require (or create) a matching Assignment row, just reflects // that the item is deployed rather than sitting in stock -- see // docs/asset-license-management.md's 2026-08-24 section for why linking // actual Employee assignments is explicitly out of scope for this import. const IMPORT_STATUS_MAP: Record = { "in use": "in_use", in_use: "in_use", assigned: "in_use", // backward-compat: pre-2026-08-25 exports/CSVs said "assigned" "in stock": "in_stock", in_stock: "in_stock", repair: "repair", "in repair": "repair", retired: "retired", disposed: "retired", lost: "lost", missing: "lost", }; function statusFromImportText(text: string): AssetItemStatus | null { return IMPORT_STATUS_MAP[text.trim().toLowerCase()] ?? null; } // Real external exports (e.g. the ServiceNow-style ITAM report this was // built against) fill blank PO-number-ish cells with placeholder junk // instead of leaving them empty -- treat these as "no value" rather than // storing the literal junk text. const PLACEHOLDER_VALUES = new Set(["not available", "pending", "n/a", "na", "none", "-"]); function cleanImportText(text: string | null): string | null { if (!text) return null; return PLACEHOLDER_VALUES.has(text.trim().toLowerCase()) ? null : text; } // Cost cells can carry currency symbols/thousands separators depending on // how the source spreadsheet formatted them (e.g. "1,234.56" or "1234"). // Strips anything that isn't a digit, minus sign, or decimal point before // parsing -- simple on purpose, this isn't trying to be a general money // parser, just tolerant of the punctuation Excel commonly adds. function parseImportCost(text: string): number | null { const cleaned = cleanImportText(text); if (!cleaned) return null; const numeric = Number(cleaned.replace(/[^0-9.-]/g, "")); return Number.isNaN(numeric) ? null : numeric; } /** * Creates one AssetItem per row via createAssetItem(), OR updates it if a * row with the same assetTag or serialNumber already exists (2026-08-24 -- * added so re-uploading a refreshed export, e.g. the company's periodic * ServiceNow re-sync, updates existing rows instead of failing every one * of them on a uniqueness conflict). Matching prefers assetTag when * present (the more stable real-world identifier -- a device's serial * rarely changes but its owner/status might get re-tagged), falling back * to serialNumber. Update only touches fields this row actually carries a * (non-empty) value for -- a blank cell means "no change", not "clear this * field", so partial re-syncs (e.g. a cost-only refresh with no other * columns) don't wipe out data the file simply didn't include this time. * warrantyEndDate is the one exception: it's only touched on UPDATE when * explicitly present in the row (never silently recomputed from * warrantyMonths the way CREATE does), so a manually-corrected warranty * date survives a re-sync. * * 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; let updated = 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) { errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: "Missing serialNumber/manufacturer/model", }); continue; } // purchaseDate is optional (2026-08-24) -- a row with none imports // fine with purchaseDate left null (and consequently no // warrantyMonths auto-fill, which needs a purchase date to add // months to). A row that HAS a purchaseDate value still has to be a // parseable date though, same as before. if (row.purchaseDate && 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); } let status: AssetItemStatus | undefined; if (row.status) { const mapped = statusFromImportText(row.status); if (!mapped) { errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Unrecognized status: "${row.status}" (expected e.g. In use, In stock, Repair, Retired)`, }); continue; } status = mapped; } const cost = row.cost ? parseImportCost(row.cost) : null; const costCurrency = cleanImportText(row.costCurrency); const externalPoNumber = cleanImportText(row.externalPoNumber); // Resolved via createOrGetArea() (idempotent by name -- same helper // the bot's Area picker uses), not a raw areaId column: a hand-edited // spreadsheet realistically has area *names*, and this also means a // never-before-seen area name just gets created rather than rejecting // the row. let areaId: string | undefined; const areaName = cleanImportText(row.areaName); if (areaName) { const areaResult = await createOrGetArea({ name: areaName }); if (!areaResult.ok) { errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Area error: ${areaResult.error}` }); continue; } areaId = areaResult.value.id; } 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 existing = await prisma.assetItem.findFirst({ where: { OR: [ ...(row.assetTag ? [{ assetTag: row.assetTag }] : []), { serialNumber: row.serialNumber }, ], }, }); if (existing) { // Partial update -- only fields this row actually carries a value // for (see importAssetItems()'s doc comment above for why blank // means "no change", not "clear"). await prisma.assetItem.update({ where: { id: existing.id }, data: { ...(row.assetTag ? { assetTag: row.assetTag } : {}), modelId: model.id, ...(row.purchaseDate ? { purchaseDate: new Date(row.purchaseDate) } : {}), ...(warrantyEndDate ? { warrantyEndDate } : {}), ...(status ? { status } : {}), ...(cost !== null ? { cost } : {}), ...(costCurrency ? { costCurrency } : {}), ...(row.purchaseOrderId ? { purchaseOrderId: row.purchaseOrderId } : {}), ...(externalPoNumber ? { externalPoNumber } : {}), ...(areaId ? { areaId } : {}), }, }); updated++; continue; } const result = await createAssetItem({ serialNumber: row.serialNumber, assetTag: row.assetTag ?? undefined, modelId: model.id, purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined, warrantyEndDate, status, cost, costCurrency, purchaseOrderId: row.purchaseOrderId ?? undefined, externalPoNumber, areaId, }); if (!result.ok) { errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error }); continue; } created++; } return { created, updated, errors }; } /** * Renders the current AssetItem table as CSV, using the same columns * parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a * real round trip, not just a one-way dump. `status` round-trips too as of * 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own * enum text ("in_stock"/"in_use"/...), which IMPORT_STATUS_MAP also * recognizes on the way back in. */ export async function exportAssetItemsCsv(): Promise { const rows = await prisma.assetItem.findMany({ include: { model: true, area: true }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); const header = [ "serial_number", "asset_tag", "manufacturer", "model", "purchase_date", "warranty_end_date", "status", "cost", "cost_currency", "area", "purchase_order_id", "external_po_number", ]; const lines = [header.join(",")]; for (const row of rows) { const cells = [ row.serialNumber, row.assetTag ?? "", row.model.manufacturer, row.model.name, row.purchaseDate ? row.purchaseDate.toISOString().slice(0, 10) : "", row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "", row.status, row.cost ? row.cost.toString() : "", row.costCurrency ?? "", row.area?.name ?? "", row.purchaseOrderId ?? "", row.externalPoNumber ?? "", ]; lines.push(cells.map(csvField).join(",")); } return lines.join("\r\n") + "\r\n"; }