AssetModel gains an optional warrantyMonths (standard vendor warranty length). createAssetItem() now defaults warrantyEndDate to purchaseDate + warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3) whenever the caller omits warrantyEndDate -- still overridable per item for batches with extended/different warranty terms. /asset-models UI gets an input for the new field; /assets needs no change since it already omits warrantyEndDate from the payload when left blank. Hand-written migration + manual smoke test (tsx script, not Vitest), same deviation as the other 2026-08-2x work in this branch -- see CLAUDE.md. tsc/lint clean.
31 KiB
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.tsdùng rawpgClientđể 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 trongdocs/asset-license-management.md. Không còntailwind.config.ts— theme khai báo thẳng trong CSS qua@theme inline { ... }(src/app/globals.css),postcss.config.jschỉ còn plugin@tailwindcss/postcss(không cầnautoprefixerriêng nữa). shadcn config: stylenew-york-v4, base colorneutral, 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ùngradix-ui(package hợp nhất, không phải@radix-ui/react-slotriêng lẻ nữa) vàtw-animate-css(thaytailwindcss-animate).globals.csscòn@import "shadcn/tailwind.css"— 1 file CSS runtime do chính packageshadcn(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ớicomponents.jsonnà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>.tsmỗ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àmtoRecord()map từ shape Prisma (camelCase) sang shape public API (snake_case — vdemployee_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), checktypeof 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) — importprismatừ đó, 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) → Prismaenum, không phảiString+ 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.cssqua@theme inline { --color-*: var(--*); ... }— không còntailwind.config.ts. Biến CSS ở:root/.darkđã là giá trịoklch(...)đầy đủ,@theme inlinechỉ alias thẳng quavar(--x), không bọc thêmhsl(...)/rgb(...)(bài học từ bughsl(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.tsmirror theo lib file, chạy trên Postgres thật qua DB tạm per-test-file (xemtests/setup.ts) — không mock DB.beforeEachdọn bằngprisma.<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:
feat:,fix:,docs:,chore:,test:,refactor:+ mô tả ngắn gọn ở thì hiện tại (vdfeat: 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:
feat: add <Entity> Prisma model + migrationfeat: add <Entity> validation + CRUD (src/lib/<entity>.ts)feat: add <Entity> API routestest: 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 quagit 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ùnggit 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ọinpm install/npm test/tscphải chạy qua Docker (node:20container, 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 nodetrước), chạynpm install/npm test/tsctrự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(servicedb, networkasset-license-mgmt-net, imagepostgres: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 khinpm 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ẳngnpm install && npm test— Postgres vẫn phải resolve quadbnếu chạy trong Docker network, hoặclocalhost/port map nếu compose có publish port (hiệndocker-compose.ymlkhô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 (xemtests/setup.ts), không đụng vào database dev chính. DATABASE_URLmẫu nằm ở.env.example— copy thành.env(đã gitignore) nếu cần chỉnh local, riêng cho từng máy.
- 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
npm installtự chạyprisma generatequa hookpostinstall(xempackage.json) — không cần gọi tay trừ khi thay đổischema.prismamà không chạynpm installlại.pgvẫn còn trongdevDependencies— chỉ dùng cho hạ tầng test (tests/setup.tstạ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"):
ba— chuyển yêu cầu ở mức business thành spec cụ thể (field, rule, edge case, acceptance criteria), bám theodocs/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ýAssignmentkhiLicensebị 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ể).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ụ,pmhỏi ngược lạibađể 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ụ.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.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.- Nếu fail: đưa report của test-runner làm input cho một lượt
feature-implementermới để fix. Lặp lại bước 2–3 tới khi xanh hết. 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/pmluôn chạy trước khi có task cụ thể chofeature-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 chofeature-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/pmkhông tự quyết nghiệp vụ mơ hồ — luôn đẩy ngược lại stakeholder thay vì đoán.pmgặ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ạiba(không tự bịa),batrả lời bằng cách tradocs/asset-license-management.mdhoặ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 quasrc/middleware.ts(default-deny;src/auth.config.tstách riêng vì Credentials provider dùng bcryptjs không chạy được Edge Runtime; API route tự checkauth()vì matcher loại trừ/api). Deploy lên homelab (2026-08-02) tạiubi.toanhoang.work— gotchaapk add opensslđã ghi ở/opt/homelab/CLAUDE.md, không lặp lại ở đây. Request.employeeIdretrofit string→FK (2026-07-31): thay đổi hành vi API có chủ ý —employee_idgiờ bắt buộc UUID hợp lệ + phải trỏ Employee có thật, trả400sạch thay vì 500 nếu không (P2003).License.vendorretrofit string→vendorIdFK (2026-08-02), cùng lúcPurchaseOrder(gộpReceipt) ra đời vớipurchaseOrderId(nullable FK) ở cảAssetItem/License/Request(3 chiều traceability khác nhau — xemdocs/asset-license-management.md).- Phân cấp tài sản (2026-08-03):
AssetCategory(valueGrouphigh/low_value) →AssetCategoryAttributeDef(thuộc tính động theo category, unique(categoryId,key)) →AssetModel(specsJSONB, validate động qua Zod build runtime từ AttributeDef).AssetItem.model(string) retrofit sangAssetItem.modelId(FK). Assignmentxong + 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 đụngAssetItem.status/License.seatsUsedvì 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àofeat/assignment, đã push): shared nav + fetch hook + table/select/label/textarea primitives, list+create cho mọi entity, list+create+return choAssignment. Chưa có UI cho transfer (chỉ có API). - UI cho
MaintenanceRecordxong (2026-08-04,src/app/maintenance-records/page.tsx): list+create+close, mirror per-row close mini-form pattern củaAssignment's return UI;targetStatusselect tái dùng closedAssetItemStatusset client-side (cùng precedent với licenses page'slicenseTypeselect). tsc/lint sạch, 334/334 test. - UI transfer cho
Assignmentxong (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_atnull), PATCH{employeeId}— route đã tự dispatch sangtransferAssignment(). Không có thay đổi lib/API, tsc/lint sạch. MaintenanceRecordxong + code-reviewer gate PASS (2026-08-04): entity mới track sửa chữa/bảo hành choAssetItem(spec BA+stakeholder, xemdocs/asset-license-management.md) —type(enumrepair/warranty/preventive),vendorId(tái dùngVendor, nullable), không trackcost. Mutator đầu tiên ghiAssetItem.statusngoàiAssignment: mở record → autostatus=repair(chặn 400 nếuAssetItemđangretiredhoặc cóAssignmentactive hoặc đã có record khác đang mở); đóng record (PATCH, setendDate+resolutionNotes) nhậntargetStatusbắt buộc, giới hạn chỉin_stock/retired(không choassigned/repair— tránh tạoAssetItem.status=assignedmồ côi không cóAssignmenttương ứng, fix sau code-reviewer MEDIUM finding). Code-reviewer tìm 1 gap HIGH (guardupdateManyban đầu dùngstatus: {not:"retired"}thay vì pin đúngstatus:"in_stock"nhưAssignment, cho phép race mở 2 record cùng lúc / đụng độ vớiAssignment) — đã fix, giờ mirror đúng pattern atomic guard củacreateAssignment(). 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âyPrismaClientKnownRequestError: Foreign key constraint violatedkhông ổn định ởbeforeEachcủa 2 file khác dù chạy riêng lẻ vẫn xanh (root cause:process.env.DATABASE_URLbị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient củasrc/lib/prisma.tssingleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật:tests/setup.tsgiờ dùngvi.mock("@/lib/prisma", async () => {...})— mỗi file test tự tạo DB tạm riêng + 1PrismaClientriêng (qua optiondatasourceUrl, không đụngprocess.envtoàn cục nữa) — không còn state nào chia sẻ giữa các file.fileParallelism: falseđã bỏ khỏivitest.config.ts(về defaulttrue). InventoryScan+Area(2026-08-20, xemdocs/asset-license-management.mdmụ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 quasrc/lib/bot-auth.ts— bearer secretBOT_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 pipelineba→pm→feature-implementer→test-runner→code-reviewerchuẩ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.tspattern, Postgres thật) và tự chạynpm test/tsc --noEmit/npm run lint— 369/369 test pass (35 test mới), tsc sạch, lint sạch — nhưng chưa quacode-reviewergate thật, khác vớiAssignment/MaintenanceRecordở trên. Nên cân nhắc chạycode-reviewercho 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ì quaprisma 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.ExpectedAssetimport + bot "🔍 Tìm trong list" (2026-08-21): sau khi quyết định tắt OCR (xemOllama/ITAM - Inventory Scansnote 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ớiExpectedAsset(migration viết tay20260821194252_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ửasrc/lib/inventory-scans.ts(createInventoryScannhận thêmexpectedAssetId, đá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/webhookvới payload Telegram giả) +tsc --noEmit/npm run lintsạch, KHÔNG quacode-reviewergate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cảInventoryScangốc, cần bổ sung test + review khi có phiên đúng cwd.AssetModel.warrantyMonths-> auto-fillAssetItem.warrantyEndDate(2026-08-21): thêm fieldwarrantyMonths(nullable) vàoAssetModel— thời hạn bảo hành chuẩn theo dòng máy/hãng.createAssetItem()trongsrc/lib/assets.tstự tínhwarrantyEndDate = purchaseDate + warrantyMonths(helperaddMonthsClamped(), 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ềnwarrantyEndDate— vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI/asset-modelscó thêm input "Standard Warranty (months)"; trang/assetskhông cần sửa gì (đã omitwarrantyEndDatekhỏi payload khi để trống, server tự điền). Migration viết tay20260821200217_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/lintsạch.- CI (2026-08-20): Gitea Actions, workflow
.gitea/workflows/ci.yml— chạynpm 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 Giteagit.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ênappsrv01:docker compose up -d --build apprồidocker 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ạybcrypt.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
vitestlên 4.x (fix advisory critical GHSA-5xrq-8626-4rwp — không khai thác được vì project không chạyvitest --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/requestschưa có test malformed JSON body / non-object body (licenses/assignmentsđã có). Button(shadcn) khôngforwardRef(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ềnrefvà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 trongcreateAssignment()— 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/licenseIdtrênAssignmentdùngON DELETE SET NULL, không nhất quán vớiemployeeId(ON DELETE RESTRICT) — nếu sau này có endpoint xoáAssetItem/License, cácAssignmentlị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 sangRESTRICTnhưemployeeIdkhi/nếu thêm delete endpoint. - (LOW, chưa cần fix)
returnedAtkhông validate>= assignedAt; decrementLicense.seatsUsedlú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).
- XOR (
- 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ênAssetItem.statuslà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 ghiAssetItem.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 giaopmkhi cần. Luồng "xin+nhận" consumable riêng (đơn giản hơn state machineRequest) 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.
InventoryScanchưa có bước "promote" sangAssetItemthật (đọc serial thật, tạoAssetItem/Assignmenttừ 1 scan đã review) — hiện chỉ nằm ởInventoryScan, chưa lên sổ chính thức.InventoryScan.assetTag/serialNumberchư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ầnANTHROPIC_API_KEYriêng cho server + route gọi Claude Vision, stakeholder đã chọn hướng thủ công trước mắt.code-reviewergate chưa chạy choInventoryScan/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-capturevàoubi-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ủatelegram-gateway,.envcủainventory-capture,.envcủa app này — xemsrc/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 tronginventory-capture/app.py) sang TypeScript, gắn vào route mới/api/telegram/webhook(route màtelegram-gatewaygọi vào — khác/api/bot/*là route mà bot gọi vào), state hội thoại theochat_iddùng in-memoryMaplà được (app này chạynext 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 proxyanswerCallbackQuery/getFilevàotelegram-gatewayđể dọn nốtBOT_TOKENkhỏ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)