diff --git a/docs/asset-license-management.md b/docs/asset-license-management.md index e3ea662..905f9ad 100644 --- a/docs/asset-license-management.md +++ b/docs/asset-license-management.md @@ -34,6 +34,7 @@ Theo dõi toàn bộ vòng đời tài sản/license: **Request → Approval → | `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 | | `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 | +| `MaintenanceRecord` | **(spec BA 2026-08-04, đã chốt đủ với stakeholder — sẵn sàng cho `pm`)** khai báo sửa chữa/bảo hành cho 1 `AssetItem` cụ thể — `assetItemId` (FK, bắt buộc), `issueDescription`, `resolutionNotes` (nullable), `startDate`, `endDate` (nullable — null = đang sửa), `type` (enum: `repair`/`warranty`/`preventive`), `vendorId` (FK → `Vendor`, nullable — null = tự sửa nội bộ). Không track `cost`. Xem "MaintenanceRecord" bên dưới | ## Luồng trạng thái Request @@ -116,6 +117,29 @@ Entity mới, độc lập với `AssetModel`. - 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`. +## MaintenanceRecord (spec BA + stakeholder, chốt 2026-08-04 — sẵn sàng cho `pm`) + +Xuất phát từ yêu cầu stakeholder: "muốn thêm 1 menu maintenance — khai báo sửa chữa cho từng `AssetItem`, kể cả bảo hành." Đây là **entity mới** (không phải mở rộng field trên `AssetItem`), hiện thực hoá case đã note từ trước ở "Case đặc biệt cần xử lý" bên dưới: "Asset hỏng → gửi RMA/sửa chữa → cần state 'in repair' tách khỏi 'assigned'" — `AssetItemStatus.repair` đã có sẵn trong enum (`prisma/schema.prisma`) từ khi `AssetItem` được tạo, nhưng chưa có entity/flow nào thực sự dùng tới giá trị đó cho tới nay. `MaintenanceRecord` sẽ là mutator **đầu tiên** của `AssetItem.status` ngoài `Assignment` — `AssetItem` hiện chưa có update endpoint nào cả, nên phần đồng bộ status dưới đây cũng ngầm là spec cho phần ghi `AssetItem.status` từ `MaintenanceRecord` (không phải 1 update endpoint chung chung cho `AssetItem`). + +**Field:** + +- `id`, `assetItemId` (FK → `AssetItem`, bắt buộc), `type` (Postgres enum: `repair` | `warranty` | `preventive`), `vendorId` (FK → `Vendor`, nullable — null nghĩa là tự sửa nội bộ, không qua vendor ngoài), `issueDescription` (text, bắt buộc — mô tả vấn đề/lý do gửi sửa), `resolutionNotes` (text, nullable — set khi đóng record, mô tả kết quả xử lý), `startDate` (Date, bắt buộc — ngày gửi đi sửa/bắt đầu), `endDate` (Date, nullable — ngày nhận lại/hoàn tất; null = đang sửa), `createdAt`. +- Không track `cost` — theo đúng tiền lệ `PurchaseOrder` (đã chốt trước đó không track tiền, xem mục "PurchaseOrder"). +- Không có field `status` riêng trên `MaintenanceRecord` — suy ra "đang sửa" (`endDate IS NULL`) vs "đã xong" (`endDate` có giá trị) từ chính `endDate`, đúng tiền lệ `PurchaseOrder.receivedAt` (không track trạng thái trùng lặp). +- Chỉ áp dụng cho `AssetItem` (hardware) — `License` không có khái niệm sửa chữa vật lý, nên **không** cần cơ chế XOR như `Assignment` (`assetItemId` là target duy nhất, luôn bắt buộc). +- `vendorId` tái dùng entity `Vendor` hiện có (nơi mua hàng) — chấp nhận gộp chung khái niệm "đối tác ngoài" cho cả mua hàng lẫn sửa chữa, không tạo bảng riêng. +- 1 `AssetItem` chỉ có tối đa 1 `MaintenanceRecord` **đang mở** (`endDate IS NULL`) tại 1 thời điểm — không thể gửi cùng lúc 2 nơi sửa. Đủ hiển nhiên về mặt vật lý (1 item vật lý không thể ở 2 nơi sửa cùng lúc) để chốt thẳng, không cần hỏi — mirror invariant "tối đa 1 `Assignment` active" đã có. +- `endDate` (nếu có) phải `>= startDate` — validate ở tầng Zod ngay từ đầu, tránh lặp lại gap đã note ở "Việc còn dở" của `../CLAUDE.md` (`Assignment.returnedAt` hiện chưa validate `>= assignedAt`). +- Không track "người tạo/ghi nhận" qua `User` — không có tiền lệ nào trong codebase hiện tại gắn business entity với `User` (chỉ dùng `User` cho login). Nếu sau này cần biết "ai gửi đi sửa", đó là field bổ sung riêng, không phải phần MVP này. + +**Đồng bộ `AssetItem.status`:** + +- **Mở** `MaintenanceRecord` (`POST`, chưa có `endDate`): tự động chuyển `AssetItem.status` sang `repair` trong cùng transaction (mirror cách `Assignment` đồng bộ `AssetItem.status`/`License.seatsUsed`). +- Nếu `AssetItem` đó đang có 1 `Assignment` **active** (`returnedAt IS NULL`) → **chặn tạo** `MaintenanceRecord`, trả `400` — bắt buộc phải return `Assignment` trước, giống cách `createAssignment()` hiện chặn tạo mới nếu `AssetItem.status` khác `in_stock`. +- Nếu `AssetItem` đang ở status `retired` sẵn → **chặn tạo** `MaintenanceRecord`, trả `400` (item đã thanh lý thì gửi sửa không còn ý nghĩa). +- **Đóng** `MaintenanceRecord` (set `endDate` + `resolutionNotes` qua `PATCH /api/maintenance-records/[id]`, cùng REST-shape tiền lệ đã chốt cho `Assignment`/`PurchaseOrder` — "chốt 2026-08-02 (b)" ở mục Assignment): body nhận thêm `targetStatus` bắt buộc (vd `in_stock` nếu sửa xong, `retired` nếu sửa không thành công) — **không** tự động mặc định về `in_stock`, vì sửa không phải lúc nào cũng thành công. +- Các field khác ngoài hành động "đóng" record không có update endpoint riêng — set 1 lần lúc tạo, không sửa lại sau, theo đúng pattern hiện có (vd `PurchaseOrder.orderDate` không có update endpoint). + ## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc) Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light). @@ -141,7 +165,7 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT - Nhân viên nghỉ việc → checklist thu hồi asset + license (offboarding) - License seat-based dùng chung (vd Adobe CC theo pool chung của phòng ban) - Tài sản luân chuyển giữa phòng ban / chi nhánh (không phải "delivery" mới, mà là re-assign) -- Asset hỏng → gửi RMA/sửa chữa → cần state "in repair" tách khỏi "assigned" +- Asset hỏng → gửi RMA/sửa chữa → cần state "in repair" tách khỏi "assigned" — **giải quyết bởi `MaintenanceRecord`, spec chốt 2026-08-04, xem mục "MaintenanceRecord" bên dưới, sẵn sàng cho `pm`** ## Quyết định kiến trúc @@ -172,6 +196,7 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT - **`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. - 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. - 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 +- **`MaintenanceRecord` (spec BA + stakeholder, chốt 2026-08-04): đã có spec đầy đủ, sẵn sàng cho `pm` chia task** — xem mục "MaintenanceRecord" phía trên. Field cơ bản + `type` (`repair`/`warranty`/`preventive`) + `vendorId` (tái dùng `Vendor`, nullable) đã chốt; đồng bộ `AssetItem.status` đã chốt rõ (auto → `repair` lúc mở, chặn nếu có `Assignment` active hoặc đã `retired`, `targetStatus` bắt buộc lúc đóng); không track `cost`. - (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 +205,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-04 (spec `MaintenanceRecord` chốt đủ với stakeholder — sẵn sàng cho `pm`, xem "Việc còn dở")