docs: chốt spec MaintenanceRecord (BA + stakeholder 2026-08-04)

Entity mới track sửa chữa/bảo hành cho AssetItem, hiện thực hoá case
"in repair" đã note từ trước. Field, type enum, đồng bộ AssetItem.status,
và quyết định không track cost đều đã chốt với stakeholder — sẵn sàng
giao pm chia task.
This commit is contained in:
toanhoang
2026-08-04 04:18:47 -04:00
parent e1ad9da72d
commit d557694f77
+27 -2
View File
@@ -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 | | `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 | | `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 | | `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 ## 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). - 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`. - `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) ## 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). 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) - 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) - 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) - 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 ## 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. - **`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. - 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 - 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 - (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. - 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. - 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ở")