CI / test (push) Failing after 8s
No code changes -- this backfilled 95 Employee + 384 Assignment rows from the real ITAM export using existing Prisma models directly (not src/lib/assignments.tss createAssignment(), which is for the live issue-from-stock workflow and would reject every row here since these AssetItems already carry status=assigned from the earlier bulk import). See CLAUDE.md for the full rationale and whats still out of scope (Cost, PO number, Employee.job/manager/email).
132 lines
36 KiB
Markdown
132 lines
36 KiB
Markdown
# Asset & License Management — Dev Notes
|
||
|
||
Web app nội bộ quản lý tài sản IT + license cho Ubisoft VN local IT. Luồng chính: Request → Approval → Procurement → Receiving → Delivery → Tracking → Thu hồi/Retire.
|
||
|
||
Business context, data model, luồng trạng thái, case đặc biệt: xem `docs/asset-license-management.md` — đó là nguồn brainstorm chính, đừng duplicate nội dung đó ở đây, chỉ link tới.
|
||
|
||
## Stack (đã chốt)
|
||
|
||
- Next.js (TypeScript) full-stack
|
||
- DB: Postgres (đã chốt 2026-07-29, xem lý do trong `docs/asset-license-management.md`)
|
||
- DB access: **Prisma** (ORM + migrations, đã chốt 2026-07-30 — lý do trong `docs/asset-license-management.md`). Không viết raw SQL cho app logic nữa — ngoại lệ duy nhất là hạ tầng test (`tests/setup.ts` dùng raw `pg` `Client` để tạo/xoá throwaway database, đó là test infra chứ không phải data access layer).
|
||
- Validation: **Zod** (đã chốt 2026-07-30, đi cùng quyết định Prisma)
|
||
- UI/CSS: **Tailwind CSS v4 + shadcn/ui (CLI `@latest`, hiện tại v4.16.0)** — nâng cấp từ Tailwind v3 + `[email protected]` ngày 2026-07-30, xem lý do quyết định trong `docs/asset-license-management.md`. Không còn `tailwind.config.ts` — theme khai báo thẳng trong CSS qua `@theme inline { ... }` (`src/app/globals.css`), `postcss.config.js` chỉ còn plugin `@tailwindcss/postcss` (không cần `autoprefixer` riêng nữa). shadcn config: style `new-york-v4`, base color `neutral`, CSS variables bật, `tailwind.config: ""` (`components.json`) — đây là style/preset cổ điển (Radix), **không phải** 8 preset thương hiệu hoá mới của CLI hiện tại (`nova/vega/maia/lyra/mira/luma/sera/rhea`, chọn qua `-p`), vốn ép dùng theme "Nova/Vega..." + tuỳ chọn thư viện primitive khác (`--base base` = Base UI, `--base aria` = React Aria) — không dùng các preset đó cho project này. Component dùng `radix-ui` (package hợp nhất, không phải `@radix-ui/react-slot` riêng lẻ nữa) và `tw-animate-css` (thay `tailwindcss-animate`). `globals.css` còn `@import "shadcn/tailwind.css"` — 1 file CSS runtime do chính package `shadcn` (npm) publish kèm nhiều utility (data-state variants, scroll-fade, shimmer...), là dependency thật (không phải chỉ dev tool) vì cần resolve lúc build. Khi thêm component mới: `npx shadcn@latest add <component>` (đã test non-interactive OK với `components.json` này qua flag `-y --overwrite`).
|
||
- Self-hosted (theo nguyên tắc chung của workspace — ưu tiên self-host hơn cloud, xem `../processes/new-office-fitout.md`)
|
||
|
||
## Coding conventions
|
||
|
||
Rút ra từ pattern thật đã dùng cho `Request` (xem `src/lib/requests.ts`, `src/app/api/requests/route.ts`, `prisma/schema.prisma`) — theo đúng pattern này cho các entity tiếp theo, đừng bịa pattern mới:
|
||
|
||
- **1 file schema Prisma** (`prisma/schema.prisma`) cho tất cả model — không tách nhiều file. Field trong model dùng camelCase (`employeeId`), map sang cột DB snake_case qua `@map("employee_id")`; tên bảng map qua `@@map("requests")` (snake_case, số nhiều).
|
||
- **1 file `src/lib/<entity>.ts` mỗi entity**, chứa cả 3 thứ trong cùng file: Zod schema + hàm validate trả về `ValidationResult` (`{ok:true,value}|{ok:false,error}`, không throw), các hàm CRUD gọi Prisma Client, và 1 hàm `toRecord()` map từ shape Prisma (camelCase) sang shape public API (snake_case — vd `employee_id`, `created_at`). Ranh giới cố ý: nội bộ TS/Prisma dùng camelCase, API trả ra ngoài dùng snake_case.
|
||
- **API route** (`src/app/api/<entity>/route.ts`) giữ mỏng: parse JSON trong try/catch (400 nếu invalid JSON), check `typeof body === "object" && body !== null` (400 nếu không), gọi hàm validate của lib, gọi hàm CRUD, trả `NextResponse.json(...)` với status code đúng (400 validation lỗi, 201 tạo mới, 200 list). Không viết business logic trong route handler.
|
||
- **Prisma Client**: dùng singleton ở `src/lib/prisma.ts` (global-guard chống hot-reload spawn nhiều instance) — import `prisma` từ đó, không tự `new PrismaClient()` ở nơi khác.
|
||
- **Enum có tập giá trị cố định gắn với domain** (vd `type: asset|license`) → Prisma `enum`, không phải `String` + check tay.
|
||
- **Màu/theme Tailwind**: từ khi lên Tailwind v4 (2026-07-30), theme khai báo thẳng trong `src/app/globals.css` qua `@theme inline { --color-*: var(--*); ... }` — không còn `tailwind.config.ts`. Biến CSS ở `:root`/`.dark` đã là giá trị `oklch(...)` đầy đủ, `@theme inline` chỉ alias thẳng qua `var(--x)`, **không bọc thêm** `hsl(...)`/`rgb(...)` (bài học từ bug `hsl(oklch(...))` hồi còn Tailwind v3 + `tailwind.config.ts`, đã fix rồi bỏ luôn file đó khi lên v4).
|
||
- **Test**: 1 file `tests/<entity>.test.ts` mirror theo lib file, chạy trên Postgres thật qua DB tạm per-test-file (xem `tests/setup.ts`) — không mock DB. `beforeEach` dọn bằng `prisma.<model>.deleteMany()`.
|
||
- Schema mới/đổi schema → tạo migration Prisma thật (`prisma migrate dev --name <mô_tả>`), không sửa tay SQL.
|
||
|
||
## Git / commit convention
|
||
|
||
- Message theo [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:` + mô tả ngắn gọn ở thì hiện tại (vd `feat: add Employee Prisma model + migration`).
|
||
- **Chia nhỏ commit theo lớp kỹ thuật** khi build 1 entity/feature mới — không gộp hết vào 1 commit lớn khó review. Thứ tự chuẩn cho 1 entity mới:
|
||
1. `feat: add <Entity> Prisma model + migration`
|
||
2. `feat: add <Entity> validation + CRUD (src/lib/<entity>.ts)`
|
||
3. `feat: add <Entity> API routes`
|
||
4. `test: add <Entity> API tests`
|
||
- Nếu 1 lớp cần sửa lại (bug do lớp sau phát hiện), thêm commit `fix:` riêng, không gộp ngược vào commit cũ (đã push thì không amend).
|
||
- Vẫn chạy xuyên suốt cả entity trong 1 lượt (feature-implementer → test-runner) rồi mới hỏi/báo cáo — không dừng lại xin review sau từng commit nhỏ. User sẽ tự xem lại qua `git log`/diff từng commit sau khi xong.
|
||
- **Lưu ý PowerShell trên máy này**: đừng để dấu `"` (ngoặc kép) lồng bên trong message nhiều dòng khi truyền qua `git commit -m` — dễ bị PowerShell tách nhầm argument, khiến git báo lỗi pathspec (đã gặp thật, xem cách né ở lịch sử commit). Cách an toàn: gán message vào biến trước (`$msg = @'...'@; git commit -m $msg`), tránh dấu `"` trong nội dung, hoặc dùng `git commit -F <file>` nếu bắt buộc phải có `"`.
|
||
|
||
## Môi trường
|
||
|
||
Project này được dùng từ **nhiều máy khác nhau** (máy Windows công ty, VM Linux ở nhà qua Proxmox) — **luôn kiểm tra host hiện tại trước khi giả định cách chạy npm**, đừng áp cứng 1 cách cho mọi nơi.
|
||
|
||
- **Máy Windows công ty** (`C:\Users\thoangminh\Desktop\ubisoft\asset-license-mgmt`): **không có Node.js/npm cài native**. Mọi `npm install`/`npm test`/`tsc` phải chạy qua Docker (`node:20` container, xem cú pháp bên dưới). Docker Desktop đã confirm hoạt động được trên máy này (dùng để chạy Xibo containers, xem `../xibo/`).
|
||
- **VM Linux ở nhà (Proxmox)**: nếu VM có Node.js cài native (kiểm tra bằng `node -v`/`which node` trước), chạy `npm install`/`npm test`/`tsc` **trực tiếp**, không cần wrap qua Docker — chỉ Postgres mới cần Docker (`docker compose up -d db`), vì đó là quyết định kiến trúc (Postgres qua container), không phải vì thiếu Node. Nếu VM đó cũng không có Node, dùng lại cách wrap Docker như máy Windows.
|
||
- DB luôn là Postgres qua `docker-compose.yml` (service `db`, network `asset-license-mgmt-net`, image `postgres:16-alpine`) — **cái này giống nhau ở mọi máy**, không phụ thuộc host có Node native hay không. Trước khi `npm test`, phải start Postgres: `docker compose up -d db` (từ thư mục project).
|
||
- Nếu phải wrap qua Docker (máy không có Node native): chạy npm trong container gắn cùng network để resolve được hostname `db`:
|
||
`docker run --rm --network asset-license-mgmt-net -v "<project dir>:/app" -w /app -e DATABASE_URL="postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt" node:20 sh -c "npm install && npm test"`
|
||
- Nếu Node native: set `DATABASE_URL` (xem `.env`/`.env.example`) rồi chạy thẳng `npm install && npm test` — Postgres vẫn phải resolve qua `db` nếu chạy trong Docker network, hoặc `localhost`/port map nếu compose có publish port (hiện `docker-compose.yml` **không** publish port ra host, cần thêm port mapping hoặc chạy npm trong cùng Docker network nếu muốn gọi từ ngoài container).
|
||
- Test tự tạo/xoá một database Postgres tạm (`test_<uuid>`) riêng cho mỗi lần chạy file test (xem `tests/setup.ts`), không đụng vào database dev chính.
|
||
- `DATABASE_URL` mẫu nằm ở `.env.example` — copy thành `.env` (đã gitignore) nếu cần chỉnh local, riêng cho từng máy.
|
||
- `npm install` tự chạy `prisma generate` qua hook `postinstall` (xem `package.json`) — không cần gọi tay trừ khi thay đổi `schema.prisma` mà không chạy `npm install` lại.
|
||
- `pg` vẫn còn trong `devDependencies` — **chỉ** dùng cho hạ tầng test (`tests/setup.ts` tạo/xoá throwaway DB), không phải data access layer của app (đó là Prisma). Đừng nhầm là app vẫn dùng raw SQL.
|
||
|
||
### Lưu ý về việc gọi custom agent (ba/pm/feature-implementer/test-runner/code-reviewer)
|
||
|
||
Các agent định nghĩa ở `.claude/agents/` trong thư mục **này** (`asset-license-mgmt/`) chỉ được Claude Code nhận diện làm `subagent_type` nếu phiên Claude Code được mở với working directory **là chính thư mục này** (hoặc thư mục con của nó) — Claude Code quét `.claude/agents/` từ cwd đi lên tới repo root, **không quét xuống** thư mục con. Nếu mở Claude Code từ `ubisoft/` (thư mục cha, gộp nhiều project), các agent này sẽ báo "not found". Cách đúng: `cd` vào `asset-license-mgmt/` rồi mới chạy `claude` khi làm việc trên project này.
|
||
|
||
## Multi-agent dev workflow
|
||
|
||
Mục tiêu: stakeholder (user) chỉ cần đưa ra mục tiêu/ưu tiên ở mức nghiệp vụ — không cần tự chia nhỏ task hay chọn giữa các phương án kỹ thuật. Đồng thời không để một agent vừa viết code vừa tự chấm bài mình — tách vai trò để bug bị bắt lại thay vì được tự "cho qua".
|
||
|
||
Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking seat cho License", "cho phép reject Request"):
|
||
|
||
0. **`ba`** — chuyển yêu cầu ở mức business thành spec cụ thể (field, rule, edge case, acceptance criteria), bám theo `docs/asset-license-management.md`, và note lại spec/quyết định đã chốt vào chính doc đó. Nếu có quyết định nghiệp vụ chưa rõ (ai được approve, xử lý `Assignment` khi `License` bị retire...) → dừng lại hỏi thẳng stakeholder, không tự đoán. Bỏ qua bước này nếu yêu cầu đã đủ nhỏ/rõ (vd 1 bug fix cụ thể).
|
||
1. **`pm`** — nhận spec từ `ba` (hoặc thẳng backlog đã rõ), chia thành danh sách task cụ thể theo đúng thứ tự lớp kỹ thuật chuẩn (model+migration → lib → API → test), sắp theo phụ thuộc, map mỗi task tới đúng 1 commit theo convention ở "Git / commit convention" — tách càng cụ thể càng tốt. Nếu spec thiếu chi tiết kỹ thuật mà việc tự quyết có thể đổi hành vi nghiệp vụ, `pm` hỏi ngược lại `ba` để bổ sung spec trước khi chốt task, thay vì tự đoán rồi chia task luôn. Không tự code, không tự quyết nghiệp vụ.
|
||
2. **`feature-implementer`** — nhận đúng 1 task từ `pm`, viết code + test đi kèm trong cùng lượt. Không tự tuyên bố "đã pass" — chỉ báo cáo đã đổi gì, giả định gì, và cái gì cố tình để ngoài scope.
|
||
3. **`test-runner`** — spawn **riêng**, không phải cùng agent vừa code. Chạy toàn bộ test suite + lint + type-check, báo lỗi cụ thể kèm file:line. Không tự sửa code.
|
||
4. Nếu fail: đưa report của test-runner làm input cho một lượt `feature-implementer` mới để fix. Lặp lại bước 2–3 tới khi xanh hết.
|
||
5. **`code-reviewer`** (optional, dùng cho thay đổi lớn/rủi ro — vd đổi state machine của Request, logic seat-count license) — review diff cuối cùng trước khi commit, tìm bug logic/security, không tự sửa.
|
||
|
||
### Quy tắc cứng
|
||
- `ba`/`pm` luôn chạy trước khi có task cụ thể cho `feature-implementer`, trừ khi task đã đủ nhỏ/rõ ràng (bug fix cụ thể, không phải feature mới) — trường hợp đó có thể giao thẳng cho `feature-implementer`.
|
||
- Implementer và test-runner luôn là 2 lượt spawn riêng biệt cho cùng 1 task — không gộp.
|
||
- test-runner không được tự sửa file, kể cả khi lỗi trông "hiển nhiên".
|
||
- Nếu implementer thấy task mơ hồ, chọn giả định nhỏ nhất hợp lý và ghi rõ trong report thay vì dừng lại hỏi — nhưng nếu ảnh hưởng luồng trạng thái Request/quyền approval thì phải hỏi trước khi code.
|
||
- `ba`/`pm` không tự quyết nghiệp vụ mơ hồ — luôn đẩy ngược lại stakeholder thay vì đoán.
|
||
- `pm` gặp gap kỹ thuật ảnh hưởng nghiệp vụ (field/rule/quan hệ chưa nêu trong spec) → hỏi ngược lại `ba` (không tự bịa), `ba` trả lời bằng cách tra `docs/asset-license-management.md` hoặc escalate tiếp lên stakeholder nếu doc cũng chưa có câu trả lời.
|
||
|
||
## Trạng thái hiện tại
|
||
|
||
Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state `Draft`), `User`+Auth, `Employee`, `AssetItem`, `License`, `Vendor`, `PurchaseOrder`, `AssetCategory`, `AssetCategoryAttributeDef`, `AssetModel`, `Assignment`, `MaintenanceRecord`. Chi tiết field/migration từng entity xem `prisma/schema.prisma` + git log — không lặp lại ở đây, chỉ ghi những thứ **không tự derive được từ code** (quyết định, gotcha, rủi ro còn treo).
|
||
|
||
- Nền tảng (2026-07-30/31): bootstrap Next.js + `Request` (Draft), migrate SQLite→Postgres, ESLint, Tailwind v4 + shadcn `@latest`, Auth.js v5 (Credentials + bcryptjs, flat permission — không role), route-gating qua `src/middleware.ts` (default-deny; `src/auth.config.ts` tách riêng vì Credentials provider dùng bcryptjs không chạy được Edge Runtime; API route tự check `auth()` vì matcher loại trừ `/api`). Deploy lên homelab (2026-08-02) tại `ubi.toanhoang.work` — gotcha `apk add openssl` đã ghi ở `/opt/homelab/CLAUDE.md`, không lặp lại ở đây.
|
||
- `Request.employeeId` retrofit string→FK (2026-07-31): thay đổi hành vi API có chủ ý — `employee_id` giờ bắt buộc UUID hợp lệ + phải trỏ Employee có thật, trả `400` sạch thay vì 500 nếu không (P2003).
|
||
- `License.vendor` retrofit string→`vendorId` FK (2026-08-02), cùng lúc `PurchaseOrder` (gộp `Receipt`) ra đời với `purchaseOrderId` (nullable FK) ở cả `AssetItem`/`License`/`Request` (3 chiều traceability khác nhau — xem `docs/asset-license-management.md`).
|
||
- Phân cấp tài sản (2026-08-03): `AssetCategory` (`valueGroup` high/low_value) → `AssetCategoryAttributeDef` (thuộc tính động theo category, unique `(categoryId,key)`) → `AssetModel` (`specs` JSONB, validate động qua Zod build runtime từ AttributeDef). `AssetItem.model` (string) retrofit sang `AssetItem.modelId` (FK).
|
||
- `Assignment` xong + code-reviewer gate PASS (2026-08-03): CRUD chuẩn + `transferAssignment()` (đóng Assignment cũ + tạo mới cùng 1 transaction, cố tình **không** đụng `AssetItem.status`/`License.seatsUsed` vì resource không thực sự rảnh giữa chừng khi transfer) — fix 1 gap HIGH code-reviewer phát hiện (trước đó phải gọi 2 request rời, có race window + không rollback). `PATCH /api/assignments/[id]` dispatch theo body: có `employeeId` → transfer, có `returnedAt` → return, cả hai → 400. CONFIRMED qua test-runner độc lập: 284/284 test, tsc/lint sạch.
|
||
- UI cơ bản đã có (nhánh `worktree-ui-basic`, đã merge vào `feat/assignment`, đã push): shared nav + fetch hook + table/select/label/textarea primitives, list+create cho mọi entity, list+create+return cho `Assignment`. Chưa có UI cho transfer (chỉ có API).
|
||
- UI cho `MaintenanceRecord` xong (2026-08-04, `src/app/maintenance-records/page.tsx`): list+create+close, mirror per-row close mini-form pattern của `Assignment`'s return UI; `targetStatus` select tái dùng closed `AssetItemStatus` set client-side (cùng precedent với licenses page's `licenseType` select). tsc/lint sạch, 334/334 test.
|
||
- UI transfer cho `Assignment` xong (2026-08-04, `src/app/assignments/page.tsx`): per-row employee `<Select>` + nút Transfer cạnh nút Return sẵn có (chỉ hiện ở row active — `returned_at` null), PATCH `{employeeId}` — route đã tự dispatch sang `transferAssignment()`. Không có thay đổi lib/API, tsc/lint sạch.
|
||
- `MaintenanceRecord` xong + code-reviewer gate PASS (2026-08-04): entity mới track sửa chữa/bảo hành cho `AssetItem` (spec BA+stakeholder, xem `docs/asset-license-management.md`) — `type` (enum `repair`/`warranty`/`preventive`), `vendorId` (tái dùng `Vendor`, nullable), không track `cost`. Mutator đầu tiên ghi `AssetItem.status` ngoài `Assignment`: mở record → auto `status=repair` (chặn 400 nếu `AssetItem` đang `retired` hoặc có `Assignment` active hoặc đã có record khác đang mở); đóng record (`PATCH`, set `endDate`+`resolutionNotes`) nhận `targetStatus` bắt buộc, giới hạn chỉ `in_stock`/`retired` (không cho `assigned`/`repair` — tránh tạo `AssetItem.status=assigned` mồ côi không có `Assignment` tương ứng, fix sau code-reviewer MEDIUM finding). Code-reviewer tìm 1 gap HIGH (guard `updateMany` ban đầu dùng `status: {not:"retired"}` thay vì pin đúng `status:"in_stock"` như `Assignment`, cho phép race mở 2 record cùng lúc / đụng độ với `Assignment`) — đã fix, giờ mirror đúng pattern atomic guard của `createAssignment()`. CONFIRMED qua test-runner độc lập: 327/327 test (chạy lại 3 lần liên tiếp), tsc/lint sạch.
|
||
- Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (`tests/maintenance-records.test.ts`), gây `PrismaClientKnownRequestError: Foreign key constraint violated` không ổn định ở `beforeEach` của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: `process.env.DATABASE_URL` bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của `src/lib/prisma.ts` singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: `tests/setup.ts` giờ dùng `vi.mock("@/lib/prisma", async () => {...})` — mỗi file test tự tạo DB tạm riêng + 1 `PrismaClient` riêng (qua option `datasourceUrl`, không đụng `process.env` toàn cục nữa) — không còn state nào chia sẻ giữa các file. `fileParallelism: false` đã bỏ khỏi `vitest.config.ts` (về default `true`).
|
||
- **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình.
|
||
- **`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.
|
||
- **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.
|
||
- **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).
|
||
- **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ở
|
||
|
||
- Fix timing side-channel ở `verifyUserCredentials`: trả về sớm khi email không tồn tại (bỏ qua bcrypt compare) → thời gian phản hồi khác nhau giữa "email sai" vs "password sai", có thể dò được email tồn tại. Fix: luôn chạy `bcrypt.compare()` kể cả khi không tìm thấy user (so với 1 hash giả cố định).
|
||
- Cải thiện `scripts/create-user.ts` để password không lộ qua CLI arg (đọc từ stdin/prompt thay vì `--password`) — chấp nhận được vì chỉ IT admin tự chạy tay, không phải self-registration công khai.
|
||
- Bump `vitest` lên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — không khai thác được vì project không chạy `vitest --ui`) — không khẩn cấp.
|
||
- Rà lại advisory riêng của `next` (vài high/moderate — SSRF qua WebSocket upgrade, middleware bypass) khi có dịp.
|
||
- Test coverage gap: `assets`/`employees`/`requests` chưa có test malformed JSON body / non-object body (`licenses`/`assignments` đã có).
|
||
- `Button` (shadcn) không `forwardRef` (registry hiện tại target React 19), project pin React 18.3.1 — dormant risk, chỉ phát sinh nếu sau này có chỗ truyền `ref` vào `<Button>`.
|
||
- **Finding từ code-reviewer gate cho `Assignment` (2026-08-03), cả hai đều dormant/chưa gãy vì chưa có delete endpoint nào và `createAssignment()` hiện chỉ được gọi qua route đã validate:**
|
||
- XOR (`assetItemId`/`licenseId`) chỉ enforce ở tầng Zod (route boundary), không lặp lại bên trong `createAssignment()` — nếu sau này có caller khác gọi thẳng lib function (script, bulk-import) bỏ qua validate, có thể tạo Assignment vi phạm XOR.
|
||
- FK `assetItemId`/`licenseId` trên `Assignment` dùng `ON DELETE SET NULL`, không nhất quán với `employeeId` (`ON DELETE RESTRICT`) — nếu sau này có endpoint xoá `AssetItem`/`License`, các `Assignment` lịch sử tham chiếu tới nó sẽ bị null hoá cả 2 field, xoá mất thông tin đã từng assign gì (và re-vi-phạm XOR retroactively). Nên đổi sang `RESTRICT` như `employeeId` khi/nếu thêm delete endpoint.
|
||
- (LOW, chưa cần fix) `returnedAt` không validate `>= assignedAt`; decrement `License.seatsUsed` lúc return không có floor guard (an toàn hiện tại vì luôn đi kèm 1 increment trước đó, nhưng không có defensive floor nếu state drift do can thiệp DB tay).
|
||
- **Finding LOW từ code-reviewer gate cho `MaintenanceRecord` (2026-08-04), chưa fix (chấp nhận được, dormant):** `closeMaintenanceRecord()`'s update cuối cùng lên `AssetItem.status` là `update()` thường, không có WHERE-guard theo trạng thái hiện tại — cùng loại rủi ro dormant như `Assignment.returnedAt`/`seatsUsed` ở trên, chỉ phát sinh nếu có flow khác ghi `AssetItem.status` đồng thời lúc đóng record (hiện chưa có).
|
||
- `ConsumableItem` (phần catalog, low-value) đã có spec (`docs/asset-license-management.md`) nhưng **chưa build** — sẵn sàng giao `pm` khi cần. Luồng "xin+nhận" consumable riêng (đơn giản hơn state machine `Request`) vẫn **BỊ CHẶN** chờ stakeholder trả lời 7 câu hỏi mở (xem doc).
|
||
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
|
||
- UI cho các state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra `git status`/working tree trước khi bắt đầu để tránh làm trùng.)
|
||
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
|
||
- `InventoryScan` chưa có bước "promote" sang `AssetItem` thật (đọc serial thật, tạo `AssetItem`/`Assignment` từ 1 scan đã review) — hiện chỉ nằm ở `InventoryScan`, chưa lên sổ chính thức.
|
||
- `InventoryScan.assetTag`/`serialNumber` chưa có OCR tự động — đọc thủ công qua ảnh (nhờ Claude đọc, không phải tính năng trong app). Nếu cần tự động: cần `ANTHROPIC_API_KEY` riêng cho server + route gọi Claude Vision, stakeholder đã chọn hướng thủ công trước mắt.
|
||
- `code-reviewer` gate chưa chạy cho `InventoryScan`/`Area` (xem note ở "Trạng thái hiện tại") — nên chạy khi có phiên mở đúng cwd project.
|
||
- **Gộp `inventory-capture` vào `ubi-dan-itam` (chốt hướng 2026-08-20, chưa làm):** stakeholder quyết định nên gộp bot Telegram kiểm kê (hiện là service Python riêng `/opt/homelab/inventory-capture/`, ngoài repo này) thẳng vào app này thay vì giữ 2 service tách rời gọi nhau qua HTTP. Lý do: đúng nguyên tắc "không tách backend/frontend nếu không có lý do hạ tầng khác biệt" đã chốt ở trên; bỏ được vòng HTTP nội bộ (`/api/bot/*` + `BOT_INGEST_SECRET`) hiện dùng để 2 service tự gọi nhau; giảm số nơi giữ `BOT_TOKEN` (hiện đang lặp ở 3 chỗ: DB của `telegram-gateway`, `.env` của `inventory-capture`, `.env` của app này — xem `src/app/api/inventory-scans/[id]/photo/route.ts`). Việc cần làm: viết lại state machine hội thoại (hiện là dict Python trong `inventory-capture/app.py`) sang TypeScript, gắn vào route mới `/api/telegram/webhook` (route mà `telegram-gateway` gọi vào — khác `/api/bot/*` là route mà bot gọi vào), state hội thoại theo `chat_id` dùng in-memory `Map` là được (app này chạy `next start`, process sống lâu dài, không phải serverless — cùng rủi ro mất state khi restart như hiện tại, không tệ hơn). Nên có test theo đúng convention khi làm. Sau khi gộp, cân nhắc thêm proxy `answerCallbackQuery`/`getFile` vào `telegram-gateway` để dọn nốt `BOT_TOKEN` khỏi app này luôn (xuống còn đúng 1 nơi giữ token).
|
||
|
||
---
|
||
Cập nhật lần cuối: 2026-08-20 (`InventoryScan` xong, CI Gitea Actions setup, quyết định gộp `inventory-capture` vào app này — chưa làm)
|