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 DateWarranty End DateStatus
+ CostPurchase Order ID
+ External PO NumberCreated 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.