diff --git a/CLAUDE.md b/CLAUDE.md index 597d31c..23c79db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: " ở 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. - **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). ## Việc còn dở diff --git a/prisma/migrations/20260824081044_add_asset_item_asset_tag/migration.sql b/prisma/migrations/20260824081044_add_asset_item_asset_tag/migration.sql new file mode 100644 index 0000000..0a55a7d --- /dev/null +++ b/prisma/migrations/20260824081044_add_asset_item_asset_tag/migration.sql @@ -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"); diff --git a/prisma/migrations/20260824082411_asset_item_purchase_date_nullable/migration.sql b/prisma/migrations/20260824082411_asset_item_purchase_date_nullable/migration.sql new file mode 100644 index 0000000..485f8d3 --- /dev/null +++ b/prisma/migrations/20260824082411_asset_item_purchase_date_nullable/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 572381c..f1d138d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,9 +118,25 @@ enum AssetItemStatus { model AssetItem { id String @id @default(uuid()) @db.Uuid 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 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 status AssetItemStatus @default(in_stock) purchaseOrderId String? @map("purchase_order_id") @db.Uuid diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index 65b8744..f420b79 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -22,10 +22,11 @@ export async function POST(request: Request): Promise { 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; const validation = validateCreateAssetItemInput({ serialNumber, + assetTag, modelId, purchaseDate, warrantyEndDate, diff --git a/src/app/assets/page.tsx b/src/app/assets/page.tsx index 082f323..028a20c 100644 --- a/src/app/assets/page.tsx +++ b/src/app/assets/page.tsx @@ -26,8 +26,9 @@ import { interface AssetItemRecord { id: string; serial_number: string; + asset_tag: string | null; model_id: string; - purchase_date: string; + purchase_date: string | null; warranty_end_date: string | null; status: "in_stock" | "assigned" | "repair" | "retired"; purchase_order_id: string | null; @@ -70,6 +71,7 @@ export default function AssetsPage() { const { data: purchaseOrders } = useApiList("/api/purchase-orders"); const [serialNumber, setSerialNumber] = useState(""); + const [assetTag, setAssetTag] = useState(""); const [modelId, setModelId] = useState(""); const [purchaseDate, setPurchaseDate] = useState(""); const [warrantyEndDate, setWarrantyEndDate] = useState(""); @@ -126,6 +128,7 @@ export default function AssetsPage() { setSubmitting(true); setSubmitError(null); + const hasAssetTag = assetTag.trim() !== ""; const hasWarrantyEndDate = warrantyEndDate.trim() !== ""; const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE; @@ -140,6 +143,7 @@ export default function AssetsPage() { // Omit optional keys entirely when left blank, rather than // sending null — matches the create-form pattern used on other // pages (e.g. purchase-orders page's receivedAt/receivedById). + ...(hasAssetTag ? { assetTag } : {}), ...(hasWarrantyEndDate ? { warrantyEndDate } : {}), ...(hasPurchaseOrderId ? { purchaseOrderId } : {}), }), @@ -165,6 +169,7 @@ export default function AssetsPage() { } setSerialNumber(""); + setAssetTag(""); setModelId(""); setPurchaseDate(""); setWarrantyEndDate(""); @@ -192,6 +197,7 @@ export default function AssetsPage() { ID Serial Number + Asset Tag Model ID Purchase Date Warranty End Date @@ -205,8 +211,9 @@ export default function AssetsPage() { {assetItem.id} {assetItem.serial_number} + {assetItem.asset_tag ?? "-"} {assetItem.model_id} - {assetItem.purchase_date} + {assetItem.purchase_date ?? "-"} {assetItem.warranty_end_date ?? "-"} {assetItem.status} {assetItem.purchase_order_id ?? "-"} @@ -226,8 +233,11 @@ export default function AssetsPage() { /asset-models - ), purchase date (bắt buộc), warranty end date,{" "} - purchase order id (tuỳ chọn -- bỏ trống thì tự tính từ warranty của model). + ), purchase date (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.

+
+ + setAssetTag(event.target.value)} + /> +
+