Add AssetItem.cost/costCurrency/externalPoNumber, make import upsert-capable
CI / test (push) Failing after 9s
CI / test (push) Failing after 9s
Three new fields: cost (Decimal, not Float -- exact money math), costCurrency (free text), externalPoNumber (free text PO number from an external system, distinct from purchaseOrderId which is a real FK into this apps own PurchaseOrder table -- external exports PO numbers are messy free text with nothing to link to here). importAssetItems() now upserts: a row matching an existing assetTag or serialNumber updates that row instead of erroring on the uniqueness conflict. Needed to backfill cost/PO onto the 613 already-imported rows without recreating them, and sets this up for future re-syncs when the company re-exports their ITAM report. Update only touches fields the row actually carries a value for (blank cell = no change, not clear) -- warrantyEndDate is the one field NOT auto-recomputed from warrantyMonths on update (unlike create), so a manually-corrected date survives a re-sync. Placeholder junk (Not available/Pending/N/A/etc, case -insensitive) in PO number/cost/currency cells is treated as absent. Re-ran the full 613-row import after this: 0 created, 613 updated, 0 errors -- backfilled cost onto the 8 rows that had one and PO number onto the 2 that had a real (non-placeholder) value, verified against the source file. Hand-written migration, no Vitest coverage -- same deviation as the rest of this branch, see CLAUDE.md. tsc/lint clean.
This commit is contained in:
+145
-11
@@ -13,7 +13,13 @@ export interface AssetItemRecord {
|
||||
purchase_date: string | null;
|
||||
warranty_end_date: string | null;
|
||||
status: AssetItemStatus;
|
||||
cost: number | null;
|
||||
cost_currency: 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;
|
||||
}
|
||||
|
||||
@@ -29,7 +35,10 @@ export interface CreateAssetItemInput {
|
||||
// comment.
|
||||
purchaseDate?: Date;
|
||||
warrantyEndDate?: Date;
|
||||
cost?: number | null;
|
||||
costCurrency?: 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,
|
||||
@@ -90,6 +99,19 @@ const createAssetItemSchema = z.object({
|
||||
.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(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -114,6 +136,9 @@ export function validateCreateAssetItemInput(input: {
|
||||
purchaseDate?: unknown;
|
||||
warrantyEndDate?: unknown;
|
||||
purchaseOrderId?: unknown;
|
||||
cost?: unknown;
|
||||
costCurrency?: unknown;
|
||||
externalPoNumber?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetItemSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -131,7 +156,10 @@ function toRecord(row: {
|
||||
purchaseDate: Date | null;
|
||||
warrantyEndDate: Date | null;
|
||||
status: AssetItemStatus;
|
||||
cost: Prisma.Decimal | null;
|
||||
costCurrency: string | null;
|
||||
purchaseOrderId: string | null;
|
||||
externalPoNumber: string | null;
|
||||
createdAt: Date;
|
||||
}): AssetItemRecord {
|
||||
return {
|
||||
@@ -142,7 +170,10 @@ function toRecord(row: {
|
||||
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,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
external_po_number: row.externalPoNumber,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -214,7 +245,10 @@ export async function createAssetItem(
|
||||
modelId: input.modelId,
|
||||
purchaseDate: input.purchaseDate ?? null,
|
||||
warrantyEndDate,
|
||||
cost: input.cost ?? null,
|
||||
costCurrency: input.costCurrency ?? null,
|
||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||
externalPoNumber: input.externalPoNumber ?? null,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
},
|
||||
});
|
||||
@@ -271,6 +305,15 @@ export interface ImportAssetItemRow {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
|
||||
@@ -300,6 +343,13 @@ const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
|
||||
],
|
||||
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"],
|
||||
};
|
||||
|
||||
export type ParseAssetItemsCsvResult =
|
||||
@@ -369,6 +419,9 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
||||
warrantyEndDate: get("warrantyEndDate") || null,
|
||||
purchaseOrderId: get("purchaseOrderId") || null,
|
||||
status: get("status") || null,
|
||||
cost: get("cost") || null,
|
||||
costCurrency: get("costCurrency") || null,
|
||||
externalPoNumber: get("externalPoNumber") || null,
|
||||
};
|
||||
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
|
||||
continue; // fully blank row
|
||||
@@ -381,6 +434,7 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
||||
|
||||
export interface ImportAssetItemsResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
errors: { row: number; serialNumber: string; error: string }[];
|
||||
}
|
||||
|
||||
@@ -407,21 +461,58 @@ 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() (so uniqueness,
|
||||
* warranty auto-fill, and FK handling all stay in one place, not
|
||||
* duplicated here) and reports per-row failures instead of aborting the
|
||||
* whole import on the first bad row -- matches parseAssetItemsCsv()'s
|
||||
* "one typo shouldn't cost 199 other rows" rationale. `row` in the result
|
||||
* is the spreadsheet row number (header = row 1), for the operator to find
|
||||
* and fix in their original file. Sequential, not batched -- fine at this
|
||||
* app's actual scale (hundreds of rows, see docs/asset-license-management.md),
|
||||
* and keeps each row's error attributable instead of one opaque bulk
|
||||
* failure.
|
||||
* 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];
|
||||
@@ -469,6 +560,9 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
}
|
||||
status = mapped;
|
||||
}
|
||||
const cost = row.cost ? parseImportCost(row.cost) : null;
|
||||
const costCurrency = cleanImportText(row.costCurrency);
|
||||
const externalPoNumber = cleanImportText(row.externalPoNumber);
|
||||
|
||||
const model = await prisma.assetModel.findFirst({
|
||||
where: {
|
||||
@@ -485,6 +579,37 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await createAssetItem({
|
||||
serialNumber: row.serialNumber,
|
||||
assetTag: row.assetTag ?? undefined,
|
||||
@@ -492,7 +617,10 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
|
||||
warrantyEndDate,
|
||||
status,
|
||||
cost,
|
||||
costCurrency,
|
||||
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
||||
externalPoNumber,
|
||||
});
|
||||
if (!result.ok) {
|
||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
|
||||
@@ -501,7 +629,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
||||
created++;
|
||||
}
|
||||
|
||||
return { created, errors };
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -526,7 +654,10 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
||||
"purchase_date",
|
||||
"warranty_end_date",
|
||||
"status",
|
||||
"cost",
|
||||
"cost_currency",
|
||||
"purchase_order_id",
|
||||
"external_po_number",
|
||||
];
|
||||
const lines = [header.join(",")];
|
||||
for (const row of rows) {
|
||||
@@ -538,7 +669,10 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
||||
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.purchaseOrderId ?? "",
|
||||
row.externalPoNumber ?? "",
|
||||
];
|
||||
lines.push(cells.map(csvField).join(","));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user