Bring an existing HomeBox inventory straight in, and capture the fields HomeBox has that we were missing (skipping asset IDs per Tony). - Item: soldTo/soldPrice/soldDate/soldNotes + flexible customFields (JSON, e.g. VIN / oil weight for vehicles); both editable in the item dialog and shown on the detail page - Pure HomeBox CSV parser (src/lib/inventory/homebox-import.ts, +5 tests) — RFC4180 quoting (commas/newlines/escaped quotes in fields), HomeBox's 0001-* null dates and 0 = empty price, semicolon labels, HB.field.* custom columns, nested 'A / B' location paths - Import service + /api/v1/items/import: find-or-creates the nested locations and labels, creates items, recomputes location paths, returns a summary - Inventory 'Import' button: paste/upload CSV, review every row with checkboxes (uncheck to skip), then import. Preview parses client-side (parser is pure) - Verified end-to-end: nested locations, split labels, sold + custom fields, skip, quoted names, null-date handling. tsc + 38 tests + build green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
// Import service for HomeBox CSV → Moonbase items. Resolves (find-or-creates)
|
|
// the nested location path and labels, then creates the items. The parsing is
|
|
// pure (src/lib/inventory/homebox-import.ts) and shared with the preview UI.
|
|
import { createId } from "@paralleldrive/cuid2";
|
|
import { db } from "@/lib/db";
|
|
import type { AuthContext } from "@/lib/api/authenticate";
|
|
import { recomputeAllPaths } from "@/lib/locations/db";
|
|
import { parseHomeBoxCsv, splitLocationPath, type HomeBoxItem } from "@/lib/inventory/homebox-import";
|
|
|
|
export type ImportSummary = {
|
|
created: number;
|
|
skipped: number;
|
|
locationsCreated: number;
|
|
labelsCreated: number;
|
|
};
|
|
|
|
async function buildLocationCache() {
|
|
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
|
|
const cache = new Map<string, string>(); // `${parentId||''}:${nameLower}` → id
|
|
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
|
|
return cache;
|
|
}
|
|
|
|
async function resolveLocationPath(
|
|
path: string | null,
|
|
cache: Map<string, string>,
|
|
counter: { n: number },
|
|
): Promise<string | null> {
|
|
if (!path) return null;
|
|
const names = splitLocationPath(path);
|
|
let parentId: string | null = null;
|
|
for (const name of names) {
|
|
const key = `${parentId ?? ""}:${name.toLowerCase()}`;
|
|
const existing: string | undefined = cache.get(key);
|
|
let id: string;
|
|
if (existing) {
|
|
id = existing;
|
|
} else {
|
|
const loc = await db.location.create({ data: { name, kind: "OTHER", parentId } });
|
|
id = loc.id;
|
|
cache.set(key, id);
|
|
counter.n++;
|
|
}
|
|
parentId = id;
|
|
}
|
|
return parentId;
|
|
}
|
|
|
|
async function buildLabelCache() {
|
|
const rows = await db.label.findMany({ select: { id: true, name: true } });
|
|
const cache = new Map<string, string>();
|
|
for (const r of rows) cache.set(r.name.toLowerCase(), r.id);
|
|
return cache;
|
|
}
|
|
|
|
async function resolveLabels(
|
|
names: string[],
|
|
cache: Map<string, string>,
|
|
counter: { n: number },
|
|
): Promise<string[]> {
|
|
const ids: string[] = [];
|
|
for (const name of names) {
|
|
let id = cache.get(name.toLowerCase());
|
|
if (!id) {
|
|
const label = await db.label.create({ data: { name } });
|
|
id = label.id;
|
|
cache.set(name.toLowerCase(), id);
|
|
counter.n++;
|
|
}
|
|
ids.push(id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function toDate(s: string | null): Date | null {
|
|
return s ? new Date(s) : null;
|
|
}
|
|
|
|
export async function importHomeBoxItems(
|
|
ctx: AuthContext,
|
|
opts: { csv: string; skip?: string[] },
|
|
): Promise<ImportSummary> {
|
|
const parsed = parseHomeBoxCsv(opts.csv);
|
|
const skip = new Set((opts.skip ?? []).map((s) => s.trim().toLowerCase()));
|
|
|
|
const locCache = await buildLocationCache();
|
|
const labelCache = await buildLabelCache();
|
|
const locCounter = { n: 0 };
|
|
const labelCounter = { n: 0 };
|
|
let created = 0;
|
|
let skipped = 0;
|
|
|
|
for (const hb of parsed as HomeBoxItem[]) {
|
|
if (skip.has(hb.name.trim().toLowerCase())) { skipped++; continue; }
|
|
|
|
const locationId = await resolveLocationPath(hb.locationPath, locCache, locCounter);
|
|
const labelIds = await resolveLabels(hb.labels, labelCache, labelCounter);
|
|
|
|
await db.item.create({
|
|
data: {
|
|
name: hb.name,
|
|
description: hb.description,
|
|
quantity: hb.quantity || 1,
|
|
locationId,
|
|
value: hb.value,
|
|
purchaseDate: toDate(hb.purchaseDate),
|
|
purchaseFrom: hb.purchaseFrom,
|
|
warrantyExpiry: toDate(hb.warrantyExpiry),
|
|
warrantyDetails: hb.warrantyDetails,
|
|
lifetimeWarranty: hb.lifetimeWarranty,
|
|
insured: hb.insured,
|
|
serial: hb.serial,
|
|
modelNumber: hb.modelNumber,
|
|
brand: hb.brand,
|
|
notes: hb.notes,
|
|
soldTo: hb.soldTo,
|
|
soldPrice: hb.soldPrice,
|
|
soldDate: toDate(hb.soldDate),
|
|
soldNotes: hb.soldNotes,
|
|
customFields: Object.keys(hb.customFields).length ? hb.customFields : undefined,
|
|
active: !hb.archived,
|
|
qrSlug: createId().slice(0, 10),
|
|
...(labelIds.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
|
|
},
|
|
});
|
|
created++;
|
|
}
|
|
|
|
// New locations were created without paths — recompute breadcrumbs once.
|
|
if (locCounter.n > 0) await recomputeAllPaths();
|
|
|
|
await db.auditEvent.create({
|
|
data: {
|
|
action: "item.import",
|
|
actorId: ctx.userId,
|
|
payload: { created, skipped, source: "homebox", via: ctx.via },
|
|
},
|
|
});
|
|
|
|
return { created, skipped, locationsCreated: locCounter.n, labelsCreated: labelCounter.n };
|
|
}
|