Add AssetItem.assetTag, status-aware bulk import, nullable purchaseDate
CI / test (push) Failing after 8s

Prep work for importing a real company ITAM export (613 real assets):

- AssetItem.assetTag (nullable, unique) -- company physical tag, was only
  on InventoryScan (move feature) before, now on the actual asset record.
- AssetItem.purchaseDate is now nullable (was required) -- some legacy
  rows genuinely have no known purchase date. Manual /assets create form
  still requires it in the HTML input; this only relaxes the DB/import
  path for historical data.
- importAssetItems() accepts a status column now (free-text "In use"/
  "In stock"/etc mapped via IMPORT_STATUS_MAP), unlike manual creation
  which always defaults in_stock -- bulk-imported inventory is usually
  already deployed, not freshly purchased, so forcing in_stock would
  misrepresent reality.

Hand-written migrations (asset_tag, purchase_date nullable), no Vitest
coverage -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.

Actually imported 613/613 real AssetItem rows from the export with 0
errors after this (not committed here -- thats data, not code; see
CLAUDE.md for the seeding/import process and whats deliberately out of
scope this round: Cost, PO number, employee Assignment).
This commit is contained in:
toanhoang
2026-08-23 21:33:48 -04:00
parent aa7f5515db
commit 69437cf38f
7 changed files with 202 additions and 44 deletions
+6
View File
@@ -96,6 +96,12 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
- **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: <khu vực/người giữ cũ>" ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd. - **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: <khu vực/người giữ cũ>" ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd.
- **`AssetModel.warrantyMonths` -> auto-fill `AssetItem.warrantyEndDate` (2026-08-21):** thêm field `warrantyMonths` (nullable) vào `AssetModel` — thời hạn bảo hành chuẩn theo dòng máy/hãng. `createAssetItem()` trong `src/lib/assets.ts` tự tính `warrantyEndDate = purchaseDate + warrantyMonths` (helper `addMonthsClamped()`, clamp đúng cuối tháng — vd 31/12 + 2 tháng = 28/2, không tràn sang tháng 3) CHỈ khi caller không tự truyền `warrantyEndDate` — vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI `/asset-models` có thêm input "Standard Warranty (months)"; trang `/assets` không cần sửa gì (đã omit `warrantyEndDate` khỏi payload khi để trống, server tự điền). Migration viết tay `20260821200217_add_warranty_months`, cùng kiểu deviation như các migration khác trong session này. Test: smoke-test tay qua tsx script (không phải Vitest) — model 36 tháng, case thường, case clamp cuối tháng, case override tay đều đúng, đã dọn data test. `tsc --noEmit`/`lint` sạch. - **`AssetModel.warrantyMonths` -> auto-fill `AssetItem.warrantyEndDate` (2026-08-21):** thêm field `warrantyMonths` (nullable) vào `AssetModel` — thời hạn bảo hành chuẩn theo dòng máy/hãng. `createAssetItem()` trong `src/lib/assets.ts` tự tính `warrantyEndDate = purchaseDate + warrantyMonths` (helper `addMonthsClamped()`, clamp đúng cuối tháng — vd 31/12 + 2 tháng = 28/2, không tràn sang tháng 3) CHỈ khi caller không tự truyền `warrantyEndDate` — vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI `/asset-models` có thêm input "Standard Warranty (months)"; trang `/assets` không cần sửa gì (đã omit `warrantyEndDate` khỏi payload khi để trống, server tự điền). Migration viết tay `20260821200217_add_warranty_months`, cùng kiểu deviation như các migration khác trong session này. Test: smoke-test tay qua tsx script (không phải Vitest) — model 36 tháng, case thường, case clamp cuối tháng, case override tay đều đúng, đã dọn data test. `tsc --noEmit`/`lint` sạch.
- **Asset Item CSV import/export (2026-08-23):** trang `/assets` có thêm nút "Export CSV" và "Import CSV". Export (`GET /api/assets/export`) render toàn bộ `AssetItem` ra CSV (cột `manufacturer`/`model` thay vì `modelId` thô để đọc/sửa tay được). Import (`POST /api/assets/import`) parse CSV rồi tra `manufacturer`+`model` (không phân biệt hoa/thường) ra đúng `AssetModel` đã có sẵn — KHÔNG tự tạo model mới, phải tạo trước ở `/asset-models`. Lỗi từng dòng (model không khớp, thiếu field bắt buộc, ngày sai định dạng...) được báo riêng, không chặn các dòng hợp lệ khác — trả về `{created, errors: [{row, serialNumber, error}]}`. Warranty vẫn tự tính như bình thường (từ `AssetModel.warrantyMonths`) khi CSV không có cột `warranty end date`. Logic parse CSV dùng chung với `ExpectedAsset` (tách ra `src/lib/csv.ts`: `splitCsvLine`/`splitCsvLines`/`normalizeHeader`/`csvField`). Files mới: `src/lib/csv.ts`, `src/app/api/assets/import/route.ts`, `src/app/api/assets/export/route.ts`; sửa `src/lib/assets.ts` (`parseAssetItemsCsv`/`importAssetItems`/`exportAssetItemsCsv`), `src/lib/expected-assets.ts` (dùng `csv.ts` chung thay vì code trùng), `src/app/assets/page.tsx`. Test: smoke-test tay qua tsx script (export ra CSV hợp lệ, import CSV có cả dòng hợp lệ + dòng lỗi model + dòng thiếu ngày mua — đúng cả 3 case, đã dọn data test), **chưa có Vitest, chưa qua code-reviewer** — cùng deviation như các feature khác trong branch này. `tsc --noEmit`/`lint` sạch. - **Asset Item CSV import/export (2026-08-23):** trang `/assets` có thêm nút "Export CSV" và "Import CSV". Export (`GET /api/assets/export`) render toàn bộ `AssetItem` ra CSV (cột `manufacturer`/`model` thay vì `modelId` thô để đọc/sửa tay được). Import (`POST /api/assets/import`) parse CSV rồi tra `manufacturer`+`model` (không phân biệt hoa/thường) ra đúng `AssetModel` đã có sẵn — KHÔNG tự tạo model mới, phải tạo trước ở `/asset-models`. Lỗi từng dòng (model không khớp, thiếu field bắt buộc, ngày sai định dạng...) được báo riêng, không chặn các dòng hợp lệ khác — trả về `{created, errors: [{row, serialNumber, error}]}`. Warranty vẫn tự tính như bình thường (từ `AssetModel.warrantyMonths`) khi CSV không có cột `warranty end date`. Logic parse CSV dùng chung với `ExpectedAsset` (tách ra `src/lib/csv.ts`: `splitCsvLine`/`splitCsvLines`/`normalizeHeader`/`csvField`). Files mới: `src/lib/csv.ts`, `src/app/api/assets/import/route.ts`, `src/app/api/assets/export/route.ts`; sửa `src/lib/assets.ts` (`parseAssetItemsCsv`/`importAssetItems`/`exportAssetItemsCsv`), `src/lib/expected-assets.ts` (dùng `csv.ts` chung thay vì code trùng), `src/app/assets/page.tsx`. Test: smoke-test tay qua tsx script (export ra CSV hợp lệ, import CSV có cả dòng hợp lệ + dòng lỗi model + dòng thiếu ngày mua — đúng cả 3 case, đã dọn data test), **chưa có Vitest, chưa qua code-reviewer** — cùng deviation như các feature khác trong branch này. `tsc --noEmit`/`lint` sạch.
- **`AssetItem.assetTag` + status-aware import + `purchaseDate` optional (2026-08-24):** để bulk-import 1 file export ITAM thật của công ty (ServiceNow-style, 613 asset thật, sheet "Page 1" = union của 8 sheet còn lại trong file gốc). Thay đổi schema:
- `AssetItem.assetTag` (nullable, unique) — mã tag công ty (vd "DANLAP001"), trước đây chỉ `InventoryScan` có field này (dành riêng cho move), giờ có ở cả bảng chính.
- `AssetItem.purchaseDate` đổi thành **nullable** (trước là NOT NULL) — dữ liệu thật có 6/613 asset không rõ ngày mua (thiếu "Order received" trong file gốc), quyết định 2026-08-24: import hết, không bỏ dòng nào, để trống ngày mua và tính sau. Form tạo tay `/assets` vẫn bắt buộc nhập ngày (input HTML `required`) — chỉ nới lỏng ở tầng DB/import cho dữ liệu lịch sử.
- `importAssetItems()` giờ nhận thêm cột `status` (map free-text "In use"→`assigned`, "In stock"→`in_stock`, "Repair"/"Retired"...) — khác với tạo tay qua UI (luôn mặc định `in_stock` vì đó là "vừa mua mới", không đúng khi import tài sản ĐÃ deploy thật). `createAssetItem()` nhận thêm `status?` param, chỉ `importAssetItems()` dùng.
- Migration viết tay: `20260824081044_add_asset_item_asset_tag`, `20260824082411_asset_item_purchase_date_nullable`.
**Kết quả import thật:** 613/613 tạo thành công, 0 lỗi (khớp 100% với file gốc: 542 assigned + 71 in_stock). Trước khi import phải seed thủ công 14 `AssetCategory` mới + 135 `AssetModel` mới (script `seed-models.ts`, tự viết, chạy 1 lần qua tsx rồi xoá — không phải feature UI, vì import `AssetItem` cố tình KHÔNG tự tạo model, xem comment `importAssetItems()`). 6 asset thiếu ngày mua: `DANCONT014, DANCONT017, DANCONT018, DANCONT020, DANCONT021, DANCONT023` — cần bổ sung tay sau. **Phạm vi đợt này CHƯA làm:** Cost/Cost currency, PO number (text tự do, không map được vào `purchaseOrderId` UUID hiện tại), gán nhân viên thật (`User Assigned` → cần entity `Assignment` riêng, chưa đụng tới) — để đợt sau.
- **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy). - **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy).
## Việc còn dở ## Việc còn dở
@@ -0,0 +1,3 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TABLE "asset_items" ADD COLUMN "asset_tag" TEXT;
CREATE UNIQUE INDEX "asset_items_asset_tag_key" ON "asset_items"("asset_tag");
@@ -0,0 +1,2 @@
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
ALTER TABLE "asset_items" ALTER COLUMN "purchase_date" DROP NOT NULL;
+17 -1
View File
@@ -118,9 +118,25 @@ enum AssetItemStatus {
model AssetItem { model AssetItem {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
serialNumber String @unique @map("serial_number") serialNumber String @unique @map("serial_number")
// Company-assigned physical tag (e.g. "DANLAP001", printed on a sticker
// on the device) -- 2026-08-24, added when bulk-importing the real
// ServiceNow ITAM export (see docs/asset-license-management.md). Was
// only on InventoryScan before (the office-move walk feature); this is
// the same concept but on the actual asset record, not a move-day scan.
// Nullable (a manually-entered new purchase may not have one yet) but
// unique when present -- the real export had 613/613 unique, non-null
// tags, same integrity guarantee as serialNumber.
assetTag String? @unique @map("asset_tag")
modelId String @map("model_id") @db.Uuid modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id]) model AssetModel @relation(fields: [modelId], references: [id])
purchaseDate DateTime @map("purchase_date") @db.Date // Nullable as of 2026-08-24 (was NOT NULL) -- bulk-importing the real
// ITAM export, some legacy rows have no known purchase date at all (no
// "Order received" value in the source). Manual /assets create form
// still requires it (a NEW purchase should always have a known date);
// this only relaxes the DB/import path for historical data. Also means
// AssetModel.warrantyMonths auto-fill (see createAssetItem()) can't
// compute anything for these rows -- warrantyEndDate stays null too.
purchaseDate DateTime? @map("purchase_date") @db.Date
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
status AssetItemStatus @default(in_stock) status AssetItemStatus @default(in_stock)
purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrderId String? @map("purchase_order_id") @db.Uuid
+2 -1
View File
@@ -22,10 +22,11 @@ export async function POST(request: Request): Promise<NextResponse> {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } = const { serialNumber, assetTag, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>; body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({ const validation = validateCreateAssetItemInput({
serialNumber, serialNumber,
assetTag,
modelId, modelId,
purchaseDate, purchaseDate,
warrantyEndDate, warrantyEndDate,
+23 -4
View File
@@ -26,8 +26,9 @@ import {
interface AssetItemRecord { interface AssetItemRecord {
id: string; id: string;
serial_number: string; serial_number: string;
asset_tag: string | null;
model_id: string; model_id: string;
purchase_date: string; purchase_date: string | null;
warranty_end_date: string | null; warranty_end_date: string | null;
status: "in_stock" | "assigned" | "repair" | "retired"; status: "in_stock" | "assigned" | "repair" | "retired";
purchase_order_id: string | null; purchase_order_id: string | null;
@@ -70,6 +71,7 @@ export default function AssetsPage() {
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders"); const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const [serialNumber, setSerialNumber] = useState(""); const [serialNumber, setSerialNumber] = useState("");
const [assetTag, setAssetTag] = useState("");
const [modelId, setModelId] = useState(""); const [modelId, setModelId] = useState("");
const [purchaseDate, setPurchaseDate] = useState(""); const [purchaseDate, setPurchaseDate] = useState("");
const [warrantyEndDate, setWarrantyEndDate] = useState(""); const [warrantyEndDate, setWarrantyEndDate] = useState("");
@@ -126,6 +128,7 @@ export default function AssetsPage() {
setSubmitting(true); setSubmitting(true);
setSubmitError(null); setSubmitError(null);
const hasAssetTag = assetTag.trim() !== "";
const hasWarrantyEndDate = warrantyEndDate.trim() !== ""; const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE; const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
@@ -140,6 +143,7 @@ export default function AssetsPage() {
// Omit optional keys entirely when left blank, rather than // Omit optional keys entirely when left blank, rather than
// sending null — matches the create-form pattern used on other // sending null — matches the create-form pattern used on other
// pages (e.g. purchase-orders page's receivedAt/receivedById). // pages (e.g. purchase-orders page's receivedAt/receivedById).
...(hasAssetTag ? { assetTag } : {}),
...(hasWarrantyEndDate ? { warrantyEndDate } : {}), ...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
...(hasPurchaseOrderId ? { purchaseOrderId } : {}), ...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
}), }),
@@ -165,6 +169,7 @@ export default function AssetsPage() {
} }
setSerialNumber(""); setSerialNumber("");
setAssetTag("");
setModelId(""); setModelId("");
setPurchaseDate(""); setPurchaseDate("");
setWarrantyEndDate(""); setWarrantyEndDate("");
@@ -192,6 +197,7 @@ export default function AssetsPage() {
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>ID</TableHead>
<TableHead>Serial Number</TableHead> <TableHead>Serial Number</TableHead>
<TableHead>Asset Tag</TableHead>
<TableHead>Model ID</TableHead> <TableHead>Model ID</TableHead>
<TableHead>Purchase Date</TableHead> <TableHead>Purchase Date</TableHead>
<TableHead>Warranty End Date</TableHead> <TableHead>Warranty End Date</TableHead>
@@ -205,8 +211,9 @@ export default function AssetsPage() {
<TableRow key={assetItem.id}> <TableRow key={assetItem.id}>
<TableCell>{assetItem.id}</TableCell> <TableCell>{assetItem.id}</TableCell>
<TableCell>{assetItem.serial_number}</TableCell> <TableCell>{assetItem.serial_number}</TableCell>
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
<TableCell>{assetItem.model_id}</TableCell> <TableCell>{assetItem.model_id}</TableCell>
<TableCell>{assetItem.purchase_date}</TableCell> <TableCell>{assetItem.purchase_date ?? "-"}</TableCell>
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell> <TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
<TableCell>{assetItem.status}</TableCell> <TableCell>{assetItem.status}</TableCell>
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell> <TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
@@ -226,8 +233,11 @@ export default function AssetsPage() {
<a href="/asset-models" className="underline"> <a href="/asset-models" className="underline">
/asset-models /asset-models
</a> </a>
), <code>purchase date</code> (bắt buộc), <code>warranty end date</code>,{" "} ), <code>purchase date</code> (bắt buộc -- cũng nhận <code>order received</code>).
<code>purchase order id</code> (tuỳ chọn -- bỏ trống thì tự tính từ warranty của model). Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
<code>In use</code> → assigned, <code>In stock</code> → in_stock, bỏ trống thì mặc định
in_stock), <code>purchase order id</code>.
</p> </p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<a href="/api/assets/export"> <a href="/api/assets/export">
@@ -275,6 +285,15 @@ export default function AssetsPage() {
/> />
</div> </div>
<div className="flex flex-col gap-2">
<Label htmlFor="assetTag">Asset Tag (optional)</Label>
<Input
id="assetTag"
value={assetTag}
onChange={(event) => setAssetTag(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="modelId">Model</Label> <Label htmlFor="modelId">Model</Label>
<Select value={modelId} onValueChange={setModelId} required> <Select value={modelId} onValueChange={setModelId} required>
+149 -38
View File
@@ -8,8 +8,9 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
export interface AssetItemRecord { export interface AssetItemRecord {
id: string; id: string;
serial_number: string; serial_number: string;
asset_tag: string | null;
model_id: string; model_id: string;
purchase_date: string; purchase_date: string | null;
warranty_end_date: string | null; warranty_end_date: string | null;
status: AssetItemStatus; status: AssetItemStatus;
purchase_order_id: string | null; purchase_order_id: string | null;
@@ -18,10 +19,25 @@ export interface AssetItemRecord {
export interface CreateAssetItemInput { export interface CreateAssetItemInput {
serialNumber: string; serialNumber: string;
assetTag?: string | null;
modelId: string; modelId: string;
purchaseDate: Date; // 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; warrantyEndDate?: Date;
purchaseOrderId?: string | null; purchaseOrderId?: 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 = export type ValidationResult =
@@ -48,6 +64,10 @@ function dateString(message: string) {
const createAssetItemSchema = z.object({ const createAssetItemSchema = z.object({
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"), 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 // 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 // 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 // (2)" section). Only a UUID *format* check here, whether it references a
@@ -57,7 +77,7 @@ const createAssetItemSchema = z.object({
modelId: z modelId: z
.string({ required_error: "modelId is required and must be a valid UUID string" }) .string({ required_error: "modelId is required and must be a valid UUID string" })
.uuid("modelId is required and must be a valid UUID string"), .uuid("modelId is required and must be a valid UUID string"),
purchaseDate: dateString("purchaseDate is required and must be a valid date string"), purchaseDate: dateString("purchaseDate must be a valid date string").optional(),
warrantyEndDate: dateString("warrantyEndDate 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 // FK to PurchaseOrder — nullable/optional, supply-side traceability (not
// every AssetItem goes through a formal PO). Only a UUID *format* check // every AssetItem goes through a formal PO). Only a UUID *format* check
@@ -74,15 +94,22 @@ const createAssetItemSchema = z.object({
/** /**
* Validates raw (untyped) asset-item-creation input. serialNumber must be a * Validates raw (untyped) asset-item-creation input. serialNumber must be a
* non-empty string, modelId must be a valid UUID string, purchaseDate must * non-empty string, assetTag is optional/nullable free text (no format
* be a valid date string, warrantyEndDate is optional but must be a valid * check -- company tag schemes vary), modelId must be a valid UUID string,
* date string if present. purchaseOrderId is optional/nullable but must be * purchaseDate must be a valid date string, warrantyEndDate is optional
* a valid UUID if present. status is not accepted here — new assets always * but must be a valid date string if present. purchaseOrderId is
* start "in_stock" (same pattern as Request always starting "Draft"), later * optional/nullable but must be a valid UUID if present. status is not
* states are set via other operations, not at creation. * 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: { export function validateCreateAssetItemInput(input: {
serialNumber?: unknown; serialNumber?: unknown;
assetTag?: unknown;
modelId?: unknown; modelId?: unknown;
purchaseDate?: unknown; purchaseDate?: unknown;
warrantyEndDate?: unknown; warrantyEndDate?: unknown;
@@ -99,8 +126,9 @@ export function validateCreateAssetItemInput(input: {
function toRecord(row: { function toRecord(row: {
id: string; id: string;
serialNumber: string; serialNumber: string;
assetTag: string | null;
modelId: string; modelId: string;
purchaseDate: Date; purchaseDate: Date | null;
warrantyEndDate: Date | null; warrantyEndDate: Date | null;
status: AssetItemStatus; status: AssetItemStatus;
purchaseOrderId: string | null; purchaseOrderId: string | null;
@@ -109,8 +137,9 @@ function toRecord(row: {
return { return {
id: row.id, id: row.id,
serial_number: row.serialNumber, serial_number: row.serialNumber,
asset_tag: row.assetTag,
model_id: row.modelId, model_id: row.modelId,
purchase_date: row.purchaseDate.toISOString(), purchase_date: row.purchaseDate ? row.purchaseDate.toISOString() : null,
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status, status: row.status,
purchase_order_id: row.purchaseOrderId, purchase_order_id: row.purchaseOrderId,
@@ -171,7 +200,7 @@ export async function createAssetItem(
// added -- this just also opportunistically uses the model row if it's // added -- this just also opportunistically uses the model row if it's
// there. // there.
let warrantyEndDate = input.warrantyEndDate ?? null; let warrantyEndDate = input.warrantyEndDate ?? null;
if (!warrantyEndDate) { if (!warrantyEndDate && input.purchaseDate) {
const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } }); const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } });
if (model?.warrantyMonths) { if (model?.warrantyMonths) {
warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths); warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths);
@@ -181,15 +210,23 @@ export async function createAssetItem(
const created = await prisma.assetItem.create({ const created = await prisma.assetItem.create({
data: { data: {
serialNumber: input.serialNumber, serialNumber: input.serialNumber,
assetTag: input.assetTag ?? null,
modelId: input.modelId, modelId: input.modelId,
purchaseDate: input.purchaseDate, purchaseDate: input.purchaseDate ?? null,
warrantyEndDate, warrantyEndDate,
purchaseOrderId: input.purchaseOrderId ?? null, purchaseOrderId: input.purchaseOrderId ?? null,
...(input.status ? { status: input.status } : {}),
}, },
}); });
return { ok: true, value: toRecord(created) }; return { ok: true, value: toRecord(created) };
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { 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" }; return { ok: false, error: "serialNumber already exists" };
} }
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
@@ -222,20 +259,47 @@ export async function listAssetItems(): Promise<AssetItemRecord[]> {
export interface ImportAssetItemRow { export interface ImportAssetItemRow {
serialNumber: string; serialNumber: string;
assetTag: string | null;
manufacturer: string; manufacturer: string;
modelName: string; modelName: string;
purchaseDate: string; purchaseDate: string;
warrantyEndDate: string | null; warrantyEndDate: string | null;
purchaseOrderId: 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;
} }
const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = { const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
serialNumber: ["serialnumber", "serial number", "serial", "sn"], serialNumber: ["serialnumber", "serial number", "serial", "sn"],
assetTag: ["assettag", "asset tag", "tag", "matag"],
manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"], manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"],
modelName: ["model", "modelname", "model name", "ten model"], modelName: ["model", "modelname", "model name", "ten model"],
purchaseDate: ["purchasedate", "purchase date", "ngaymua", "ngay mua"], purchaseDate: [
warrantyEndDate: ["warrantyenddate", "warranty end date", "warranty", "baohanh", "bao hanh"], "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"], purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"],
status: ["status", "state", "tinh trang"],
}; };
export type ParseAssetItemsCsvResult = export type ParseAssetItemsCsvResult =
@@ -245,12 +309,14 @@ export type ParseAssetItemsCsvResult =
/** /**
* Parses a CSV export into rows ready for importAssetItems(). Unlike * Parses a CSV export into rows ready for importAssetItems(). Unlike
* src/lib/expected-assets.ts's parser (a staging/lookup table with no hard * src/lib/expected-assets.ts's parser (a staging/lookup table with no hard
* NOT NULL requirements), the 4 fields an AssetItem actually needs at * NOT NULL requirements), the fields an AssetItem actually needs at
* creation -- serialNumber/manufacturer/modelName/purchaseDate -- must all * creation -- serialNumber/manufacturer/modelName (purchaseDate is
* be present as recognized header columns, or this fails fast on the whole * optional as of 2026-08-24, see schema.prisma's AssetItem.purchaseDate
* file rather than silently producing rows importAssetItems() can never * comment) -- must all be present as recognized header columns, or this
* create. Individual rows with one of those 4 fields blank still aren't * fails fast on the whole file rather than silently producing rows
* rejected here though -- that's importAssetItems()'s per-row error, so a * 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. * typo in row 200 doesn't block rows 1-199.
*/ */
export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult { export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
@@ -270,12 +336,11 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
columnIndex[field] = idx; columnIndex[field] = idx;
} }
} }
const required: (keyof ImportAssetItemRow)[] = [ // purchaseDate is NOT required here (as of 2026-08-24 -- see
"serialNumber", // schema.prisma's AssetItem.purchaseDate comment): some legacy rows in
"manufacturer", // a real bulk import genuinely have no known purchase date, and that's
"modelName", // fine to import with a null purchaseDate rather than skipping the row.
"purchaseDate", const required: (keyof ImportAssetItemRow)[] = ["serialNumber", "manufacturer", "modelName"];
];
const missing = required.filter((field) => columnIndex[field] === undefined); const missing = required.filter((field) => columnIndex[field] === undefined);
if (missing.length > 0) { if (missing.length > 0) {
return { return {
@@ -297,11 +362,13 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
}; };
const row: ImportAssetItemRow = { const row: ImportAssetItemRow = {
serialNumber: get("serialNumber"), serialNumber: get("serialNumber"),
assetTag: get("assetTag") || null,
manufacturer: get("manufacturer"), manufacturer: get("manufacturer"),
modelName: get("modelName"), modelName: get("modelName"),
purchaseDate: get("purchaseDate"), purchaseDate: get("purchaseDate"),
warrantyEndDate: get("warrantyEndDate") || null, warrantyEndDate: get("warrantyEndDate") || null,
purchaseOrderId: get("purchaseOrderId") || null, purchaseOrderId: get("purchaseOrderId") || null,
status: get("status") || null,
}; };
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) { if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
continue; // fully blank row continue; // fully blank row
@@ -317,6 +384,29 @@ export interface ImportAssetItemsResult {
errors: { row: number; serialNumber: string; error: string }[]; 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" -> "assigned" 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": "assigned",
assigned: "assigned",
"in stock": "in_stock",
in_stock: "in_stock",
repair: "repair",
"in repair": "repair",
retired: "retired",
disposed: "retired",
};
function statusFromImportText(text: string): AssetItemStatus | null {
return IMPORT_STATUS_MAP[text.trim().toLowerCase()] ?? null;
}
/** /**
* Creates one AssetItem per row via createAssetItem() (so uniqueness, * Creates one AssetItem per row via createAssetItem() (so uniqueness,
* warranty auto-fill, and FK handling all stay in one place, not * warranty auto-fill, and FK handling all stay in one place, not
@@ -337,15 +427,20 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
const row = rows[i]; const row = rows[i];
const rowNumber = i + 2; // +1 for 0-index, +1 for the header row const rowNumber = i + 2; // +1 for 0-index, +1 for the header row
if (!row.serialNumber || !row.manufacturer || !row.modelName || !row.purchaseDate) { if (!row.serialNumber || !row.manufacturer || !row.modelName) {
errors.push({ errors.push({
row: rowNumber, row: rowNumber,
serialNumber: row.serialNumber, serialNumber: row.serialNumber,
error: "Missing serialNumber/manufacturer/model/purchaseDate", error: "Missing serialNumber/manufacturer/model",
}); });
continue; continue;
} }
if (Number.isNaN(Date.parse(row.purchaseDate))) { // 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}"` }); errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Invalid purchaseDate: "${row.purchaseDate}"` });
continue; continue;
} }
@@ -361,6 +456,19 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
} }
warrantyEndDate = new Date(row.warrantyEndDate); 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 model = await prisma.assetModel.findFirst({ const model = await prisma.assetModel.findFirst({
where: { where: {
@@ -379,9 +487,11 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
const result = await createAssetItem({ const result = await createAssetItem({
serialNumber: row.serialNumber, serialNumber: row.serialNumber,
assetTag: row.assetTag ?? undefined,
modelId: model.id, modelId: model.id,
purchaseDate: new Date(row.purchaseDate), purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
warrantyEndDate, warrantyEndDate,
status,
purchaseOrderId: row.purchaseOrderId ?? undefined, purchaseOrderId: row.purchaseOrderId ?? undefined,
}); });
if (!result.ok) { if (!result.ok) {
@@ -395,13 +505,12 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
} }
/** /**
* Renders the current AssetItem table as CSV, using the same * Renders the current AssetItem table as CSV, using the same columns
* manufacturer/model/purchaseDate/warrantyEndDate/purchaseOrderId columns
* parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a * parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a
* real round trip, not just a one-way dump. `status` is included for * real round trip, not just a one-way dump. `status` round-trips too as of
* reference but ignored on import (see validateCreateAssetItemInput's * 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own
* comment: new items always start in_stock; status changes are a separate * enum text ("in_stock"/"assigned"/...), which IMPORT_STATUS_MAP also
* operation, not something a re-import should silently override). * recognizes on the way back in.
*/ */
export async function exportAssetItemsCsv(): Promise<string> { export async function exportAssetItemsCsv(): Promise<string> {
const rows = await prisma.assetItem.findMany({ const rows = await prisma.assetItem.findMany({
@@ -411,6 +520,7 @@ export async function exportAssetItemsCsv(): Promise<string> {
const header = [ const header = [
"serial_number", "serial_number",
"asset_tag",
"manufacturer", "manufacturer",
"model", "model",
"purchase_date", "purchase_date",
@@ -422,9 +532,10 @@ export async function exportAssetItemsCsv(): Promise<string> {
for (const row of rows) { for (const row of rows) {
const cells = [ const cells = [
row.serialNumber, row.serialNumber,
row.assetTag ?? "",
row.model.manufacturer, row.model.manufacturer,
row.model.name, row.model.name,
row.purchaseDate.toISOString().slice(0, 10), row.purchaseDate ? row.purchaseDate.toISOString().slice(0, 10) : "",
row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "", row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "",
row.status, row.status,
row.purchaseOrderId ?? "", row.purchaseOrderId ?? "",