Công cụ tùy chỉnh
Lưu ý: Kiến trúc plugin (mở rộng mã trong môi trường sandbox cho các trường hợp cấu hình không đủ) chưa được quyết định và không được đề cập ở đây. Sẽ được ghi lại riêng khi phương án được chọn.
1. Tổng quan
Phần tiêu đề “1. Tổng quan”BPO ERP loại bỏ việc phân nhánh mã theo từng tenant thông qua công cụ tùy chỉnh dựa trên cấu hình. Hành vi đặc thù của tenant — trường tùy chỉnh, quy tắc xác thực và bước quy trình — được biểu diễn dưới dạng metadata lưu trữ và được áp dụng tại runtime bởi nền tảng. Không thay đổi mã; không cần triển khai lại.
Công cụ tùy chỉnh xuất hiện trong Giai đoạn 2. Tuy nhiên, một số cam kết nền tảng phải được xây dựng trong Giai đoạn 1 — được liệt kê rõ ràng trong §6 bên dưới.
2. Tại sao quyết định Giai đoạn 2 ảnh hưởng đến Giai đoạn 1
Phần tiêu đề “2. Tại sao quyết định Giai đoạn 2 ảnh hưởng đến Giai đoạn 1”Việc cải tiến cơ sở hạ tầng tùy chỉnh vào schema production với dữ liệu tenant thực sẽ rất tốn kém. Ba quyết định phải được xác định trong Giai đoạn 1:
-
Cột
CustomDatatrên mỗi bảng entity cốt lõi. Thêm cột JSON vào bảng có hàng triệu hàng trong production đòi hỏi migration schema kéo dài. Nó phải có từ migration đầu tiên, dù không có tenant nào điền vào đến Giai đoạn 2. -
Điểm hook trước/sau khi lưu trong mỗi domain service. Engine quy tắc xác thực (Giai đoạn 2) cần một điểm gọi nhất quán, được xác định trong mỗi domain service để đưa vào đánh giá của nó. Nếu domain service Giai đoạn 1 được viết không có các hook này, Giai đoạn 2 phải sửa đổi từng service — một refactor rủi ro cao.
-
Domain event bus (dù chưa có subscriber). Workflow trigger (Giai đoạn 2) lắng nghe domain event (
PurchaseOrderSubmitted,InvoiceApproved, v.v.). Nếu domain service Giai đoạn 1 không phát các event này, Giai đoạn 2 phải thêm chúng vào sau, chạm đến mọi command handler.
Không có gì trong số này hiển thị với người dùng trong Giai đoạn 1. Chúng là hạ tầng để Giai đoạn 2 ra mắt suôn sẻ.
3. Trường tùy chỉnh
Phần tiêu đề “3. Trường tùy chỉnh”Quyết định
Phần tiêu đề “Quyết định”Cột JSON (CustomData NVARCHAR(MAX)) trên mỗi bảng entity có phạm vi tenant, được quản lý bởi bảng metadata CustomFieldDefinitions theo loại entity theo tenant.
Các phương án đã xem xét
Phần tiêu đề “Các phương án đã xem xét”| Phương án | Mô tả | Lý do bị loại |
|---|---|---|
| EAV (Entity-Attribute-Value) | Mỗi giá trị field là một hàng riêng trong bảng CustomFieldValues chung | Hiệu năng truy vấn rất kém; join qua bảng EAV chậm và phức tạp; lập index từng giá trị khó |
| Shadow tables | Bảng {Entity}CustomFields riêng cho từng loại entity | Yêu cầu thay đổi schema (DDL) cho mỗi loại entity mới — phủ nhận mục tiêu “không cần triển khai lại” |
| Cột JSON (được chọn) | CustomData NVARCHAR(MAX) trên mỗi entity; hàm JSON của SQL Server cho xác thực và lập index | Không thay đổi schema khi thêm field mới; SQL Server hỗ trợ truy vấn JSON_VALUE và index cột tính toán trên đường dẫn JSON cụ thể |
Mô hình dữ liệu
Phần tiêu đề “Mô hình dữ liệu”CustomFieldDefinitions (Định nghĩa trường tùy chỉnh)────────────────────────────────────────────────DefinitionId UNIQUEIDENTIFIER PKTenantId UNIQUEIDENTIFIER NOT NULL (RLS bắt buộc)EntityType NVARCHAR(100) NOT NULL -- 'SalesOrder', 'Product', 'Supplier', v.v.FieldKey NVARCHAR(100) NOT NULL -- khóa dùng trong blob JSONFieldLabel NVARCHAR(200) NOT NULL -- nhãn hiển thị (có thể bản địa hóa qua i18n key)DataType NVARCHAR(50) NOT NULL -- 'Text', 'Number', 'Boolean', 'Date', 'List'IsRequired BIT NOT NULLDefaultValue NVARCHAR(500) NULLListOptions NVARCHAR(MAX) NULL -- JSON array của {value, label} cho loại 'List'DisplayOrder INT NOT NULLIsActive BIT NOT NULLUNIQUE (TenantId, EntityType, FieldKey)Mỗi bảng entity có phạm vi tenant mang:
CustomData NVARCHAR(MAX) NULL-- Đối tượng JSON. Khóa phải khớp với giá trị FieldKey đang hoạt động trong CustomFieldDefinitions-- cho EntityType và TenantId tương ứng.-- Ví dụ: { "MaKhuVuc": "HCM", "MaPheDuyet": "PD-2026-0042" }Xác thực
Phần tiêu đề “Xác thực”Khi ghi, hook trước-lưu của domain service gọi dịch vụ xác thực CustomField, dịch vụ này:
- Lấy các
CustomFieldDefinitionsđang hoạt động cho loại entity và tenant - Xác thực các trường bắt buộc có mặt
- Xác thực kiểu dữ liệu và các ràng buộc cấp field
- Trả về lỗi xác thực có cấu trúc (cùng định dạng với lỗi xác thực cốt lõi)
Trường tùy chỉnh không bao giờ được truy cập trực tiếp theo khóa trong mã nền tảng — luôn qua metadata định nghĩa. Điều này đảm bảo mã nền tảng không biết về ngữ nghĩa trường đặc thù của tenant.
4. Quy tắc xác thực tùy chỉnh
Phần tiêu đề “4. Quy tắc xác thực tùy chỉnh”Quyết định
Phần tiêu đề “Quyết định”Cây điều kiện lưu dưới dạng JSON, được đánh giá bởi engine quy tắc nền tảng tại điểm hook trước-lưu của domain service.
Quy tắc biểu diễn ràng buộc nghiệp vụ trong DSL có cấu trúc — so sánh field, kiểm tra phạm vi, khớp regex, tham chiếu chéo field và tham chiếu đến trường tùy chỉnh. Không có mã thực thi; nền tảng đánh giá cây.
Mô hình dữ liệu
Phần tiêu đề “Mô hình dữ liệu”ValidationRules (Quy tắc xác thực)────────────────────────────────────────────────RuleId UNIQUEIDENTIFIER PKTenantId UNIQUEIDENTIFIER NOT NULL (RLS bắt buộc)EntityType NVARCHAR(100) NOT NULLRuleName NVARCHAR(200) NOT NULLDescription NVARCHAR(500) NULLCondition NVARCHAR(MAX) NOT NULL -- cây điều kiện JSON (xem DSL bên dưới)ErrorMessage NVARCHAR(500) NOT NULL -- hiển thị cho người dùng khi vi phạm; có thể bản địa hóaIsActive BIT NOT NULLDSL cây điều kiện
Phần tiêu đề “DSL cây điều kiện”Điều kiện được tạo từ các toán tử and, or và not, với các nút lá tham chiếu đến field entity hoặc field custom data:
{ "and": [ { "field": "TotalAmount", "op": "gt", "value": 0 }, { "or": [ { "field": "CustomData.MaPheDuyet", "op": "notEmpty" }, { "field": "TotalAmount", "op": "lte", "value": 5000000 } ] } ]}Các toán tử được hỗ trợ: eq, neq, gt, gte, lt, lte, in, notIn, isEmpty, notEmpty, matches (regex), contains.
Tham chiếu field dùng ký hiệu chấm: TotalAmount tham chiếu field cốt lõi; CustomData.MaPheDuyet tham chiếu trường tùy chỉnh.
5. Bước quy trình tùy chỉnh
Phần tiêu đề “5. Bước quy trình tùy chỉnh”Quyết định
Phần tiêu đề “Quyết định”Định nghĩa quy trình lưu dưới dạng cấu hình. Nền tảng cung cấp engine thực thi; tenant cung cấp các bước.
Một quy trình được kích hoạt bởi domain event trên loại entity cụ thể. Nó thực thi một chuỗi bước — phê duyệt, thông báo, cập nhật field — mỗi bước có thể có điều kiện bảo vệ sử dụng cùng DSL với quy tắc xác thực.
Mô hình dữ liệu
Phần tiêu đề “Mô hình dữ liệu”WorkflowDefinitions (Định nghĩa quy trình)────────────────────────────────────────────────WorkflowId UNIQUEIDENTIFIER PKTenantId UNIQUEIDENTIFIER NOT NULL (RLS bắt buộc)WorkflowName NVARCHAR(200) NOT NULLTriggerEntity NVARCHAR(100) NOT NULL -- ví dụ: 'PurchaseOrder'TriggerEvent NVARCHAR(100) NOT NULL -- ví dụ: 'Submitted', 'Approved', 'Created'IsActive BIT NOT NULLWorkflowSteps (Bước quy trình)────────────────────────────────────────────────StepId UNIQUEIDENTIFIER PKWorkflowId UNIQUEIDENTIFIER NOT NULL FK → WorkflowDefinitionsStepOrder INT NOT NULLStepType NVARCHAR(50) NOT NULL-- 'Approval' → yêu cầu vai trò được đặt tên phê duyệt trước khi tiếp tục-- 'Notification' → gửi thông báo email/trong-ứng-dụng đến vai trò hoặc người dùng-- 'FieldUpdate' → đặt giá trị field trên entityConfig NVARCHAR(MAX) NOT NULL -- JSON; cấu trúc tùy theo StepTypeCondition NVARCHAR(MAX) NULL -- cây điều kiện JSON; bước bị bỏ qua nếu đánh giá falseVí dụ Config bước
Phần tiêu đề “Ví dụ Config bước”// Bước phê duyệt{ "approverRole": "TruongPhongMuaHang", "timeoutHours": 48, "escalateTo": "GiamDoc" }
// Bước thông báo{ "recipientRole": "KeToan", "template": "hoa_don_duoc_duyet", "channels": ["email", "in-app"] }
// Bước cập nhật field{ "field": "Status", "value": "DuocDuyetThanhToan" }Domain Events (Cam kết Giai đoạn 1)
Phần tiêu đề “Domain Events (Cam kết Giai đoạn 1)”Mọi lệnh nghiệp vụ có thể kích hoạt quy trình phải phát domain event sau khi lưu thành công. Giai đoạn 1 phải định nghĩa và phát các event này dù chưa có subscriber quy trình nào đến Giai đoạn 2:
| Entity | Các event |
|---|---|
| PurchaseOrder | PurchaseOrderCreated, PurchaseOrderSubmitted, PurchaseOrderApproved, PurchaseOrderCancelled |
| SupplierInvoice | SupplierInvoiceReceived, SupplierInvoiceMatched, SupplierInvoiceApproved |
| SalesOrder | SalesOrderCreated, SalesOrderConfirmed, SalesOrderCancelled |
| CustomerInvoice | CustomerInvoiceIssued, CustomerInvoicePaid |
| GoodsReceipt | GoodsReceiptPosted |
| StockAdjustment | StockAdjustmentPosted |
6. Cam kết Giai đoạn 1
Phần tiêu đề “6. Cam kết Giai đoạn 1”Những điều sau phải xuất hiện trong Giai đoạn 1 dù engine tùy chỉnh xuất hiện trong Giai đoạn 2:
| Cam kết | Phạm vi | Lý do |
|---|---|---|
Cột CustomData NVARCHAR(MAX) NULL trên mỗi bảng entity cốt lõi có phạm vi tenant | EF Core migrations | Thêm vào schema production với dữ liệu thực yêu cầu backfill migration và khóa bảng — tránh được nếu có từ đầu |
Bảng CustomFieldDefinitions, ValidationRules, WorkflowDefinitions, WorkflowSteps | EF Core migrations | Schema phải tồn tại để engine Giai đoạn 2 ghi vào; giao diện quản trị có thể là Giai đoạn 2 |
Interface IPreSaveHook và IPostSaveHook trong domain service | Domain layer (Core.Domain) | Engine quy tắc xác thực Giai đoạn 2 cần điểm gọi nhất quán |
| Domain event bus + phát event trong mọi command handler Giai đoạn 1 | Core platform | Subscription trigger quy trình trong Giai đoạn 2 cần event để tồn tại |
Pipeline TenantId claim → ITenantContext → DbContext | Core platform | Cần cho cô lập trường tùy chỉnh và quy tắc xác thực (giống RLS; đã cam kết trong multitenancy.md) |