Files
ubi-dan-itam/CLAUDE.md
T
toanhoang aa7f5515db
CI / test (push) Failing after 8s
Add AssetItem CSV import/export
/assets gets Export CSV (GET /api/assets/export) and Import CSV
(POST /api/assets/import) buttons. Export emits human-readable
manufacturer/model columns instead of raw modelId; import resolves those
back to an AssetModel (case-insensitive match, must already exist -- this
does not auto-create models) so export -> edit in Excel -> re-import is a
real round trip.

Per-row import errors (unmatched model, missing required field, bad date)
are collected and returned alongside the created count rather than
aborting the whole file on the first bad row -- a typo in row 200 should
not cost rows 1-199. warrantyEndDate still auto-fills from the models
warrantyMonths when the CSV omits it, same as manual creation.

Extracted src/lib/csv.ts (splitCsvLine/splitCsvLines/normalizeHeader/
csvField) out of expected-assets.ts so both CSV parsers share one
implementation instead of duplicating it.

Manual smoke test only (tsx script: export shape, valid row + unmatched-
model row + missing-date row all handled correctly), no Vitest coverage,
not through the ba->pm->feature-implementer->test-runner->code-reviewer
pipeline -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.
2026-08-23 07:41:52 -04:00

125 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- **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)