From 2cbe746bdea4c01863cf37d62a673ba2b9bfb0f5 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 23 Aug 2026 22:14:48 -0400 Subject: [PATCH] Add AssetItem.cost/costCurrency/externalPoNumber, make import upsert-capable 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. --- CLAUDE.md | 6 + .../migration.sql | 4 + prisma/schema.prisma | 15 ++ src/app/api/assets/route.ts | 16 +- src/app/assets/page.tsx | 70 +++++++- src/lib/assets.ts | 156 ++++++++++++++++-- 6 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260824090149_add_asset_item_cost_po/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index 24d3e58..bc766bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. **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). +- **`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). ## Việc còn dở diff --git a/prisma/migrations/20260824090149_add_asset_item_cost_po/migration.sql b/prisma/migrations/20260824090149_add_asset_item_cost_po/migration.sql new file mode 100644 index 0000000..58229d0 --- /dev/null +++ b/prisma/migrations/20260824090149_add_asset_item_cost_po/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1d138d..e52e431 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,8 +139,23 @@ model AssetItem { purchaseDate DateTime? @map("purchase_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date 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 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) // Back-relation for Assignment.assetItemId — an AssetItem can have // multiple Assignment rows over its lifetime (historical + at most one diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index f420b79..486fa20 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -22,8 +22,17 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); } - const { serialNumber, assetTag, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } = - body as Record; + const { + serialNumber, + assetTag, + modelId, + purchaseDate, + warrantyEndDate, + purchaseOrderId, + cost, + costCurrency, + externalPoNumber, + } = body as Record; const validation = validateCreateAssetItemInput({ serialNumber, assetTag, @@ -31,6 +40,9 @@ export async function POST(request: Request): Promise { purchaseDate, warrantyEndDate, purchaseOrderId, + cost, + costCurrency, + externalPoNumber, }); if (!validation.ok) { diff --git a/src/app/assets/page.tsx b/src/app/assets/page.tsx index 028a20c..7218e08 100644 --- a/src/app/assets/page.tsx +++ b/src/app/assets/page.tsx @@ -31,7 +31,10 @@ interface AssetItemRecord { purchase_date: string | null; warranty_end_date: string | null; status: "in_stock" | "assigned" | "repair" | "retired"; + cost: number | null; + cost_currency: string | null; purchase_order_id: string | null; + external_po_number: string | null; created_at: string; } @@ -76,6 +79,9 @@ export default function AssetsPage() { const [purchaseDate, setPurchaseDate] = useState(""); const [warrantyEndDate, setWarrantyEndDate] = useState(""); 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 [submitError, setSubmitError] = useState(null); @@ -115,7 +121,7 @@ export default function AssetsPage() { .join("; ") + (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(); } catch { setImportStatus("error"); @@ -131,6 +137,15 @@ export default function AssetsPage() { const hasAssetTag = assetTag.trim() !== ""; const hasWarrantyEndDate = warrantyEndDate.trim() !== ""; 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 { const response = await fetch("/api/assets", { @@ -146,6 +161,9 @@ export default function AssetsPage() { ...(hasAssetTag ? { assetTag } : {}), ...(hasWarrantyEndDate ? { warrantyEndDate } : {}), ...(hasPurchaseOrderId ? { purchaseOrderId } : {}), + ...(hasCost ? { cost: Number(cost) } : {}), + ...(hasCostCurrency ? { costCurrency } : {}), + ...(hasExternalPoNumber ? { externalPoNumber } : {}), }), }); @@ -174,6 +192,9 @@ export default function AssetsPage() { setPurchaseDate(""); setWarrantyEndDate(""); setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE); + setCost(""); + setCostCurrency(""); + setExternalPoNumber(""); refetch(); } catch { setSubmitError("Request failed"); @@ -202,7 +223,9 @@ export default function AssetsPage() { Purchase Date Warranty End Date Status + Cost Purchase Order ID + External PO Number Created At @@ -216,7 +239,13 @@ export default function AssetsPage() { {assetItem.purchase_date ?? "-"} {assetItem.warranty_end_date ?? "-"} {assetItem.status} + + {assetItem.cost !== null + ? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}` + : "-"} + {assetItem.purchase_order_id ?? "-"} + {assetItem.external_po_number ?? "-"} {assetItem.created_at} ))} @@ -233,11 +262,14 @@ export default function AssetsPage() { /asset-models - ), purchase date (bắt buộc -- cũng nhận order received). + ), purchase date (không bắt buộc -- cũng nhận order received). Tuỳ chọn: asset tag, warranty end date (bỏ trống thì tự tính từ warranty của model), status/state (nhận{" "} In use → assigned, In stock → in_stock, bỏ trống thì mặc định - in_stock), purchase order id. + in_stock), cost, cost currency,{" "} + po number (text tự do, khác purchase order id là UUID trong + hệ thống). Dòng có asset tag hoặc serial number đã tồn tại sẽ{" "} + cập nhật thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ liệu.

+
+ + setCost(event.target.value)} + /> +
+ +
+ + setCostCurrency(event.target.value)} + placeholder="e.g. VND, USD" + /> +
+ +
+ + setExternalPoNumber(event.target.value)} + placeholder="PO number from an external system, if any" + /> +
+ {submitError ?

{submitError}

: null}