Author SHA1 Message Date
toanhoangandClaude Sonnet 5 e319f88a75 docs: add BA spec for high/low-value classification + per-category custom fields
Records two pending business decisions in docs/asset-license-management.md:
AssetCategory/AssetModel/ConsumableItem classification (already decided
2026-08-02, carried forward from local WIP), and a new spec for Snipe-IT-style
per-category custom fields (AssetFieldDefinition/AssetFieldValue, EAV-based).
Custom fields spec is blocked on stakeholder answers to open questions
(required flag, editing definitions after assets exist, whether it applies to
ConsumableItem) before pm can chunk implementation tasks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-02 12:40:30 -04:00
7 changed files with 31 additions and 752 deletions
+2 -7
View File
@@ -113,15 +113,10 @@ Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking s
- Rà lại advisory riêng của `next` (prod dependency, có vài high/moderate — vd SSRF qua WebSocket upgrade, middleware bypass) khi có dịp, tách biệt khỏi đợt retrofit Prisma/Zod này.
- Test coverage gaps (chưa block gì, chỉ là follow-up): chưa test malformed JSON body, chưa test JSON body không phải object (array/string).
- `Button` (shadcn) không `forwardRef`, React đang pin 18 — dormant risk, xem note ở "Trạng thái hiện tại".
- **`Vendor`/`PurchaseOrder`(gộp `Receipt`)/`Assignment`/`AssetCategoryAttributeDef` (2026-08-02): spec đầy đủ ở `docs/asset-license-management.md`, đã có task list phụ thuộc-đúng do `pm`-pass sequencing (2026-08-02), sẵn sàng giao `feature-implementer` theo đúng thứ tự dưới đây (3 lane độc lập, có thể xen kẽ):**
- **Lane A (Vendor → License.vendorId → PurchaseOrder → purchaseOrderId x3)**: (1-4) `Vendor` entity chuẩn 4 commit (model+migration/lib/API/test), không phụ thuộc gì. (5) Retrofit `License.vendor` (string) → `License.vendorId` (FK) — **1 commit gộp** (schema+migration+lib+test), theo đúng tiền lệ retrofit `Request.employeeId` (commit `49d96b7`, không tách lớp như entity mới vì tách ra sẽ để lại state nửa vời không chạy được) — tiện thể fix luôn finding cũ của `code-reviewer`: `createLicense()` chưa theo pattern `{ok,value}|{ok,error}`, giờ **bắt buộc** phải thêm vì `vendorId` là FK mới (cần bắt `P2003`). Phụ thuộc (1). (6-9) `PurchaseOrder` entity chuẩn 4 commit (`vendorId` FK, `receivedBy` FK→`Employee`, Zod `.refine()` cho ràng buộc `receivedAt`/`receivedBy` cùng null hoặc cùng có giá trị, bắt `P2003` cho cả 2 FK, theo đúng pattern `{ok,error}` ngay từ đầu) — phụ thuộc (1) `Vendor` + `Employee` (đã có). (10) `AssetItem.purchaseOrderId` — 1 commit gộp (nullable FK). (11) `License.purchaseOrderId` — 1 commit gộp **riêng** với task (5) dù cùng file, vì trigger phụ thuộc khác nhau (`Vendor` vs `PurchaseOrder`) — gộp chung sẽ vi phạm nguyên tắc 1-commit-1-thay-đổi-nghiệp-vụ. (12) `Request.purchaseOrderId` — 1 commit gộp. (10)(11)(12) đều chỉ phụ thuộc (6), độc lập lẫn nhau, thứ tự giữa 3 task này không quan trọng.
- **Lane B (AssetCategory → AssetCategoryAttributeDef + AssetModel → wire dynamic validation → AssetItem.modelId)**: **gap phát hiện khi sequencing** — `AssetCategory`/`AssetModel`/`ConsumableItem` đã có spec từ round BA trước (2026-08-02, xem "Phân loại tài sản" trong doc) nhưng **CHƯA build** (không có trong `prisma/schema.prisma` hiện tại, `AssetItem.model` vẫn là string tự do) — phải làm trước thì `AssetCategoryAttributeDef` mới có ý nghĩa. (13-16) `AssetCategory` entity chuẩn 4 commit (`name`, `valueGroup` enum `high_value`/`low_value`), không phụ thuộc gì. (17-20) `AssetCategoryAttributeDef` entity chuẩn 4 commit (`categoryId` FK, `key`, `label`, `dataType` enum, `required`, `sortOrder`, unique `(categoryId,key)` → bắt `P2002`) — phụ thuộc (13), độc lập với `AssetModel`. (21-24) `AssetModel` entity chuẩn 4 commit (`categoryId` FK + business rule "category phải `valueGroup=high_value`" — cần lookup DB, không phải Zod thuần; `manufacturer`, `name`, `specs` JSONB — ở task này `specs` chỉ validate lỏng là object hợp lệ, CHƯA khớp động theo `AssetCategoryAttributeDef`) — phụ thuộc (13), độc lập với (17-20). (25) Wire dynamic specs validation vào `AssetModel` create/update — **1 commit riêng**, không gộp vào (22), vì đây là logic mới/rủi ro (build Zod schema runtime từ `AssetCategoryAttributeDef`, map `dataType`→`z.string()/number()/boolean()/coerce.date()`, từ chối key lạ, bắt buộc key `required`) — phụ thuộc (18) và (22). (26) Retrofit `AssetItem.model` (string) → `AssetItem.modelId` (FK) — 1 commit gộp, phụ thuộc (21) (không cần đợi (25)).
- **Lane C (`Assignment`, độc lập hoàn toàn với Lane A/B — chỉ cần `Employee`+`AssetItem`+`License` đã có sẵn)**: (27) model+migration (`employeeId` FK, `assetItemId`/`licenseId` nullable FK, `assignedAt`, `returnedAt` nullable, `signedAt` nullable). (28) lib validation+CRUD — rủi ro nhất trong cả 4 entity: Zod `.refine()` XOR `assetItemId`/`licenseId`, chặn >1 `Assignment` active/`AssetItem`, transaction tăng/giảm `License.seatsUsed` kèm chặn overbook (`seatsUsed >= seatsTotal`), đồng bộ `AssetItem.status` (assigned/in_stock), helper transfer (đóng cũ+mở mới cùng 1 transaction). (29) API routes — **cần quyết định shape mới**: đây là entity đầu tiên cần thao tác "update" (return/transfer) trong khi codebase hiện tại **mọi entity đều chỉ có POST(create)+GET(list), chưa có route update nào** — đề xuất mặc định `POST /api/assignments` (assign/transfer) + `PATCH /api/assignments/[id]` (return, nhận `{returnedAt}}`), cần xác nhận lại khi giao task cho `pm`/`feature-implementer` vì không có tiền lệ. (30) tests. **(31) Bắt buộc code-reviewer pass riêng cho Assignment (diff 27-30) trước khi coi là xong** — đúng rule ở "Multi-agent dev workflow" (state-mutation-với-invariant, seat-count) — không phải 1 commit, là 1 gate.
- **2 câu hỏi mở đã được stakeholder trả lời (2026-08-02, xem "Assignment" trong `docs/asset-license-management.md`) — Lane C hết bị chặn**: (a) `createAssignment()` **chặn** nếu `AssetItem.status` khác `in_stock` (400). (b) Route update dùng `PATCH /api/<entity>/[id]` (áp dụng cho cả `Assignment` và `PurchaseOrder`), không dùng action-based sub-route.
- `ConsumableItem` (phần catalog, KHÔNG phải luồng "xin+nhận" — luồng đó vẫn BỊ CHẶN chờ 7 câu hỏi mở, xem doc): độc lập với toàn bộ 3 lane trên (chỉ cần `AssetCategory`), sẵn sàng cho `pm` chia task riêng nếu stakeholder muốn gộp vào cùng đợt — không đưa vào sequencing này vì nằm ngoài phạm vi 4 việc được giao lần này.
- Các entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`AssetItem` xong 2026-07-31, `License` xong 2026-08-02, xem "Trạng thái hiện tại"). `Assignment` là entity tiếp theo nên làm — nối `Employee`+`AssetItem`+`License`, trả lời trực tiếp câu hỏi nghiệp vụ "ai đang giữ máy/license nào" nêu ở `docs/asset-license-management.md`.
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
- UI (hiện chỉ có placeholder home page)
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao)
---
Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity, task sequencing cho Vendor/PurchaseOrder/Assignment/AssetCategoryAttributeDef)
Cập nhật lần cuối: 2026-08-02 (deploy lên homelab ubi.toanhoang.work, License entity)
+29 -59
View File
@@ -23,17 +23,19 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval →
| Entity | Ý nghĩa |
|---|---|
| `Employee` | id, tên, phòng ban, manager |
| `Vendor` | id, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable) — nhà cung cấp/reseller mua hàng qua, **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM). Xem "Vendor" bên dưới |
| `AssetItem` | hardware **high-value** (serial hóa bắt buộc) — serial number, model (FK → `AssetModel`, bắt buộc — không còn string tự do), ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới). Category không còn gán trực tiếp trên `AssetItem` — suy ra qua `AssetModel.categoryId`. Xem "Phân loại tài sản" bên dưới |
| `License` | phần mềm — tên, `vendorId` (FK → `Vendor`, retrofit từ string tự do — xem "Vendor" bên dưới), tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription), `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cung, xem "PurchaseOrder" bên dưới) |
| `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái, `purchaseOrderId` (FK → `PurchaseOrder`, nullable — chiều cầu, xem "PurchaseOrder" bên dưới) — **chỉ áp dụng cho asset high-value/license**, không dùng cho consumable (xem "Phân loại tài sản" bên dưới) |
| `PurchaseOrder` | id, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable — null nghĩa là chưa nhận), `receivedBy` (FK → `Employee`, nullable, set cùng lúc với `receivedAt`). **Gộp luôn `Receipt`** (không còn là entity riêng — nhận hàng là 1 phần trạng thái của chính PO), all-or-nothing theo cả PO (không nhận từng phần), không track giá/chi phí, không có `status` riêng (suy ra từ Request liên kết). Xem "PurchaseOrder" bên dưới |
| `Assignment` | id, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable) / `licenseId` (FK → `License`, nullable — đúng 1 trong 2, XOR), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable — chỉ là timestamp đánh dấu đã ký, không upload file). Gán AssetItem (high-value)/License cho 1 Employee cụ thể. **Không áp dụng cho consumable** (consumable track theo tồn kho, không gắn 1 người cụ thể). Xem "Assignment" bên dưới |
| `Vendor` | nhà cung cấp |
| `AssetItem` | hardware **high-value** (serial hóa bắt buộc) — serial number, model (FK → `AssetModel`, bắt buộc — không còn string tự do), ngày mua, bảo hành, trạng thái (in stock / assigned / repair / retired). Category không còn gán trực tiếp trên `AssetItem` — suy ra qua `AssetModel.categoryId`. Xem "Phân loại tài sản" bên dưới |
| `License` | phần mềm — tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, loại (perpetual/subscription) |
| `Request` | người yêu cầu, loại (asset/license), lý do, trạng thái — **chỉ áp dụng cho asset high-value/license**, không dùng cho consumable (xem "Phân loại tài sản" bên dưới) |
| `PurchaseOrder` | gộp nhiều Request đã duyệt, vendor, ngày đặt, ngày dự kiến nhận |
| `Receipt` | nhận hàng — đối chiếu PO, ngày nhận, người nhận |
| `Assignment` | gán AssetItem (high-value)/License cho 1 Employee cụ thể — ngày giao, ngày trả, biên bản ký nhận. **Không áp dụng cho consumable** (consumable track theo tồn kho, không gắn 1 người cụ thể) |
| `Request.servicenow_ticket` | field text, nhập tay số ticket ServiceNow liên quan (không tích hợp API ServiceNow ngay — volume thấp, nhập tay đủ dùng; để ngỏ cho auto-sync sau nếu cần) |
| `AssetCategory` | id, tên (vd "Laptop", "Cáp HDMI"), nhóm giá trị cố định (`high_value` \| `low_value`) — danh mục category cụ thể do IT admin tự định nghĩa, không hardcode. Xem "Phân loại tài sản" bên dưới |
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420"), `specs` (JSONB — giá trị thật của các thuộc tính động, xem `AssetCategoryAttributeDef`) — tách riêng 2 field `manufacturer`/`name`, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `AssetCategoryAttributeDef` | id, `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date`), `required` (bool), `sortOrder` (int) — định nghĩa thuộc tính riêng theo từng category, giá trị thật nằm ở `AssetModel.specs`. Unique `(categoryId, key)`. Xem "Phân loại tài sản" bên dưới |
| `AssetModel` | id, category (FK → `AssetCategory`, bắt buộc, phải thuộc nhóm `high_value`), `manufacturer` (vd "Dell"), `name` (vd "Latitude 5420") — tách riêng 2 field, không gộp chung 1 string, để filter/report theo hãng. Nằm giữa `AssetCategory` và `AssetItem` (1 model có nhiều `AssetItem` serial khác nhau). Xem "Phân loại tài sản" bên dưới |
| `ConsumableItem` | phụ kiện **low-value/consumable** — tên, category (bắt buộc, phải thuộc nhóm `low_value`, trỏ thẳng `AssetCategory` — **không** qua `AssetModel`, khái niệm "model" chỉ áp dụng cho asset serial hóa), số lượng tồn kho (`quantityOnHand`). **KHÔNG serial hóa, KHÔNG gắn Assignment cá nhân** — chỉ track theo số lượng. Xem "Phân loại tài sản" bên dưới |
| `AssetFieldDefinition` | định nghĩa 1 custom field gắn với 1 `AssetCategory` — `fieldKey`, `label`, `fieldType` (`text\|number\|date\|boolean\|dropdown`), `options` (chỉ dùng khi `fieldType = dropdown`). Xem "Custom fields theo Category" bên dưới |
| `AssetFieldValue` | giá trị thực tế của 1 `AssetFieldDefinition` trên 1 `AssetItem` cụ thể — 4 cột kiểu riêng (`valueText`/`valueNumber`/`valueDate`/`valueBoolean`), chỉ cột khớp `fieldType` được set. Xem "Custom fields theo Category" bên dưới |
## Luồng trạng thái Request
@@ -52,69 +54,36 @@ Draft → Pending Approval → Approved → Ordered → Received → Delivered
**Cập nhật 2026-08-02 (2): thêm phân cấp `AssetModel` giữa `AssetCategory` và `AssetItem`.** Lý do: nhiều model khác nhau trong cùng 1 category (vd category "Laptop" có nhiều model "Dell Latitude 5420", "MacBook Pro 14"...), cần report/thống kê theo model (vd "đang có bao nhiêu Dell Latitude 5420"), tránh lỗi gõ tay khi model là string tự do như trước. Phân cấp: `AssetCategory` ("Laptop") → `AssetModel` ("Dell Latitude 5420") → từng `AssetItem` (1 serial cụ thể thuộc model đó). `AssetModel.manufacturer`/`AssetModel.name` tách riêng (không gộp 1 string) để filter/report riêng theo hãng. Khái niệm "model" chỉ có ý nghĩa cho asset serial hóa (`high_value`) — `ConsumableItem` không có model, vẫn trỏ thẳng `AssetCategory` (`low_value`) như cũ.
**Cập nhật 2026-08-02 (3): thêm `AssetCategoryAttributeDef` — thuộc tính đặc thù theo từng category.** Lý do: từng category có bộ thuộc tính riêng cần track (vd RAM/CPU cho category "Laptop", độ phân giải cho "Màn hình") mà không phải asset high-value nào cũng giống nhau — cần định nghĩa động theo category, không hardcode cột cứng cho từng loại thuộc tính.
- Entity mới `AssetCategoryAttributeDef`: `categoryId` (FK → `AssetCategory`), `key` (slug, vd `ram_gb`), `label` (tên hiển thị, vd "RAM (GB)"), `dataType` (enum đóng: `string`/`number`/`boolean`/`date` — đây là tập giá trị kỹ thuật cố định do dev định nghĩa, **khác** với `AssetCategory` là tập mở IT admin tự mở rộng), `required` (bool), `sortOrder` (int, thứ tự hiển thị trên form). Unique `(categoryId, key)`.
- Mỗi `AssetCategory` có bộ attribute **riêng, độc lập** — cố tình **không** phải thư viện thuộc tính dùng chung giữa các category. Nếu 2 category cùng muốn 1 thuộc tính kiểu "khối lượng (kg)", mỗi category tự tạo 1 row `AssetCategoryAttributeDef` riêng — trùng tên `key` giữa 2 category là bình thường và được chấp nhận, đây là 2 row không liên quan tới nhau. Lý do: ở quy mô hiện tại (ít category), thiết kế thư viện thuộc tính dùng chung (tách riêng bảng `AttributeDef` + bảng join `CategoryAttribute`) cộng thêm độ phức tạp thật (thêm 1 bảng, UX phải chọn "gắn thuộc tính có sẵn hay tạo mới", sửa 1 def ảnh hưởng dây chuyền nhiều category) để đổi lấy lợi ích (sửa 1 chỗ áp dụng khắp nơi, query cross-category an toàn) mà hiện chưa cần tới — stakeholder đã từ chối rõ ràng thiết kế dùng chung để giữ đơn giản.
- Giá trị thật nằm ở `AssetModel.specs` (cột JSONB) — **không** nằm trên `AssetItem`, vì đây là thuộc tính theo spec-sheet dùng chung cho mọi serial thuộc cùng 1 model (mọi `AssetItem` "Dell Latitude 5420" đều cùng RAM), không phải dữ liệu riêng theo từng instance. Validate động lúc ghi: app build Zod schema runtime từ các `AssetCategoryAttributeDef` thuộc category của model đó (map `dataType` → `z.string()`/`z.number()`/`z.boolean()`/`z.coerce.date()`), từ chối key lạ chưa được định nghĩa cho category, bắt buộc các key có `required = true` phải có mặt.
- Known gap chấp nhận được, chưa chặn: nếu admin sửa/xoá 1 `AssetCategoryAttributeDef` sau khi đã có `AssetModel` mang giá trị dưới key đó trong `specs`, giá trị cũ trở thành orphaned/stale — chưa có cơ chế migration/cleanup. Ghi nhận là known gap để xử lý khi có UI quản lý attribute-def thật, không cần giải quyết ngay bây giờ.
**Ràng buộc dữ liệu:**
- `AssetModel.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = high_value`.
- `AssetItem.modelId` bắt buộc, phải trỏ tới 1 `AssetModel` có thật (thay cho field `model` string tự do trước đây — đã đổi, không cần chiến lược backfill vì DB chưa có `AssetItem` thật). `AssetItem` không còn giữ `categoryId` trực tiếp — category được suy ra qua `AssetItem.modelId → AssetModel.categoryId` (tránh 2 nguồn sự thật có thể lệch nhau).
- `ConsumableItem.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có `valueGroup = low_value` — trỏ thẳng, không qua `AssetModel`.
- `Request` (state machine đầy đủ) tiếp tục chỉ áp dụng cho `type: asset|license` như hiện tại — **không** mở rộng `RequestType` thêm `consumable`.
- `AssetCategoryAttributeDef.categoryId` bắt buộc, phải trỏ tới 1 `AssetCategory` có thật. Unique `(categoryId, key)`.
- `AssetModel.specs` (JSONB) chỉ được chứa các key đã có `AssetCategoryAttributeDef` tương ứng với category của model đó — validate ở tầng Zod (build động lúc runtime), không phải DB CHECK constraint (nhất quán với nguyên tắc "không raw SQL cho app logic" của codebase).
**Luồng riêng cho consumable ("xin + nhận" đơn giản hoá):** stakeholder đã xác nhận muốn 1 luồng gọn hơn state machine đầy đủ của `Request`, nhưng **chưa chốt chi tiết state/approval/xử lý hết hàng** — xem danh sách câu hỏi mở trong spec BA (2026-08-02, lưu ở lịch sử trao đổi/PR liên quan). Không build luồng này (entity `ConsumableRequest` hoặc tương đương) tới khi có câu trả lời — chỉ mới build phần catalog (`AssetCategory`, `ConsumableItem`).
## Vendor (chốt 2026-08-02)
## Custom fields theo Category (Additional Options) (spec BA 2026-08-02)
Entity mới, độc lập với `AssetModel`.
**Yêu cầu gốc từ stakeholder** (lấy cảm hứng từ module "Additional Options" của Snipe-IT): mỗi `AssetCategory` cho phép admin tự định nghĩa thêm các field tuỳ biến; khi tạo mới 1 `AssetItem`, tuỳ theo category của asset đó, form hiện thêm 1 section "Additional Options" chứa đúng các field đã định nghĩa cho category đó.
- Field: `id`, `name`, `contactEmail`, `contactPhone`, `address`, `taxCode` (nullable — mã số thuế VN), `notes` (nullable).
- **Phân biệt quan trọng:** `Vendor` (nơi mua hàng — vd nhà phân phối/reseller VN) là khái niệm **khác** `AssetModel.manufacturer` (hãng sản xuất/OEM, vd "Dell") — không phải lúc nào cũng cùng 1 công ty (vd mua laptop Dell qua reseller "FPT Trading", không mua thẳng từ Dell). Quyết định: giữ nguyên `AssetModel.manufacturer` là string tự do như hiện tại (không đổi gì); `Vendor` chỉ dùng cho quan hệ mua sắm (`PurchaseOrder`, `License`).
- **Retrofit `License.vendor`** (hiện là string tự do) → **`License.vendorId`** (FK → `Vendor`), theo đúng tiền lệ đã có trong codebase (`Request.employeeId` string→FK khi `Employee` ra đời, `AssetItem.model` string→FK khi `AssetModel` ra đời — xem "Trạng thái hiện tại" ở `../CLAUDE.md`). Chưa có `License` thật nào trong DB production nên đây là đổi kiểu cột thuần tuý, **không cần chiến lược backfill** — giống các retrofit trước đó.
**Gắn vào `AssetCategory`, không phải `AssetModel`** — theo đúng cách stakeholder mô tả ("với mỗi category tương ứng... custom thêm các field"). Mọi `AssetModel`/`AssetItem` thuộc cùng 1 category dùng chung 1 bộ field definition.
**Ràng buộc dữ liệu:**
- `License.vendorId` bắt buộc, phải trỏ tới 1 `Vendor` có thật (thay cho field `vendor` string tự do trước đây).
**Lưu trữ: chuẩn hoá quan hệ (EAV), không dùng cột JSONB** — nhất quán với toàn bộ schema hiện tại (mọi entity khác đều dùng cột có kiểu/enum rõ ràng, chưa có tiền lệ JSON nào), và tránh việc các Postgres view phục vụ Power BI (đã chốt ở "Quyết định kiến trúc") phải `->>`/cast JSON cho từng field.
## PurchaseOrder (gộp Receipt) (chốt 2026-08-02)
- `AssetFieldDefinition`: `id`, `categoryId` (FK → `AssetCategory`, bắt buộc), `fieldKey` (unique **trong phạm vi 1 category**, không unique toàn cục — 2 category khác nhau có thể cùng có field `color`), `label` (tên hiển thị), `fieldType` (enum `text|number|date|boolean|dropdown`), `options` (mảng string, **bắt buộc và không rỗng/không trùng lặp nếu `fieldType = dropdown`**, bỏ trống với các type khác).
- `AssetFieldValue`: `id`, `assetItemId` (FK → `AssetItem`), `fieldDefinitionId` (FK → `AssetFieldDefinition`), 4 cột giá trị kiểu riêng — `valueText`/`valueNumber`/`valueDate`/`valueBoolean` — **chỉ cột khớp với `fieldDefinitionId.fieldType` được set, các cột còn lại null**. Giữ giá trị đúng kiểu Postgres native (không stringly-typed) để filter/report được, cùng nguyên tắc với enum/date ở các entity khác.
- MVP chỉ áp dụng cho `AssetItem` (high-value, serial hóa) — **chưa áp dụng cho `ConsumableItem`** (xem câu hỏi mở #3 bên dưới, chưa chốt).
- MVP: **mọi custom field đều optional**, chưa có khái niệm `required` (xem câu hỏi mở #1).
- MVP: **chưa có khả năng sửa/xoá `AssetFieldDefinition`** sau khi tạo — đây cũng là capability sửa/xoá đầu tiên trong toàn app (mọi entity hiện tại đều mới chỉ có tạo mới + đọc, chưa có `PUT`/`PATCH`/xoá nào), nên đây không phải gap riêng của feature này mà là giới hạn chung hiện tại của app (xem câu hỏi mở #2).
**Quyết định: gộp `Receipt` vào `PurchaseOrder`, không giữ là entity riêng nữa** — nhận hàng chỉ là 1 phần trạng thái của chính PO đó (`receivedAt`/`receivedBy`), không cần 1 bảng riêng để đối chiếu. Row `Receipt` trong bảng Data model ở trên đã được xoá, gộp vào row `PurchaseOrder`.
**Câu hỏi mở — cần stakeholder trả lời, không tự đoán:**
1. Field có thể đánh dấu `required` không? Nếu có, chặn cứng hay chỉ cảnh báo lúc tạo `AssetItem`?
2. Khi category đã có `AssetItem` thật dùng field definition của nó — cho sửa/xoá definition không? Nếu xoá 1 field đã có giá trị: giữ nguyên giá trị cũ (chỉ ẩn khỏi form tạo mới), xoá cứng luôn cả giá trị liên quan, hay soft-delete/archive?
3. Custom field chỉ áp dụng cho `AssetItem` (high-value) hay áp dụng cả `ConsumableItem` (low-value)? Snipe-IT gốc chỉ áp dụng fieldset cho hardware serial hóa, nhưng domain doc này chưa xác nhận có áp dụng tương tự cho Ubisoft VN không.
4. Có giới hạn số lượng custom field tối đa trên 1 category không?
5. Giá trị custom field có cần search/filter được trong report (Power BI view) hoặc filter danh sách asset trong app không?
- Field: `id`, `vendorId` (FK → `Vendor`), `orderDate`, `expectedDate`, `receivedAt` (nullable DateTime — null nghĩa là chưa nhận), `receivedBy` (nullable FK → `Employee`, set cùng lúc với `receivedAt`).
- Cố tình **không** có field giá/chi phí — stakeholder nói rõ `PurchaseOrder` không cần track tiền, chỉ cần trả lời "asset/license nào thuộc PO nào".
- Cố tình **không** có field `PurchaseOrder.status` riêng — trạng thái suy ra từ trạng thái của các `Request` liên kết (`Request` đã có state machine `Ordered → Received → Delivered` riêng), tránh 2 nguồn sự thật.
- **Nhận hàng là all-or-nothing theo từng PO** ("nhận 1 lần đủ cho cả PO") — không hỗ trợ nhận nhiều đợt/từng phần. Đây là đánh đổi đơn giản hoá **có chủ ý**, stakeholder đã biết rõ và chấp nhận có thể cần đổi lại sau (paraphrase: "giờ làm đơn giản vậy trước, sau này cần đổi thì tính tiếp") — ghi lại rõ đây là tradeoff/assumption đã note, không phải bỏ sót khi viết spec.
- **Traceability** (đúng yêu cầu cốt lõi của stakeholder: "chỉ cần biết asset nằm trong PO nào"): thêm `purchaseOrderId` (nullable FK → `PurchaseOrder`) ở **CẢ BA** chỗ — `AssetItem`, `License`, và `Request`. Ba field này phục vụ mục đích khác nhau, không dư thừa:
- `Request.purchaseOrderId` — chiều cầu (demand-side): request nào đã duyệt được gộp vào PO này (đây vốn là mục đích ban đầu đã note trong doc cho `PurchaseOrder`: "gộp nhiều Request đã duyệt").
- `AssetItem.purchaseOrderId` và `License.purchaseOrderId` — chiều cung (supply-side): record tồn kho/thực tế nào tới qua PO này. Set lúc intake/tạo mới. Nullable vì không phải `AssetItem`/`License` nào cũng đi qua 1 PO chính thức (vd tồn kho lịch sử nhập trước khi có hệ thống).
**Ràng buộc dữ liệu:**
- `PurchaseOrder.vendorId` bắt buộc, trỏ tới 1 `Vendor` có thật.
- `PurchaseOrder.receivedBy` chỉ set cùng lúc với `receivedAt` (cả hai cùng null hoặc cùng có giá trị).
- `AssetItem.purchaseOrderId`, `License.purchaseOrderId`, `Request.purchaseOrderId` đều nullable, trỏ tới `PurchaseOrder` nếu có.
- `PurchaseOrder` không có field `status` — suy ra qua `Request` liên kết, không lưu trùng.
## Assignment (chốt 2026-08-02)
- Field: `id`, `employeeId` (FK → `Employee`), `assetItemId` (FK → `AssetItem`, nullable), `licenseId` (FK → `License`, nullable), `assignedAt`, `returnedAt` (nullable — null nghĩa đang giữ), `signedAt` (nullable DateTime — null nghĩa biên bản chưa ký. Cố tình chỉ là 1 timestamp đánh dấu đã ký/chưa, **KHÔNG** phải upload file — stakeholder đã quyết định rõ không cần lưu file scan lúc này, "biên bản ký nhận" chỉ cần đánh dấu đã ký + ngày ký).
- Đúng 1 trong 2 (`assetItemId`/`licenseId`) phải được set — không cả hai, không cả hai đều rỗng (XOR) — validate ở tầng Zod qua `.refine()`, theo đúng cách codebase đã xử lý validate cross-field (không phải DB CHECK constraint, đúng nguyên tắc "không raw SQL cho app logic" — Prisma schema cũng không diễn tả được constraint XOR này natively).
- Không phải entity mới, nhưng có các business rule tầng application cần note lại rõ ở đây (đủ quan trọng để phát sinh bug nếu bỏ sót, nhưng là rule app phải tự enforce, không phải bản thân là 1 entity):
- 1 `AssetItem` chỉ có tối đa 1 `Assignment` **active** (`returnedAt IS NULL`) tại 1 thời điểm — không thể bị giữ bởi 2 người cùng lúc. Enforce trước khi tạo `Assignment` mới.
- Assign 1 `License` phải **tăng** `License.seatsUsed`, và phải **bị từ chối** nếu `seatsUsed >= seatsTotal` (không overbook). Trả lại (set `returnedAt`) **giảm** `seatsUsed`. Toàn bộ phải chạy trong 1 transaction — đây chính là rủi ro seat-count race condition mà `code-reviewer` đã note trước đó cho `License` (xem "Trạng thái hiện tại" ở `../CLAUDE.md`, mục `License`) — `Assignment` là chỗ thực sự implement/exercise seat counting lần đầu tiên.
- Chuyển tài sản giữa nhân viên (case đặc biệt đã note ở trên: "Tài sản luân chuyển giữa phòng ban / chi nhánh") = đóng `Assignment` cũ (`returnedAt = now`) + tạo `Assignment` mới, cả hai trong 1 transaction. Không cần entity "Transfer" riêng.
- `AssetItem.status` phải đồng bộ theo trạng thái `Assignment`: tạo `Assignment` → status `assigned`; trả lại → status `in_stock`, trừ nhánh RMA/sửa chữa đã note ở "Case đặc biệt cần xử lý" (đó là case riêng, không đi qua path trả-lại-thông-thường này).
- **Chốt 2026-08-02 (trả lời 2 câu hỏi mở của `pm`):** (a) `createAssignment()` phải **chặn** nếu `AssetItem.status` hiện tại khác `in_stock` (vd không cho assign 1 item đang `repair`/`retired`) — trả lỗi 400, không chỉ dựa vào điều kiện "chưa có Assignment active nào". (b) Route "update" (trả lại/chuyển giao `Assignment`, nhận hàng `PurchaseOrder`) dùng `PATCH /api/<entity>/[id]` — tiền lệ REST-shape đầu tiên cho update trong codebase, áp dụng thống nhất cho cả `Assignment` và `PurchaseOrder`, không dùng action-based sub-route.
**Ràng buộc dữ liệu:**
- `Assignment.employeeId` bắt buộc, trỏ tới 1 `Employee` có thật.
- Đúng 1 trong `assetItemId`/`licenseId` được set (XOR) — validate Zod `.refine()`.
- Không được có > 1 `Assignment` active cho cùng 1 `AssetItem` tại 1 thời điểm (enforce ở tầng application, không phải DB constraint).
- Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400).
- `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`.
Chưa build gì cho phần này tới khi có câu trả lời cho ít nhất câu #1–#3 (câu #4–#5 có thể để mặc định hợp lý — không giới hạn số field, chưa cần search — nếu stakeholder không có ý kiến, nhưng vẫn nên hỏi trước khi `pm` chia task).
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
@@ -169,8 +138,9 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
## Việc còn dở
- Thiết kế ERD chi tiết + wireframe màn hình
- **`Vendor`, `PurchaseOrder` (gộp `Receipt`), `Assignment`, `AssetCategoryAttributeDef` (2026-08-02): đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task** — xem các mục "Vendor", "PurchaseOrder (gộp Receipt)", "Assignment" và "Cập nhật 2026-08-02 (3)" trong "Phân loại tài sản" ở trên. Bao gồm cả retrofit `License.vendor` (string) → `License.vendorId` (FK → `Vendor`, không cần backfill vì chưa có `License` thật trong DB) và thêm `purchaseOrderId` (nullable FK) vào `AssetItem`/`License`/`Request`. Thứ tự phụ thuộc gợi ý cho `pm`: `Vendor` → retrofit `License.vendorId` → `PurchaseOrder` (+ `purchaseOrderId` trên 3 entity) → `Assignment`; `AssetCategoryAttributeDef` độc lập, có thể làm song song.
- Entity còn lại: `PurchaseOrder`, `Receipt`, `Assignment` (`Employee`, `AssetItem` đã xong 2026-07-31; `License` xong 2026-08-02, xem `../CLAUDE.md`)
- Consumable (2026-08-02): phần phân loại (`AssetCategory`, `ConsumableItem`) đã có spec đầy đủ (BA), sẵn sàng cho `pm` chia task. Luồng "xin + nhận" (`ConsumableRequest` hoặc tương đương) **BỊ CHẶN** — cần stakeholder trả lời các câu hỏi mở: (1) có cần approval không, ai duyệt; (2) ai được tạo request xin (tự xin cho mình hay xin hộ được); (3) có giới hạn số lượng tối đa/lần xin không; (4) hết hàng thì chặn hẳn / cho "nợ" chờ nhập thêm / fulfill một phần; (5) trừ tồn kho lúc tạo request hay lúc fulfill; (6) restock tồn kho qua luồng nào, ai được làm; (7) có cần ghi nhận người phát hàng/ký nhận không. Không tự đoán — xem chi tiết đầy đủ trong spec BA.
- Custom fields theo Category / "Additional Options" (2026-08-02, spec BA — xem "Custom fields theo Category" ở trên): thiết kế data model (`AssetFieldDefinition`/`AssetFieldValue`, EAV, gắn vào `AssetCategory`) đã rõ, nhưng **BỊ CHẶN** cho tới khi stakeholder trả lời ít nhất 3 câu hỏi mở: (1) field có bắt buộc (`required`) được không; (2) có cho sửa/xoá field definition sau khi category đã có asset dùng không, xử lý giá trị cũ ra sao; (3) áp dụng cho `ConsumableItem` không hay chỉ `AssetItem`. Chưa giao cho `pm`.
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
- Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp.
@@ -180,4 +150,4 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
- Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây.
---
Cập nhật lần cuối: 2026-08-02 (spec `Vendor`, `PurchaseOrder` gộp `Receipt`, `Assignment`, `AssetCategoryAttributeDef` — sẵn sàng cho `pm` chia task)
Cập nhật lần cuối: 2026-08-02
@@ -1,13 +0,0 @@
-- CreateTable
CREATE TABLE "vendors" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"contact_email" TEXT NOT NULL,
"contact_phone" TEXT NOT NULL,
"address" TEXT NOT NULL,
"tax_code" TEXT,
"notes" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vendors_pkey" PRIMARY KEY ("id")
);
-22
View File
@@ -122,28 +122,6 @@ model License {
@@map("licenses")
}
// Purchasing counterparty — see docs/asset-license-management.md's "## Vendor
// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
// from (e.g. "FPT Trading"), a different concept that isn't always the same
// company. No FKs on this entity yet: it's the starting point for later
// FKs from PurchaseOrder/License (License.vendor string -> License.vendorId
// FK retrofit is noted in the doc as a separate follow-up, not done here).
// taxCode/notes are nullable — not every vendor record has a known VN tax
// code or extra notes at creation time.
model Vendor {
id String @id @default(uuid()) @db.Uuid
name String
contactEmail String @map("contact_email")
contactPhone String @map("contact_phone")
address String
taxCode String? @map("tax_code")
notes String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("vendors")
}
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
// accounts, created by hand via scripts/create-user.ts, no self-registration.
-52
View File
@@ -1,52 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { createVendor, listVendors, validateCreateVendorInput } from "@/lib/vendors";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { name, contactEmail, contactPhone, address, taxCode, notes } =
body as Record<string, unknown>;
const validation = validateCreateVendorInput({
name,
contactEmail,
contactPhone,
address,
taxCode,
notes,
});
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createVendor(validation.value);
return NextResponse.json(created, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const vendors = await listVendors();
return NextResponse.json(vendors, { status: 200 });
}
-123
View File
@@ -1,123 +0,0 @@
import { z } from "zod";
import { prisma } from "./prisma";
export interface VendorRecord {
id: string;
name: string;
contact_email: string;
contact_phone: string;
address: string;
tax_code: string | null;
notes: string | null;
created_at: string;
}
export interface CreateVendorInput {
name: string;
contactEmail: string;
contactPhone: string;
address: string;
taxCode?: string | null;
notes?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreateVendorInput }
| { ok: false; error: string };
// Mirrors src/lib/licenses.ts's nonEmptyString helper.
function nonEmptyString(message: string) {
return z.string({ required_error: message, invalid_type_error: message }).refine(
(value) => value.trim() !== "",
{ message }
);
}
const createVendorSchema = z.object({
name: nonEmptyString("name is required and must be a non-empty string"),
contactEmail: z
.string({
required_error: "contactEmail is required and must be a valid email",
invalid_type_error: "contactEmail is required and must be a valid email",
})
.email("contactEmail is required and must be a valid email"),
contactPhone: nonEmptyString("contactPhone is required and must be a non-empty string"),
address: nonEmptyString("address is required and must be a non-empty string"),
// Nullable, per docs/asset-license-management.md's "## Vendor" spec — not
// every vendor record has a known VN tax code at creation time.
taxCode: nonEmptyString("taxCode must be a non-empty string").optional().nullable(),
notes: nonEmptyString("notes must be a non-empty string").optional().nullable(),
});
/**
* Validates raw (untyped) vendor-creation input. name/contactPhone/address
* must be non-empty strings, contactEmail must be a valid email string,
* taxCode/notes are optional/nullable (per the "## Vendor" spec in
* docs/asset-license-management.md) but must be non-empty strings if
* present.
*/
export function validateCreateVendorInput(input: {
name?: unknown;
contactEmail?: unknown;
contactPhone?: unknown;
address?: unknown;
taxCode?: unknown;
notes?: unknown;
}): ValidationResult {
const result = createVendorSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
name: string;
contactEmail: string;
contactPhone: string;
address: string;
taxCode: string | null;
notes: string | null;
createdAt: Date;
}): VendorRecord {
return {
id: row.id,
name: row.name,
contact_email: row.contactEmail,
contact_phone: row.contactPhone,
address: row.address,
tax_code: row.taxCode,
notes: row.notes,
created_at: row.createdAt.toISOString(),
};
}
/**
* Creates a Vendor. No unique constraint exists on any field, no FKs
* either (see docs/asset-license-management.md's "## Vendor" spec — this
* is a standalone entity, same starting shape as Employee/License before
* they grew relations) — so unlike createAssetItem()/createEmployee()
* there's no known Prisma error code to catch/translate here.
*/
export async function createVendor(input: CreateVendorInput): Promise<VendorRecord> {
const created = await prisma.vendor.create({
data: {
name: input.name,
contactEmail: input.contactEmail,
contactPhone: input.contactPhone,
address: input.address,
taxCode: input.taxCode ?? null,
notes: input.notes ?? null,
},
});
return toRecord(created);
}
export async function listVendors(): Promise<VendorRecord[]> {
const rows = await prisma.vendor.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
-476
View File
@@ -1,476 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
import {
createVendor,
listVendors,
validateCreateVendorInput,
} from "@/lib/vendors";
import { prisma } from "@/lib/prisma";
// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts:
// - lib-level tests below exercise src/lib/vendors.ts directly (validation,
// nullable taxCode/notes, list ordering).
// - route-level tests (further down) exercise src/app/api/vendors/route.ts
// over HTTP, including the auth() gate — mirrors tests/licenses.test.ts's
// @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/vendors/route";
import { auth } from "@/auth";
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
const testSession: Session = {
user: { id: "test-user-id" },
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
};
function postRequest(body: unknown): Request {
return new Request("http://localhost/api/vendors", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
// Each test starts from an empty table so listing/order assertions are
// deterministic.
await prisma.vendor.deleteMany();
// Default to a logged-in session — the lib-level tests below don't go
// through the route at all (so this is a no-op for them), and the
// route-level tests exercise business logic, not auth gating (that's
// covered by the "auth gating" describe block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("validateCreateVendorInput", () => {
it("accepts valid input with taxCode and notes", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toMatchObject({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
}
});
it("accepts valid input with no taxCode/notes", () => {
const result = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.taxCode).toBeUndefined();
expect(result.value.notes).toBeUndefined();
}
});
it("rejects an empty name", () => {
const result = validateCreateVendorInput({
name: "",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/name/i);
}
});
it("rejects an invalid contactEmail", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "not-an-email",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactEmail/i);
}
});
it("rejects a missing contactEmail", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactEmail/i);
}
});
it("rejects an empty contactPhone", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/contactPhone/i);
}
});
it("rejects an empty address", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/address/i);
}
});
it("rejects an empty taxCode when provided", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/taxCode/i);
}
});
it("accepts a null taxCode/notes explicitly", () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: null,
notes: null,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.taxCode).toBeNull();
expect(result.value.notes).toBeNull();
}
});
});
describe("createVendor", () => {
it("creates a vendor with taxCode and notes", async () => {
const result = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createVendor(result.value);
expect(record).toMatchObject({
name: "FPT Trading",
contact_email: "[email protected]",
contact_phone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
tax_code: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(typeof record.id).toBe("string");
expect(record.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
});
it("creates a vendor with no taxCode/notes (both null)", async () => {
const result = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const record = await createVendor(result.value);
expect(record.tax_code).toBeNull();
expect(record.notes).toBeNull();
});
});
describe("listVendors", () => {
it("lists created vendors, newest first", async () => {
const first = validateCreateVendorInput({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
});
expect(first.ok).toBe(true);
if (first.ok) await createVendor(first.value);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
const second = validateCreateVendorInput({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
});
expect(second.ok).toBe(true);
if (second.ok) await createVendor(second.value);
const records = await listVendors();
expect(records).toHaveLength(2);
// newest first
expect(records[0]).toMatchObject({ name: "FPT Trading" });
expect(records[1]).toMatchObject({ name: "Synnex FPT" });
});
it("returns an empty list when there are no vendors", async () => {
const records = await listVendors();
expect(records).toEqual([]);
});
});
describe("POST /api/vendors", () => {
it("creates a vendor on valid input", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
taxCode: "0301234567",
notes: "Preferred reseller for Dell laptops",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
name: "FPT Trading",
contact_email: "[email protected]",
contact_phone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
tax_code: "0301234567",
notes: "Preferred reseller for Dell laptops",
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates a vendor with no taxCode/notes", async () => {
const res = await POST(
postRequest({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.tax_code).toBeNull();
expect(body.notes).toBeNull();
});
it("rejects an empty name", async () => {
const res = await POST(
postRequest({
name: "",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/name/i);
});
it("rejects an invalid contactEmail", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "not-an-email",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/contactEmail/i);
});
it("rejects an empty contactPhone", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/contactPhone/i);
});
it("rejects an empty address", async () => {
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/address/i);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/vendors", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
const res = await POST(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON/i);
});
it("rejects a non-object JSON body", async () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check (same behavior as /api/licenses) — use a JSON string
// primitive here to actually exercise the "not an object" branch.
const res = await POST(postRequest("not an object"));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/JSON object/i);
});
});
describe("GET /api/vendors", () => {
it("lists created vendors, newest first", async () => {
await POST(
postRequest({
name: "Synnex FPT",
contactEmail: "[email protected]",
contactPhone: "0287654321",
address: "456 Le Van Viet, Q9, HCMC",
})
);
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ name: "FPT Trading" });
expect(body[1]).toMatchObject({ name: "Synnex FPT" });
});
it("returns an empty list when there are no vendors", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});
});
describe("auth gating on /api/vendors", () => {
// Mirrors tests/licenses.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/vendors returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({
name: "FPT Trading",
contactEmail: "[email protected]",
contactPhone: "0281234567",
address: "123 Nguyen Van Linh, Q7, HCMC",
})
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.vendor.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/vendors returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});