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:
@@ -103,6 +103,12 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
|||||||
- Migration viết tay: `20260824081044_add_asset_item_asset_tag`, `20260824082411_asset_item_purchase_date_nullable`.
|
- 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.
|
**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.
|
||||||
- **Backfill Employee + Assignment từ dữ liệu thật (2026-08-24):** tiếp theo đợt import 613 AssetItem — trích 95 Employee khác nhau (từ cột "User Assigned", 2 người không có Department trong file gốc do là "(Other)"/"(Freelance)" → gán tạm "Unknown") + 384 Assignment (asset ↔ nhân viên, dùng ngày "Assigned Date" thật của file làm `assignedAt` lịch sử). **Quan trọng: KHÔNG dùng `createAssignment()` có sẵn** — hàm đó cho luồng nghiệp vụ SỐNG (cấp phát mới từ kho, bắt buộc AssetItem đang `in_stock`, tự chuyển sang `assigned`, không cho set `assignedAt` tay). Asset import đã có sẵn `status=assigned` đúng thực tế rồi nên gọi hàm đó sẽ bị từ chối hết — đây là backfill lịch sử, phải ghi thẳng qua Prisma (`prisma.assignment.create()` trực tiếp, script `seed-assignments.ts`, chạy 1 lần qua tsx rồi xoá, không phải feature UI/lib mới). Kết quả: 95 employee + 384 assignment, 0 lỗi, khớp 100% (kiểm tra chéo DANLAP001 → Duc Luu Minh, đúng phòng ban + ngày gán). 158 asset `status=assigned` không có Assignment — đúng thực tế (thiết bị dùng chung, file gốc không gán riêng ai). **Chưa làm:** Cost, PO number, Job/Manager/Email của Employee (schema `Employee` hiện chỉ có name+department, chưa mở rộng thêm field từ file gốc).
|
- **Backfill Employee + Assignment từ dữ liệu thật (2026-08-24):** tiếp theo đợt import 613 AssetItem — trích 95 Employee khác nhau (từ cột "User Assigned", 2 người không có Department trong file gốc do là "(Other)"/"(Freelance)" → gán tạm "Unknown") + 384 Assignment (asset ↔ nhân viên, dùng ngày "Assigned Date" thật của file làm `assignedAt` lịch sử). **Quan trọng: KHÔNG dùng `createAssignment()` có sẵn** — hàm đó cho luồng nghiệp vụ SỐNG (cấp phát mới từ kho, bắt buộc AssetItem đang `in_stock`, tự chuyển sang `assigned`, không cho set `assignedAt` tay). Asset import đã có sẵn `status=assigned` đúng thực tế rồi nên gọi hàm đó sẽ bị từ chối hết — đây là backfill lịch sử, phải ghi thẳng qua Prisma (`prisma.assignment.create()` trực tiếp, script `seed-assignments.ts`, chạy 1 lần qua tsx rồi xoá, không phải feature UI/lib mới). Kết quả: 95 employee + 384 assignment, 0 lỗi, khớp 100% (kiểm tra chéo DANLAP001 → Duc Luu Minh, đúng phòng ban + ngày gán). 158 asset `status=assigned` không có Assignment — đúng thực tế (thiết bị dùng chung, file gốc không gán riêng ai). **Chưa làm:** Cost, PO number, Job/Manager/Email của Employee (schema `Employee` hiện chỉ có name+department, chưa mở rộng thêm field từ file gốc).
|
||||||
|
- **`AssetItem.cost`/`costCurrency`/`externalPoNumber` + import upsert (2026-08-24):** thêm 3 field: `cost` (Decimal(14,2), không dùng Float để tránh sai số làm tròn), `costCurrency` (text tự do vd "VND"/"CNY"), `externalPoNumber` (text tự do — **khác** `purchaseOrderId` vốn là UUID FK thật vào bảng `PurchaseOrder`; PO number từ export ngoài thường là text rác không map được vào entity thật). CSV import/export cập nhật theo (`parseAssetItemsCsv`/`exportAssetItemsCsv`), cột `po number` tách biệt alias với `purchase order id` để không đụng nhau.
|
||||||
|
- **`importAssetItems()` giờ hỗ trợ upsert** (2026-08-24, thay đổi hành vi quan trọng): dòng có `assetTag`/`serialNumber` đã tồn tại sẽ **UPDATE** thay vì báo lỗi trùng như trước — cần thiết để backfill cost/PO lên 613 asset đã import trước đó, và hữu ích cho lần công ty xuất file ITAM mới sau này (re-sync định kỳ, không phải one-off). Update chỉ đụng field nào dòng CSV **có giá trị** (ô trống = không đổi, không phải xoá) — riêng `warrantyEndDate` chỉ update khi dòng có giá trị rõ ràng, KHÔNG tự tính lại từ `warrantyMonths` như nhánh CREATE (tránh ghi đè warranty đã sửa tay). Kết quả trả về thêm field `updated` bên cạnh `created`/`errors`.
|
||||||
|
- Dữ liệu rác dạng "Not available"/"Pending"/"N/A"/"None"/"-" (case-insensitive) ở cột PO number/cost/cost currency được coi là rỗng (hàm `cleanImportText()`/`parseImportCost()`), không lưu literal text rác vào DB.
|
||||||
|
Migration viết tay: `20260824090149_add_asset_item_cost_po`.
|
||||||
|
**Kết quả backfill thật:** chạy lại đúng file CSV 613 dòng (giờ có thêm 3 cột) → **0 tạo mới, 613 update, 0 lỗi** (đúng — không tạo trùng). 8/613 asset có cost thật (khớp 100% giá trị gốc, kiểm tra chéo), 2/613 có PO number thật, còn lại đúng là rác đã lọc.
|
||||||
|
**Việc còn dở, ý tưởng đã thống nhất với user (chưa làm):** tách `Department` thành entity riêng (như `Area`/`Vendor`), cho cả `Employee` và `AssetItem` cùng tham chiếu qua FK thay vì mỗi bên giữ 1 chuỗi text tự do — giúp asset dùng chung (không gán ai) vẫn gắn được department, và tránh lệch chính tả giữa 2 bảng.
|
||||||
- **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,4 @@
|
|||||||
|
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||||
|
ALTER TABLE "asset_items" ADD COLUMN "cost" DECIMAL(14,2);
|
||||||
|
ALTER TABLE "asset_items" ADD COLUMN "cost_currency" TEXT;
|
||||||
|
ALTER TABLE "asset_items" ADD COLUMN "external_po_number" TEXT;
|
||||||
@@ -139,8 +139,23 @@ model AssetItem {
|
|||||||
purchaseDate DateTime? @map("purchase_date") @db.Date
|
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)
|
||||||
|
// Purchase cost, in `costCurrency`'s units -- added 2026-08-24 alongside
|
||||||
|
// externalPoNumber for the real ITAM export (very sparse there: 8/613
|
||||||
|
// rows had a nonzero cost). Decimal, not Float, for exact money math (no
|
||||||
|
// binary-float rounding drift) -- Decimal(14,2) comfortably covers VND
|
||||||
|
// amounts in the millions with room to spare.
|
||||||
|
cost Decimal? @db.Decimal(14, 2)
|
||||||
|
costCurrency String? @map("cost_currency")
|
||||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||||
|
// Free-text PO number from an external system (e.g. the real ITAM
|
||||||
|
// export's "PO number" column) -- deliberately NOT the same thing as
|
||||||
|
// purchaseOrderId above, which is a real FK into this app's own
|
||||||
|
// PurchaseOrder table. External exports' PO numbers are messy free text
|
||||||
|
// ("Not available", "Pending", or an actual number) with no matching
|
||||||
|
// PurchaseOrder row to link to here, so this just preserves the raw
|
||||||
|
// value for reference instead of forcing a fake relation.
|
||||||
|
externalPoNumber String? @map("external_po_number")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
// Back-relation for Assignment.assetItemId — an AssetItem can have
|
// Back-relation for Assignment.assetItemId — an AssetItem can have
|
||||||
// multiple Assignment rows over its lifetime (historical + at most one
|
// multiple Assignment rows over its lifetime (historical + at most one
|
||||||
|
|||||||
@@ -22,8 +22,17 @@ 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, assetTag, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
const {
|
||||||
body as Record<string, unknown>;
|
serialNumber,
|
||||||
|
assetTag,
|
||||||
|
modelId,
|
||||||
|
purchaseDate,
|
||||||
|
warrantyEndDate,
|
||||||
|
purchaseOrderId,
|
||||||
|
cost,
|
||||||
|
costCurrency,
|
||||||
|
externalPoNumber,
|
||||||
|
} = body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetItemInput({
|
const validation = validateCreateAssetItemInput({
|
||||||
serialNumber,
|
serialNumber,
|
||||||
assetTag,
|
assetTag,
|
||||||
@@ -31,6 +40,9 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
purchaseDate,
|
purchaseDate,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
purchaseOrderId,
|
purchaseOrderId,
|
||||||
|
cost,
|
||||||
|
costCurrency,
|
||||||
|
externalPoNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
|
|||||||
+67
-3
@@ -31,7 +31,10 @@ interface AssetItemRecord {
|
|||||||
purchase_date: string | null;
|
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";
|
||||||
|
cost: number | null;
|
||||||
|
cost_currency: string | null;
|
||||||
purchase_order_id: string | null;
|
purchase_order_id: string | null;
|
||||||
|
external_po_number: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,9 @@ export default function AssetsPage() {
|
|||||||
const [purchaseDate, setPurchaseDate] = useState("");
|
const [purchaseDate, setPurchaseDate] = useState("");
|
||||||
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
||||||
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
||||||
|
const [cost, setCost] = useState("");
|
||||||
|
const [costCurrency, setCostCurrency] = useState("");
|
||||||
|
const [externalPoNumber, setExternalPoNumber] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -115,7 +121,7 @@ export default function AssetsPage() {
|
|||||||
.join("; ") +
|
.join("; ") +
|
||||||
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
|
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
|
||||||
: "";
|
: "";
|
||||||
setImportMessage(`Đã import ${body.created} asset.` + errorSummary);
|
setImportMessage(`Tạo mới ${body.created}, cập nhật ${body.updated} asset.` + errorSummary);
|
||||||
refetch();
|
refetch();
|
||||||
} catch {
|
} catch {
|
||||||
setImportStatus("error");
|
setImportStatus("error");
|
||||||
@@ -131,6 +137,15 @@ export default function AssetsPage() {
|
|||||||
const hasAssetTag = assetTag.trim() !== "";
|
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;
|
||||||
|
const hasCost = cost.trim() !== "";
|
||||||
|
const hasCostCurrency = costCurrency.trim() !== "";
|
||||||
|
const hasExternalPoNumber = externalPoNumber.trim() !== "";
|
||||||
|
|
||||||
|
if (hasCost && Number.isNaN(Number(cost))) {
|
||||||
|
setSubmitError("Cost must be a number");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/assets", {
|
const response = await fetch("/api/assets", {
|
||||||
@@ -146,6 +161,9 @@ export default function AssetsPage() {
|
|||||||
...(hasAssetTag ? { assetTag } : {}),
|
...(hasAssetTag ? { assetTag } : {}),
|
||||||
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
||||||
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
||||||
|
...(hasCost ? { cost: Number(cost) } : {}),
|
||||||
|
...(hasCostCurrency ? { costCurrency } : {}),
|
||||||
|
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +192,9 @@ export default function AssetsPage() {
|
|||||||
setPurchaseDate("");
|
setPurchaseDate("");
|
||||||
setWarrantyEndDate("");
|
setWarrantyEndDate("");
|
||||||
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
||||||
|
setCost("");
|
||||||
|
setCostCurrency("");
|
||||||
|
setExternalPoNumber("");
|
||||||
refetch();
|
refetch();
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError("Request failed");
|
setSubmitError("Request failed");
|
||||||
@@ -202,7 +223,9 @@ export default function AssetsPage() {
|
|||||||
<TableHead>Purchase Date</TableHead>
|
<TableHead>Purchase Date</TableHead>
|
||||||
<TableHead>Warranty End Date</TableHead>
|
<TableHead>Warranty End Date</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Cost</TableHead>
|
||||||
<TableHead>Purchase Order ID</TableHead>
|
<TableHead>Purchase Order ID</TableHead>
|
||||||
|
<TableHead>External PO Number</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>Created At</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -216,7 +239,13 @@ export default function AssetsPage() {
|
|||||||
<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.cost !== null
|
||||||
|
? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}`
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
||||||
|
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
||||||
<TableCell>{assetItem.created_at}</TableCell>
|
<TableCell>{assetItem.created_at}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -233,11 +262,14 @@ 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 -- cũng nhận <code>order received</code>).
|
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
|
||||||
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
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{" "}
|
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
|
<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>.
|
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
|
||||||
|
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
|
||||||
|
hệ thống). Dòng có <code>asset tag</code> hoặc <code>serial number</code> đã tồn tại sẽ{" "}
|
||||||
|
<b>cập nhật</b> thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ liệu.
|
||||||
</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">
|
||||||
@@ -348,6 +380,38 @@ export default function AssetsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="cost">Cost (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="cost"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={cost}
|
||||||
|
onChange={(event) => setCost(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="costCurrency">Cost Currency (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="costCurrency"
|
||||||
|
value={costCurrency}
|
||||||
|
onChange={(event) => setCostCurrency(event.target.value)}
|
||||||
|
placeholder="e.g. VND, USD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="externalPoNumber">External PO Number (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="externalPoNumber"
|
||||||
|
value={externalPoNumber}
|
||||||
|
onChange={(event) => setExternalPoNumber(event.target.value)}
|
||||||
|
placeholder="PO number from an external system, if any"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
|
|||||||
+145
-11
@@ -13,7 +13,13 @@ export interface AssetItemRecord {
|
|||||||
purchase_date: string | null;
|
purchase_date: string | null;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
|
cost: number | null;
|
||||||
|
cost_currency: string | null;
|
||||||
purchase_order_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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +35,10 @@ export interface CreateAssetItemInput {
|
|||||||
// comment.
|
// comment.
|
||||||
purchaseDate?: Date;
|
purchaseDate?: Date;
|
||||||
warrantyEndDate?: Date;
|
warrantyEndDate?: Date;
|
||||||
|
cost?: number | null;
|
||||||
|
costCurrency?: string | null;
|
||||||
purchaseOrderId?: string | null;
|
purchaseOrderId?: string | null;
|
||||||
|
externalPoNumber?: string | null;
|
||||||
// Only set by importAssetItems() (bulk-loading already-deployed
|
// Only set by importAssetItems() (bulk-loading already-deployed
|
||||||
// inventory from a real export -- see docs/asset-license-management.md's
|
// inventory from a real export -- see docs/asset-license-management.md's
|
||||||
// 2026-08-24 section). The manual /assets create form never sends this,
|
// 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")
|
.uuid("purchaseOrderId must be a valid UUID string")
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.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;
|
purchaseDate?: unknown;
|
||||||
warrantyEndDate?: unknown;
|
warrantyEndDate?: unknown;
|
||||||
purchaseOrderId?: unknown;
|
purchaseOrderId?: unknown;
|
||||||
|
cost?: unknown;
|
||||||
|
costCurrency?: unknown;
|
||||||
|
externalPoNumber?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createAssetItemSchema.safeParse(input);
|
const result = createAssetItemSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -131,7 +156,10 @@ function toRecord(row: {
|
|||||||
purchaseDate: Date | null;
|
purchaseDate: Date | null;
|
||||||
warrantyEndDate: Date | null;
|
warrantyEndDate: Date | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
|
cost: Prisma.Decimal | null;
|
||||||
|
costCurrency: string | null;
|
||||||
purchaseOrderId: string | null;
|
purchaseOrderId: string | null;
|
||||||
|
externalPoNumber: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): AssetItemRecord {
|
}): AssetItemRecord {
|
||||||
return {
|
return {
|
||||||
@@ -142,7 +170,10 @@ function toRecord(row: {
|
|||||||
purchase_date: row.purchaseDate ? row.purchaseDate.toISOString() : null,
|
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,
|
||||||
|
cost: row.cost ? row.cost.toNumber() : null,
|
||||||
|
cost_currency: row.costCurrency,
|
||||||
purchase_order_id: row.purchaseOrderId,
|
purchase_order_id: row.purchaseOrderId,
|
||||||
|
external_po_number: row.externalPoNumber,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -214,7 +245,10 @@ export async function createAssetItem(
|
|||||||
modelId: input.modelId,
|
modelId: input.modelId,
|
||||||
purchaseDate: input.purchaseDate ?? null,
|
purchaseDate: input.purchaseDate ?? null,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
|
cost: input.cost ?? null,
|
||||||
|
costCurrency: input.costCurrency ?? null,
|
||||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
|
externalPoNumber: input.externalPoNumber ?? null,
|
||||||
...(input.status ? { status: input.status } : {}),
|
...(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
|
// not here (parsing stays dumb string-in/string-out, same as every other
|
||||||
// field on this row).
|
// field on this row).
|
||||||
status: string | null;
|
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[]> = {
|
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"],
|
purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"],
|
||||||
status: ["status", "state", "tinh trang"],
|
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 =
|
export type ParseAssetItemsCsvResult =
|
||||||
@@ -369,6 +419,9 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
|||||||
warrantyEndDate: get("warrantyEndDate") || null,
|
warrantyEndDate: get("warrantyEndDate") || null,
|
||||||
purchaseOrderId: get("purchaseOrderId") || null,
|
purchaseOrderId: get("purchaseOrderId") || null,
|
||||||
status: get("status") || 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) {
|
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
|
||||||
continue; // fully blank row
|
continue; // fully blank row
|
||||||
@@ -381,6 +434,7 @@ export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
|||||||
|
|
||||||
export interface ImportAssetItemsResult {
|
export interface ImportAssetItemsResult {
|
||||||
created: number;
|
created: number;
|
||||||
|
updated: number;
|
||||||
errors: { row: number; serialNumber: string; error: string }[];
|
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;
|
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,
|
* Creates one AssetItem per row via createAssetItem(), OR updates it if a
|
||||||
* warranty auto-fill, and FK handling all stay in one place, not
|
* row with the same assetTag or serialNumber already exists (2026-08-24 --
|
||||||
* duplicated here) and reports per-row failures instead of aborting the
|
* added so re-uploading a refreshed export, e.g. the company's periodic
|
||||||
* whole import on the first bad row -- matches parseAssetItemsCsv()'s
|
* ServiceNow re-sync, updates existing rows instead of failing every one
|
||||||
* "one typo shouldn't cost 199 other rows" rationale. `row` in the result
|
* of them on a uniqueness conflict). Matching prefers assetTag when
|
||||||
* is the spreadsheet row number (header = row 1), for the operator to find
|
* present (the more stable real-world identifier -- a device's serial
|
||||||
* and fix in their original file. Sequential, not batched -- fine at this
|
* rarely changes but its owner/status might get re-tagged), falling back
|
||||||
* app's actual scale (hundreds of rows, see docs/asset-license-management.md),
|
* to serialNumber. Update only touches fields this row actually carries a
|
||||||
* and keeps each row's error attributable instead of one opaque bulk
|
* (non-empty) value for -- a blank cell means "no change", not "clear this
|
||||||
* failure.
|
* 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> {
|
export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<ImportAssetItemsResult> {
|
||||||
const errors: ImportAssetItemsResult["errors"] = [];
|
const errors: ImportAssetItemsResult["errors"] = [];
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
@@ -469,6 +560,9 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
}
|
}
|
||||||
status = mapped;
|
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({
|
const model = await prisma.assetModel.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -485,6 +579,37 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
continue;
|
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({
|
const result = await createAssetItem({
|
||||||
serialNumber: row.serialNumber,
|
serialNumber: row.serialNumber,
|
||||||
assetTag: row.assetTag ?? undefined,
|
assetTag: row.assetTag ?? undefined,
|
||||||
@@ -492,7 +617,10 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
|
purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
status,
|
status,
|
||||||
|
cost,
|
||||||
|
costCurrency,
|
||||||
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
||||||
|
externalPoNumber,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
|
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
|
||||||
@@ -501,7 +629,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, errors };
|
return { created, updated, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -526,7 +654,10 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
|||||||
"purchase_date",
|
"purchase_date",
|
||||||
"warranty_end_date",
|
"warranty_end_date",
|
||||||
"status",
|
"status",
|
||||||
|
"cost",
|
||||||
|
"cost_currency",
|
||||||
"purchase_order_id",
|
"purchase_order_id",
|
||||||
|
"external_po_number",
|
||||||
];
|
];
|
||||||
const lines = [header.join(",")];
|
const lines = [header.join(",")];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -538,7 +669,10 @@ export async function exportAssetItemsCsv(): Promise<string> {
|
|||||||
row.purchaseDate ? 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.cost ? row.cost.toString() : "",
|
||||||
|
row.costCurrency ?? "",
|
||||||
row.purchaseOrderId ?? "",
|
row.purchaseOrderId ?? "",
|
||||||
|
row.externalPoNumber ?? "",
|
||||||
];
|
];
|
||||||
lines.push(cells.map(csvField).join(","));
|
lines.push(cells.map(csvField).join(","));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user