CI / test (push) Failing after 9s
'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).
789 lines
31 KiB
TypeScript
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";
|
|
}
|