Files
ubi-dan-itam/src/lib/assets.ts
T
toanhoang 03d74d69c1
CI / test (push) Failing after 9s
Rename AssetItemStatus 'assigned' to 'in_use'
'assigned' implied a specific person always holds the item, but
createAssignment() only requires status=in_stock to hand something out --
the status value itself never guaranteed a matching Assignment row exists.
~158 of the 613 real-imported assets carry this status with no Assignment
at all (shared/unowned equipment like meeting-room gear), so "assigned"
was actively misleading for them. 'in_use' doesn't carry that implication.

DB: `ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use'`
(hand-written migration -- runs cleanly, no data migration needed, every
existing row's status just reads under the new name). Renamed across:
schema.prisma enum, src/lib/assets.ts (type union + IMPORT_STATUS_MAP,
which keeps an `assigned` key mapped to `in_use` for backward-compat with
pre-2026-08-25 exports/CSVs), src/lib/assignments.ts (the literal status
value createAssignment() sets), src/app/assets/page.tsx + api/assets/
route.ts (filter dropdown + validStatuses), src/app/maintenance-records/
page.tsx (its own copy of the status list for the "close" targetStatus
picker). maintenance-records.ts itself needed no logic change -- its
closeableTargetStatusValues was already just ["in_stock", "retired"],
"assigned" was never a valid close target, only mentioned in comments
(updated for accuracy).

Also fixes a real regression found while verifying this: tests/
assets.test.ts (a pre-existing, properly-reviewed test file, not written
this session) broke 2 commits ago when GET /api/assets's signature and
response shape changed for pagination -- it still called GET() with no
Request and expected a bare array. tsc didn't catch this until now
because this container has no dev bind mount (Dockerfile is COPY-based,
`docker exec` only reflects the last build) -- see the
ubi-dan-itam-docker-no-bind-mount memory note. Added a getRequest()
helper and updated assertions to the {items, total} shape.

Verified by actually rebuilding and running the full suite fresh (not
just tsc/lint smoke checks): 369/369 tests pass, including
assignments.test.ts and maintenance-records.test.ts, which this rename
touches directly. tsc/lint clean against the rebuilt image. Migration
applied to the real DB, data verified intact (542 in_use + 71 in_stock =
613, matches pre-rename counts exactly).
2026-08-24 21:57:33 -04:00

789 lines
31 KiB
TypeScript

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<CreateAssetItemResult> {
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<ListAssetItemsResult> {
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<keyof ImportAssetItemRow, string[]> = {
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<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;
}
}
// 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<string, AssetItemStatus> = {
"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<ImportAssetItemsResult> {
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<string> {
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";
}