Đơn giản hoá form Tạo thiết bị + Hoàn thiện Device Profile Catalog

Date: 2026-07-05
Status: Approved (conversation-level), pending file review gate

1) Goal

Đơn giản hoá màn “Thêm thiết bị mới” (Thêm thiết bị mới) ở trang quản lý thiết bị của tổ chức (/devices), theo yêu cầu:

  • Form chỉ còn Loại thiết bị là input chính người dùng chọn.
  • Tên thiết bị tự động sinh bằng cách ghép Loại thiết bị + số tự tăng.
  • Mã serial không còn là input — hệ thống tự sinh.
  • Mô hình không xoá hẳn (vẫn có giá trị tham khảo/quản lý tài sản) nhưng chuyển thành lựa chọn từ danh mục có thể cấu hình (vì model do chính nền tảng/nhà sản xuất kiểm soát), và không bắt buộc.

Device là một entity dùng chung cho nhiều luồng (tạo mới của tổ chức, chỉnh sửa, và System Device Inventory của Super Admin), thay đổi phải áp dụng nhất quán cho cả ba nơi, và hạ tầng “danh mục Mô hình” (Device Profile) hiện có nhưng làm dở phải được hoàn thiện thay vì chắp vá thêm một cơ chế song song.

2) Current State

  • Form Create ở frontend/src/pages/devices/DeviceList.tsx (Drawer, dòng ~437–479) có 4 field: Tên thiết bị (Input, bắt buộc), Mã serial (Input, bắt buộc), Loại thiết bị (Select, bắt buộc), Mô hình (Input, bắt buộc).
  • Backend CreateDeviceDto (backend/src/devices/dto/create-device.dto.ts): serialNumber bắt buộc + unique ở DB, name bắt buộc, type bắt buộc enum, model optional.
  • serialNumber không mang ý nghĩa nghiệp vụ cho người dùng — nó là khoá kỹ thuật nội bộ: dùng làm name khi provision thiết bị lên ThingsBoard, và là khoá join để route telemetry ngược về đúng thiết bị (telemetry.service.ts, so khớp serialNumber === dto.deviceName). Firmware không biết đến khái niệm này.
  • Đã có sẵn cơ chế tự sinh name/serialNumber theo mẫu <Prefix>-<sequence> (backend/src/devices/device-identity.util.ts, DEVICE_IDENTITY_RULES cho sensor/gateway/inverter/feeder) nhưng scope global, hiện chỉ dùng cho luồng System Device Inventory (system-device-inventory.service.ts, endpoint GET /system/devices/generate-identity).
  • model là free-text, không có logic nghiệp vụ nào phụ thuộc vào giá trị của nó ở backend, nhưng được hiển thị ở nhiều nơi: DeviceList.tsx (bảng + form), DeviceDetail.tsx, PondDetail.tsx, DeviceDrawer.tsx (trang Tổ chức), SystemDeviceInventoryPage.tsx.
  • Đã tồn tại một catalog “Device Profile” làm dở dang:
    • Entity DeviceProfile (backend/src/devices/entities/device-profile.entity.ts) có code, name, deviceTypeId, manufacturer, model, firmwareFamily, supportedFunctions, isActive.
    • Device entity đã có sẵn cột device_profile_id (FK tới DeviceProfile) — hạ tầng liên kết đã có, chỉ là chưa được dùng ở luồng org-facing.
    • API hiện chỉ có GET /device-profilesPOST /device-profiles, quyền giới hạn device:read:system/device:create:system — org user không gọi được.
    • Trang admin frontend/src/pages/system-devices/DeviceProfilesPage.tsx chỉ liệt kê (read-only), nút “Tạo profile” chưa gắn xử lý.
    • Bảng device_types được seed với gateway, sensor, feeder, pump_controller — lệch với 4 giá trị Device.type đang dùng thực tế (sensor, gateway, inverter, feeder). pump_controller không được tham chiếu ở bất kỳ đâu khác ngoài migration seed của nó.
    • SystemDeviceInventoryPage.tsx đã dùng useDeviceProfiles() để chọn deviceProfileId, nhưng đồng thời vẫn có một ô “Mô hình” nhập tay tự do riêng biệt — hai khái niệm trùng lặp cùng tồn tại.

3) Options Considered

Option A: Chỉ sửa form Create của tổ chức, giữ nguyên mọi nơi khác

Pros: Phạm vi nhỏ, nhanh.
Cons: Tạo ra 2 hành vi khác nhau cho cùng field trên cùng entity (Create tự sinh serial/chọn model qua danh mục, Edit vẫn cho gõ tay serial → có thể phá unique constraint và khoá join telemetry; model ở Edit và ở form admin vẫn là text tự do trong khi Create dùng danh mục → dữ liệu không nhất quán, không đúng tinh thần “config được” mà người dùng yêu cầu).

Option B: Xây catalog Model mới, độc lập với DeviceProfile có sẵn

Pros: Không phải dọn dẹp taxonomy cũ (pump_controller), không phải mở quyền đọc cho endpoint hiện tại.
Cons: Tạo ra catalog thứ hai song song với DeviceProfile vốn đã có đúng cấu trúc cần thiết (đã có deviceTypeId, model, manufacturer, isActive, và FK device_profile_id sẵn trên Device) — lãng phí, và System Device Inventory vẫn tồn đọng một danh mục nửa vời cần dọn.

Option C (chọn): Hoàn thiện DeviceProfile thành danh mục dùng chung, đồng bộ cả 3 luồng

Hoàn thiện CRUD + quyền đọc + UI quản lý cho DeviceProfile hiện có, đối soát lại device_types cho khớp 4 loại thiết bị đang dùng, và dùng deviceProfileId làm nguồn “Mô hình” thống nhất ở cả Create, Edit (tổ chức) và System Device Inventory (Super Admin).

Pros: Một nguồn sự thật duy nhất cho “Mô hình”, không có 2 cơ chế song song, giải quyết dứt điểm phần dở dang có sẵn thay vì né tránh nó.
Cons: Khối lượng việc lớn hơn: 1 migration dữ liệu, API CRUD đầy đủ, mở quyền đọc có kiểm soát, UI quản lý catalog mới, và sửa đồng thời 3 form.

Người dùng đã xác nhận rõ ưu tiên “bản ship hoàn chỉnh, chuẩn chỉ” hơn là giới hạn phạm vi — chọn Option C.

4) Selected Design

4.1 Sinh Tên thiết bị tự động (Create, org-facing)

  • Format: ${TypeLabel}-${sequence} (ví dụ Sensor-3), dùng namePrefix có sẵn trong DEVICE_IDENTITY_RULES (Sensor/Gateway/Inverter/Feeder) làm TypeLabel.
  • sequence được tính theo tổ chức hiện tại + loại thiết bị: quét các name hiện có của tổ chức đó khớp pattern ${TypeLabel}-\d+, lấy số lớn nhất + 1 (không chỉ đếm số lượng, để không bị lặp số khi thiết bị cũ đã bị xoá).
  • sequence hiển thị dạng số nguyên thường, không zero-pad (Sensor-3, không phải Sensor-003) — khác với serialNumber (mục 4.2) vốn giữ nguyên format zero-pad 3 chữ số đã có sẵn (SNS-003), vì đây là hai cơ chế sinh số độc lập phục vụ hai mục đích khác nhau.
  • Đây là scope generation mới, tách biệt với cơ chế global sẵn có trong device-identity.util.ts (dùng cho System Device Inventory) — không sửa cơ chế cũ, chỉ thêm hàm mới scoped theo organizationId + type.
  • Form hiển thị tên sẽ được tạo dưới dạng preview read-only (không cho sửa tay), cập nhật lại mỗi khi đổi Loại thiết bị, theo đúng UX pattern applyGeneratedIdentity đã có ở SystemDeviceInventoryPage.tsx.

4.2 Sinh Mã serial tự động (Create, org-facing)

  • Ẩn hoàn toàn khỏi form Create. Backend tự sinh bằng cách tái sử dụng nguyên trạng buildGeneratedDeviceIdentity/getNextGeneratedSequence (device-identity.util.ts), giữ nguyên scope global toàn hệ thống (không theo tổ chức) vì vai trò của nó là khoá kỹ thuật join telemetry, cần unique tuyệt đối bất kể tổ chức nào tạo.
  • DevicesService.create() bỏ qua mọi giá trị name/serialNumber do client gửi lên (nếu có) — luôn tự sinh phía server. Client chỉ gửi type + deviceProfileId (optional).
  • Race condition: bọc việc sinh sequence + insert trong transaction, retry có giới hạn (ví dụ tối đa 5 lần) khi vi phạm unique constraint, để tránh 2 request đồng thời sinh trùng serialNumber hoặc trùng name trong cùng tổ chức+loại.

4.3 Hoàn thiện Device Profile Catalog

Đối soát device_types: migration data thay pump_controller bằng inverter để khớp đúng 4 giá trị Device.type đang dùng (sensor, gateway, inverter, feeder). Đã xác nhận pump_controller không được tham chiếu ở đâu khác — an toàn.

API bổ sung (backend/src/devices/controllers/device-profiles.controller.ts):

  • PATCH /device-profiles/:id — cập nhật name, manufacturer, model, firmwareFamily, supportedFunctions, isActive. Quyền device:update:system.
  • DELETE /device-profiles/:id — soft-delete (set isActive = false), không xoá cứng vì có thể đã có Device.device_profile_id tham chiếu. Quyền device:delete:system.
  • Mở quyền đọc cho org user: GET /device-profiles chấp nhận thêm device:read:own bên cạnh device:read:system (scope-aware, theo đúng mô hình RBAC resource:action:scope đã dùng trong hệ thống); khi gọi ở scope own, chỉ trả về profile có isActive = true. Hỗ trợ filter theo deviceType qua query param.

UI quản lý (frontend/src/pages/system-devices/DeviceProfilesPage.tsx): hoàn thiện thật — gắn nút “Tạo profile” vào form/modal tạo mới (dùng hook useCreateDeviceProfile đã có sẵn nhưng chưa được UI nào gọi), thêm action Sửa (useUpdateDeviceProfile mới) và Xoá/Vô hiệu hoá (useDeleteDeviceProfile mới, gọi endpoint soft-delete).

4.4 “Mô hình” trở thành nguồn sự thật duy nhất qua deviceProfileId

  • Form Create/Edit (DeviceList.tsx) và form System Device Inventory (SystemDeviceInventoryPage.tsx) đều đổi field “Mô hình” thành Select chọn deviceProfileId, optional, lọc theo Loại thiết bị đã chọn, dùng chung một hook fetch danh mục (useDeviceProfiles mở rộng nhận filter deviceType).
  • Xoá ô “Mô hình” nhập tay tự do đang trùng lặp ở SystemDeviceInventoryPage.tsx.
  • Hiển thị “Mô hình” ở mọi nơi đọc (DeviceDetail.tsx, bảng DeviceList.tsx, PondDetail.tsx) đổi sang ưu tiên device.deviceProfile?.model ?? device.model ?? '—' — vẫn hiển thị được giá trị model tự do cũ của thiết bị legacy (tạo trước khi có thay đổi này), không cần data migration cho các bản ghi cũ.
  • Cột model (free-text) trên Device không bị xoá khỏi DB (theo đúng nguyên tắc không xoá chỉ vì “có trong DB” khi vẫn còn giá trị tham khảo cho dữ liệu cũ), nhưng không còn là input trực tiếp ở bất kỳ form nào từ nay — chỉ còn đọc để backward-compat hiển thị.

4.5 Đồng bộ form Edit (org-facing, DeviceList.tsx)

  • Tên thiết bị: vẫn cho sửa tay bình thường (đổi tên gợi nhớ là nhu cầu hợp lệ, khác bản chất với serial).
  • Mã serial: chuyển sang hiển thị read-only (không còn là input có thể sửa) — chỉ xem, tránh người dùng tự gõ đè phá vỡ unique constraint/khoá join telemetry.
  • Loại thiết bị: giữ nguyên Select như hiện tại.
  • Mô hình: đổi thành Select danh mục như mô tả ở 4.4.

4.6 DTO changes

  • CreateDeviceDto (org-facing): bỏ name, serialNumber, model (free text) khỏi input hợp lệ; chỉ nhận type (bắt buộc) + deviceProfileId (optional, uuid) + các field optional khác vốn đã có (pondId, installationDate, warrantyMonths, ownershipType, capabilities).
  • UpdateDeviceDto: nhận name (optional, cho phép đổi tên tay), deviceProfileId (optional). Không nhận serialNumber (không cho sửa qua API). Giữ khả năng đọc model cũ nhưng không phải input mới.
  • DTO cho System Device Inventory (create-system-device.dto.ts) đồng bộ tương tự: bỏ ô model tự do, chỉ dùng deviceProfileId.

5) File-Level Scope

  • Backend:
    • backend/src/devices/device-identity.util.ts: thêm hàm sinh sequence theo organizationId + type (không sửa hàm cũ).
    • backend/src/devices/devices.service.ts: sửa create() tự sinh name/serialNumber, bỏ qua input client, thêm retry khi conflict; sửa update() không cho sửa serialNumber.
    • backend/src/devices/dto/create-device.dto.ts, update-device.dto.ts: bỏ field theo 4.6.
    • backend/src/devices/dto/create-system-device.dto.ts, system-device-inventory.service.ts: bỏ ô model tự do, dùng deviceProfileId.
    • backend/src/devices/controllers/device-profiles.controller.ts, device-profile.service.ts: thêm update, soft-delete, mở quyền đọc own scope, filter theo deviceType.
    • Migration mới: data-fix device_types (pump_controllerinverter).
    • backend/src/telemetry/telemetry.service.ts: không đổi logic, chỉ xác nhận vẫn hoạt động đúng với serial tự sinh (không có thay đổi cần thiết, chỉ cần test lại).
  • Frontend:
    • frontend/src/pages/devices/DeviceList.tsx: sửa Drawer Create (bỏ Tên/Serial input, thêm preview tên, đổi Mô hình thành Select) và Drawer Edit (serial read-only, Mô hình Select).
    • frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx: bỏ ô Mô hình tự do, dùng chung Select danh mục.
    • frontend/src/pages/system-devices/DeviceProfilesPage.tsx: thêm modal/form tạo, sửa, xoá.
    • frontend/src/hooks/useSystemDeviceInventory.ts hoặc hook riêng cho device profiles: thêm useUpdateDeviceProfile, useDeleteDeviceProfile, mở rộng useDeviceProfiles(filter?: { deviceType }).
    • frontend/src/pages/devices/DeviceDetail.tsx, frontend/src/pages/farm/PondDetail.tsx: đổi cách hiển thị model theo 4.4.
    • frontend/src/types/device.types.ts, frontend/src/schemas/device.schema.ts: cập nhật type/schema khớp DTO mới.

6) Acceptance Criteria

  1. Form “Thêm thiết bị mới” (tổ chức) chỉ yêu cầu chọn Loại thiết bị; Mô hình optional dạng Select; không còn input Tên thiết bị/Mã serial.
  2. Thiết bị mới tạo có name dạng ${TypeLabel}-${sequence} đúng theo tổ chức + loại thiết bị, không trùng với thiết bị hiện có cùng tổ chức+loại.
  3. Thiết bị mới tạo có serialNumber tự sinh, unique toàn hệ thống, theo đúng pattern hiện có (SNS-/GTW-/INV-/FDR-).
  4. Form Edit (tổ chức): Mã serial chỉ đọc, Mô hình là Select cùng danh mục với Create.
  5. Form System Device Inventory: không còn ô “Mô hình” tự do, chỉ còn Select danh mục dùng chung.
  6. GET /device-profiles gọi được bởi org user (scope own), chỉ trả profile isActive = true, filter được theo deviceType.
  7. Có thể tạo, sửa, và vô hiệu hoá (soft-delete) một Device Profile qua UI trang /system/device-profiles.
  8. device_types không còn pump_controller, có đủ 4 loại khớp với Device.type.
  9. Thiết bị cũ (tạo trước thay đổi này) vẫn hiển thị đúng giá trị model cũ ở mọi trang đọc, không bị mất dữ liệu.
  10. Telemetry route đúng thiết bị khi serialNumber là giá trị tự sinh (không có regression).

7) Out of Scope

  • Cho phép org user tự tạo/sửa Device Profile — chỉ Super Admin quản lý catalog (đúng tinh thần “nhà mình sản xuất thì phải config được” — config tập trung).
  • Data migration gán deviceProfileId ngược cho thiết bị cũ dựa trên giá trị model free-text hiện có (không tự động suy đoán match).
  • Đổi cơ chế authentication/access token của thiết bị.
  • Thay đổi luồng assign/unassign organization, inventory lifecycle của System Device Inventory (giữ nguyên spec đã duyệt trước đó).

8) Risks and Mitigations

  • Risk: Data-fix device_types chạy migration trên môi trường đã có dữ liệu thật dùng pump_controller.
    Mitigation: Đã xác nhận bằng grep toàn repo không có tham chiếu nào ngoài migration seed; kiểm tra thêm ở DB thực tế trước khi chạy migration ở production.
  • Risk: Mở quyền đọc GET /device-profiles cho org user có thể lộ thông tin không cần thiết (manufacturer, firmwareFamily).
    Mitigation: Các trường này không nhạy cảm (đã là thông tin công khai về sản phẩm), chấp nhận trả về nguyên vẹn cho scope own, chỉ lọc theo isActive.
  • Risk: Race condition khi 2 request tạo thiết bị đồng thời trong cùng tổ chức+loại sinh trùng sequence.
    Mitigation: Retry có giới hạn trong transaction khi vi phạm unique constraint.
  • Risk: Xoá ô nhập Mô hình tự do ở System Device Inventory làm Super Admin không thể ghi model cho thiết bị chưa có trong catalog.
    Mitigation: Chấp nhận đánh đổi này theo đúng yêu cầu “phải config được” — Super Admin luôn có quyền tạo profile mới trước khi gán, không mất khả năng, chỉ đổi luồng thao tác.