# Schema Diagram — Luồng DB SOLUTION_ERP > ERD core mermaid dưới = **36 table** (Migration 11 — Tier 3 + 4-bảng overhaul + 4 master catalogs + Role/User VN). Module mở rộng Mig 12-42 → migration table §7 + section §11-15. **Tổng schema hiện tại: 91 table (Mig 42, 2026-05-30).** Mermaid render ở VS Code / GitHub / Gitea. ## 1. Full ERD ```mermaid erDiagram Users ||--o{ UserRoles : "has" Roles ||--o{ UserRoles : "assigned to" Users ||--o{ UserClaims : "has" Users ||--o{ UserLogins : "has" Users ||--o{ UserTokens : "has" Roles ||--o{ RoleClaims : "has" Roles ||--o{ Permissions : "grants" MenuItems ||--o{ Permissions : "controlled by" MenuItems ||--o{ MenuItems : "parent-of" Suppliers ||--o{ Contracts : "party-B" Projects ||--o{ Contracts : "belongs-to" Departments ||--o{ Contracts : "drafted-in" Users ||--o{ Contracts : "drafter" ContractTemplates ||--o{ Contracts : "uses" WorkflowDefinitions ||--o{ Contracts : "pinned-policy" Contracts ||--o{ ContractApprovals : "history" Contracts ||--o{ ContractComments : "thread" Contracts ||--o{ ContractAttachments : "files" Users ||--o{ ContractApprovals : "approved-by" Users ||--o{ ContractComments : "author" WorkflowDefinitions ||--o{ WorkflowSteps : "has" WorkflowSteps ||--o{ WorkflowStepApprovers : "allowed-by" Users ||--o{ Notifications : "recipient" Users { uniqueidentifier Id PK nvarchar FullName "200" nvarchar Email "UK" nvarchar PasswordHash nvarchar RefreshToken "512" datetime2 RefreshTokenExpiresAt bit IsActive datetime2 CreatedAt } Roles { uniqueidentifier Id PK nvarchar Name "UK" nvarchar Description "500" datetime2 CreatedAt } MenuItems { nvarchar Key PK "50" nvarchar Label "200" nvarchar ParentKey FK "NULL if root" int Order nvarchar Icon "50" } Permissions { uniqueidentifier Id PK uniqueidentifier RoleId FK nvarchar MenuKey FK "50" bit CanRead bit CanCreate bit CanUpdate bit CanDelete } Suppliers { uniqueidentifier Id PK nvarchar Code "UK 50" nvarchar Name "200" int Type "NCC/NTP/TD/DVDV/CDT" nvarchar TaxCode "20" nvarchar Phone "30" nvarchar Email "100" nvarchar Address "500" bit IsDeleted } Projects { uniqueidentifier Id PK nvarchar Code "UK 50" nvarchar Name "200" date StartDate date EndDate uniqueidentifier ManagerUserId FK decimal BudgetTotal "18,2" } Departments { uniqueidentifier Id PK nvarchar Code "UK 50" nvarchar Name "200" uniqueidentifier ManagerUserId FK } ContractTemplates { uniqueidentifier Id PK nvarchar FormCode "UK 50" nvarchar Name "200" int ContractType "nullable" nvarchar FileName "255" nvarchar StoragePath "500" nvarchar Format "docx/xlsx" nvarchar FieldSpec "max JSON" bit IsActive } ContractClauses { uniqueidentifier Id PK nvarchar Code "UK 50" nvarchar Name "200" nvarchar Content "max rich text" int Version bit IsActive } Contracts { uniqueidentifier Id PK nvarchar MaHopDong "UK filtered 100" nvarchar TenHopDong "500" int Type "ContractType enum" int Phase "9 state" uniqueidentifier SupplierId FK uniqueidentifier ProjectId FK uniqueidentifier DepartmentId FK uniqueidentifier DrafterUserId FK uniqueidentifier TemplateId FK uniqueidentifier WorkflowDefinitionId FK "pinned policy, nullable" decimal GiaTri "18,2" bit BypassProcurementAndCCM datetime2 SlaDeadline bit SlaWarningSent nvarchar DraftData "max JSON" bit IsDeleted } ContractApprovals { uniqueidentifier Id PK uniqueidentifier ContractId FK int FromPhase int ToPhase uniqueidentifier ApproverUserId FK "NULL=system" int Decision "Pending/Approve/Reject/AutoApprove" nvarchar Comment "1000" datetime2 ApprovedAt } ContractComments { uniqueidentifier Id PK uniqueidentifier ContractId FK uniqueidentifier UserId FK int Phase "phase luc comment" nvarchar Content "2000" datetime2 CreatedAt } ContractAttachments { uniqueidentifier Id PK uniqueidentifier ContractId FK nvarchar FileName "255" nvarchar StoragePath "500" bigint FileSize nvarchar ContentType "100" int Purpose "DraftExport/ScannedSigned/SealedCopy" nvarchar Note "500" } ContractCodeSequences { nvarchar Prefix PK "200" int LastSeq datetime2 UpdatedAt } Notifications { uniqueidentifier Id PK uniqueidentifier RecipientUserId FK int Type "ContractTransition/CommentAdded/SlaExpired/..." nvarchar Title "200" nvarchar Body "2000" nvarchar Link "500" bit IsRead datetime2 ReadAt datetime2 CreatedAt } WorkflowTypeAssignments { uniqueidentifier Id PK int ContractType "UK" nvarchar PolicyName "50 Standard/SkipCcm" datetime2 UpdatedAt uniqueidentifier UpdatedBy FK } WorkflowDefinitions { uniqueidentifier Id PK nvarchar Code "100 QT-MB / QT-TP / ..." int Version "auto-increment per Code" bit IsActive "chi 1 active per ContractType" int ContractType nvarchar Name "200" nvarchar Description "500" datetime2 CreatedAt uniqueidentifier CreatedBy FK } WorkflowSteps { uniqueidentifier Id PK uniqueidentifier WorkflowDefinitionId FK int Order int Phase "target ContractPhase int" nvarchar Name "200" int SlaDays } WorkflowStepApprovers { uniqueidentifier Id PK uniqueidentifier WorkflowStepId FK int Kind "1=Role, 2=User" nvarchar AssignmentValue "200 RoleName or UserId Guid string" } ``` ## 2. Luồng dữ liệu chính (data flow diagram) ```mermaid flowchart TB subgraph IDENTITY ["🔐 Identity domain (seed lần đầu)"] U[Users] -->|N-M| R[Roles] R --> P[Permissions] P --> MI[MenuItems] end subgraph MASTER ["📋 Master data (admin CRUD + seed demo)"] S[Suppliers] PR[Projects] DE[Departments] end subgraph FORMS ["📄 Form templates (admin upload)"] CT[ContractTemplates] CC[ContractClauses] end subgraph WORKFLOW ["⚙️ Versioned workflow (admin designer)"] WD[WorkflowDefinitions] WS[WorkflowSteps] WSA[WorkflowStepApprovers] WTA[WorkflowTypeAssignments legacy override] WD --> WS WS --> WSA end subgraph CONTRACT ["📝 Contract workflow"] C[Contracts] CA[ContractApprovals] CCM[ContractComments] CAT[ContractAttachments] CCS[ContractCodeSequences] end subgraph NOTIFY ["🔔 Notification module"] N[Notifications] end U -.Drafter.-> C S --> C PR --> C DE --> C CT --> C WD -.pinned at create.-> C C --> CA C --> CCM C --> CAT C -.gen when DangDongDau.-> CCS C -.transition event.-> N CCM -.comment added.-> N U -.recipient.-> N ``` ## 3. Vòng đời 1 HĐ — data changes (với versioned workflow) ```mermaid flowchart LR Create[POST /contracts type=5] Create --> PickWD["SELECT TOP 1 WorkflowDefinition
WHERE ContractType=5 AND IsActive=1
→ Id=wf-v02"] PickWD --> C1["Contracts INSERT
Phase=2, SLA=+7d, WorkflowDefinitionId=wf-v02"] Transition1[Transition 2→3] Transition1 --> LoadPolicy["Load wf-v02.Steps.Approvers
WorkflowPolicyRegistry.FromDefinition(def)"] LoadPolicy --> Guard["Check allowed roles for
(from=2, to=3)"] Guard --> C2["UPDATE Phase=3
INSERT ContractApprovals
INSERT Notifications bulk"] Comment[POST /comments] Comment --> C3["INSERT ContractComments
INSERT Notifications"] NewVersion[Admin creates QT-MB-v03] NewVersion --> NV1["INSERT WorkflowDefinition v03 IsActive=1
UPDATE v02 SET IsActive=0 (atomic)"] NV1 -.->|HĐ cũ không ảnh hưởng| C1 Transition3[Transition 7→8 BOD ký] Transition3 --> CG["ContractCodeGenerator SERIALIZABLE
UPSERT ContractCodeSequences"] CG --> C5["UPDATE Contract
SET MaHopDong, Phase=8
INSERT Notifications"] ``` ## 4. Index strategy | Table | Index | Purpose | |---|---|---| | Contracts | `UX_Contracts_MaHopDong` filtered `WHERE [MaHopDong] IS NOT NULL` | Unique mã HĐ | | Contracts | `IX_Contracts_Phase_IsDeleted` | Inbox query theo phase | | Contracts | `IX_Contracts_SupplierId` | Filter HĐ theo NCC | | Contracts | `IX_Contracts_ProjectId` | Filter HĐ theo dự án | | Contracts | `IX_Contracts_SlaDeadline` | SLA expiry job query | | Contracts | `IX_Contracts_WorkflowDefinitionId` | Pinned policy lookup | | ContractApprovals | `IX_ContractApprovals_ContractId_ApprovedAt` | Timeline query theo HĐ | | ContractComments | `IX_ContractComments_ContractId_CreatedAt` | Thread load | | ContractAttachments | `IX_ContractAttachments_ContractId` | Attachments load | | Suppliers/Projects/Departments | `UX_{Table}_Code` | Unique business code | | Permissions | `UX_Permissions_RoleId_MenuKey` | 1 row / role / menu | | MenuItems | `IX_MenuItems_ParentKey` | Tree query | | Notifications | `IX_Notifications_RecipientUserId_IsRead_CreatedAt` | Bell badge unread count + list | | WorkflowDefinitions | `UX_WorkflowDefinitions_Code_Version` | Unique version per code | | WorkflowDefinitions | `IX_WorkflowDefinitions_ContractType_IsActive` | Active policy lookup | | WorkflowSteps | `IX_WorkflowSteps_WorkflowDefinitionId_Order` | Load steps ordered | | WorkflowStepApprovers | `IX_WorkflowStepApprovers_WorkflowStepId` | Approver load | | WorkflowTypeAssignments | `UX_WorkflowTypeAssignments_ContractType` | 1 override per type (legacy) | Chi tiết + cheatsheet SQL: [`database-guide.md`](database-guide.md). ## 5. Relationship cardinality | Parent → Child | Cardinality | OnDelete | Ghi chú | |---|---|---|---| | Contract → ContractApproval | 1 - N | Cascade | Xóa HĐ → xóa lịch sử (nhưng mức delete bị chặn sau DangInKy) | | Contract → ContractComment | 1 - N | Cascade | — | | Contract → ContractAttachment | 1 - N | Cascade | File vật lý vẫn còn trong `wwwroot/uploads/`, cleanup riêng | | Supplier → Contract | 1 - N | Restrict | Không xóa Supplier nếu còn HĐ tham chiếu | | Project → Contract | 1 - N | Restrict | Tương tự | | **WorkflowDefinition → Contract** | 1 - N | **Restrict** | **KHÔNG cascade** → HĐ cũ pin version cũ không bị xóa khi admin archive | | Role → Permission | 1 - N | Cascade | Xóa role → clear permissions | | MenuItem → Permission | 1 - N | Cascade | — | | MenuItem → MenuItem (self-ref) | 1 - N | Restrict | Parent-child menu | | **WorkflowDefinition → WorkflowStep** | 1 - N | Cascade | Delete def → remove steps (chỉ khi no Contract tham chiếu) | | **WorkflowStep → WorkflowStepApprover** | 1 - N | Cascade | — | | **User → Notification (RecipientUserId)** | 1 - N | Cascade | — | ## 6. Soft delete behavior Mọi entity extend `AuditableEntity`: - Delete qua `db.X.Remove(entity)` → `AuditingInterceptor` chuyển `EntityState.Deleted` → `Modified`, set `IsDeleted=true, DeletedAt, DeletedBy` - Query filter `HasQueryFilter(x => !x.IsDeleted)` tự động filter ra - Để query bao gồm soft-deleted: `.IgnoreQueryFilters()` Entity list áp dụng: - Supplier, Project, Department - Contract - ContractTemplate, ContractClause - WorkflowDefinition (admin archive = `IsActive=false`, xóa logic chỉ khi muốn scrub) KHÔNG soft delete (cascade hoặc keep): - ContractApproval, ContractComment, ContractAttachment — cascade khi Contract xóa - Permission, MenuItem — cascade khi Role xóa - ContractCodeSequence — không bao giờ xóa (giữ history seq) - Notifications — không soft delete, chỉ `IsRead` flag (giữ history ngắn hạn, cleanup job sau) - WorkflowStep / WorkflowStepApprover — cascade khi WorkflowDefinition xóa - Identity tables (Users, Roles, ...) — Identity không support soft delete built-in ## 7. Truy vấn tiêu biểu ### Inbox HĐ chờ role của tôi (với versioned workflow) ```sql -- Server filter (API): HĐ chờ role eligible phase theo pinned policy -- Thực tế ContractsController resolve policy runtime: -- def = db.WorkflowDefinitions.Include(Steps.Approvers).Where(Id == contract.WorkflowDefinitionId).FirstOrDefault() -- policy = def != null ? Registry.FromDefinition(def) : Registry.For(contract.Type) -- phase eligible = policy.Transitions.Where(t => t.From == contract.Phase && t.AllowedRoles.Intersect(myRoles).Any()) -- SQL tương đương: SELECT c.Id, c.MaHopDong, c.TenHopDong, c.Phase, c.SlaDeadline, s.Name AS SupplierName, p.Name AS ProjectName FROM Contracts c INNER JOIN Suppliers s ON c.SupplierId = s.Id INNER JOIN Projects p ON c.ProjectId = p.Id WHERE c.IsDeleted = 0 AND c.Phase IN (/* phase eligible computed theo pinned workflow */) ORDER BY c.SlaDeadline ASC; ``` ### Pick active workflow tại create-time ```sql SELECT TOP 1 Id FROM WorkflowDefinitions WHERE ContractType = @Type AND IsActive = 1 ORDER BY [Version] DESC; ``` ### Tạo version mới (atomic) ```sql BEGIN TRAN; -- Step 1: deactivate current active UPDATE WorkflowDefinitions SET IsActive = 0 WHERE ContractType = @Type AND IsActive = 1; -- Step 2: compute next version DECLARE @NextVersion INT = (SELECT ISNULL(MAX([Version]), 0) + 1 FROM WorkflowDefinitions WHERE Code = @Code); -- Step 3: insert new active version INSERT WorkflowDefinitions (Id, Code, [Version], IsActive, ContractType, Name, Description, CreatedAt, CreatedBy) VALUES (NEWID(), @Code, @NextVersion, 1, @Type, @Name, @Description, GETUTCDATE(), @UserId); -- Step 4: insert steps + approvers (batch) -- ... COMMIT; ``` ### Dashboard stats (MyDashboard user-specific) ```sql SELECT (SELECT COUNT(*) FROM Contracts WHERE DrafterUserId = @Me AND IsDeleted = 0 AND Phase NOT IN (9, 99)) AS DraftsInProgress, (SELECT COUNT(*) FROM Contracts WHERE Phase IN (/* eligible phases cho role tôi */) AND IsDeleted = 0) AS PendingMyApproval, (SELECT COUNT(*) FROM Contracts WHERE IsDeleted = 0 AND SlaDeadline BETWEEN GETUTCDATE() AND DATEADD(DAY, 2, GETUTCDATE())) AS DueSoon, (SELECT COUNT(*) FROM Contracts WHERE IsDeleted = 0 AND SlaDeadline < GETUTCDATE() AND Phase NOT IN (9, 99)) AS Overdue, (SELECT ISNULL(SUM(GiaTri), 0) FROM Contracts WHERE DrafterUserId = @Me AND Phase = 2) AS DraftsTotalValue; ``` ### Notifications unread count (bell badge) ```sql SELECT COUNT(*) FROM Notifications WHERE RecipientUserId = @Me AND IsRead = 0; ``` ### Gen mã HĐ atomic ```sql BEGIN TRAN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; UPDATE ContractCodeSequences SET LastSeq = LastSeq + 1, UpdatedAt = GETUTCDATE() WHERE Prefix = @Prefix; IF @@ROWCOUNT = 0 INSERT ContractCodeSequences (Prefix, LastSeq, UpdatedAt) VALUES (@Prefix, 1, GETUTCDATE()); SELECT LastSeq FROM ContractCodeSequences WHERE Prefix = @Prefix; COMMIT; ``` ## 8. Migration lịch sử | # | Migration | Tables added / changed | |---|---|---| | 1 | `Init` | 7 Identity tables | | 2 | `AddMasterData` | Suppliers, Projects, Departments | | 3 | `AddPermissions` | MenuItems, Permissions | | 4 | `AddForms` | ContractTemplates, ContractClauses | | 5 | `AddContractsWorkflow` | Contracts, ContractApprovals, ContractComments, ContractAttachments, ContractCodeSequences | | 6 | `AddNotifications` | Notifications | | 7 | `AddWorkflowTypeAssignments` | WorkflowTypeAssignments (admin override legacy) | | 8 | `AddVersionedWorkflows` | WorkflowDefinitions, WorkflowSteps, WorkflowStepApprovers + Contracts.WorkflowDefinitionId FK | | **9** | **`AddContractDetailsAndChangelog`** | **7 ContractType-specific Details + ContractChangelogs (unified audit log)** | | **10** | **`AddMasterCatalogs`** | **UnitsOfMeasure, MaterialItems, ServiceItems, WorkItems** | | **11** | **`AddRoleShortNameAndUserDepartment`** | **+Role.ShortName + User.DepartmentId/Position (cột thêm, không bảng mới)** | | **12** | **`AddPurchaseEvaluations`** | **10 bảng module Duyệt NCC: PurchaseEvaluations + Suppliers + Details + Quotes + Approvals + Changelogs + Attachments + WorkflowDefinitions + WorkflowSteps + WorkflowStepApprovers** | | **13** | **`AddPurchaseEvaluationCodeSequences`** | **PurchaseEvaluationCodeSequences (Prefix PK, atomic seq mirror ContractCodeSequences). Format MaPhieu PE/{YYYY}/{A\|B}/{Seq:D3}** | | **14** | **`AddBudgets`** | **4 bảng module Ngân sách: Budgets + BudgetDetails + BudgetApprovals + BudgetChangelogs. + nullable FK index Contract.BudgetId & PurchaseEvaluation.BudgetId** | | **15** | **`AddPurchaseEvaluationDepartmentOpinions`** | **1 bảng `PurchaseEvaluationDepartmentOpinions` (UNIQUE PEId+Kind, max 1 row mỗi loại phòng ban per phiếu — UPDATE in-place, audit qua Changelog)** | | **16** | **`AddTwoStageDeptApprovalAndSmartReject`** | **3 bảng `Contract/PurchaseEvaluation/Budget DepartmentApprovals` (UNIQUE TargetId+Phase+Dept+Stage cho 2-stage NV/TPB approval per phòng × phase) + 4 ALTER (`Users.CanBypassReview` bit + 3 `RejectedFromPhase` int cho smart reject quay về Drafter + jump-back phase đã reject)** | | **17** | **`AddManualBudgetFieldsToPeAndContract`** | **4 ALTER (PE + HĐ × `BudgetManualName` + `BudgetManualAmount`) — manual budget fallback. Không bảng mới.** | | **18** | **`AddPeWorkflowInnerStepsAndPositionLevel`** | **N-stage PE — 1 bảng `PurchaseEvaluationWorkflowStepInnerSteps` + 2 ALTER (`Users.PositionLevel` + `PEDeptApproval.InnerStepId`). (Mig 21 drop sau.)** | | **19** | **`AlterPeDeptApprovalsUniqueFilteredForInnerSteps`** | **Filtered unique split legacy/N-stage. Không bảng mới.** | | **20** | **`AddContractWorkflowInnerStepsAndAlterDeptApprovalUnique`** | **N-stage Contract mirror — 1 bảng `WorkflowStepInnerSteps` + ALTER. (Mig 21 drop sau.)** | | **21** | **`RefactorWorkflowToFlatModel`** | **🎯 DRASTIC: bỏ phase enum legacy → ChoDuyet=10 flat. DROP TABLE × 2 (InnerSteps PE+Contract) + ALTER tracking.** | | **22** | **`AddApprovalWorkflowsV2`** | **🎯 V2 schema — 3 bảng `ApprovalWorkflows` + `ApprovalWorkflowSteps` + `ApprovalWorkflowLevels` + enum ApplicableType. Quy trình > Bước (Phòng) > Cấp (NV).** | | **23** | **`AddApprovalWorkflowIdToPurchaseEvaluation`** | **Pin V2 vào PE — `PE.ApprovalWorkflowId`. Không bảng mới.** | | **24** | **`AddCurrentApprovalLevelOrderToPe`** | **Service V2 wire — `PE.CurrentApprovalLevelOrder`. Không bảng mới.** | | **25** | **`AddIsUserSelectableToApprovalWorkflows`** | **ALTER `ApprovalWorkflows` +`IsUserSelectable bit`. Không bảng mới.** | | **26** | **`AddPeLevelOpinionsForV2`** | **1 bảng `PurchaseEvaluationLevelOpinions` (UNIQUE PEId+LevelId, FK Cascade Pe + Restrict Level). Section 5 V2 dynamic.** | | **27** | **`AddVisibilityAndDisplayLabelToMenuItems`** | **2 ALTER `MenuItems` (+IsVisible +DisplayLabel) — admin ẩn/hiện + đổi tên menu eOffice. Không bảng mới.** | | **28** | **`AddAdvancedOptionsToApprovalWorkflows`** | **ALTER `ApprovalWorkflows` advanced options. Không bảng mới.** | | **29** | **`RefactorAdvancedOptionsToPerLevelAndDrafterUser`** | **Refactor advanced options → per-Level + DrafterUser. Không bảng mới.** | | **30** | **`AddAllowApproverEditBudgetToLevels`** | **ALTER `ApprovalWorkflowLevels` +`AllowApproverEditBudget`. Không bảng mới.** | | **31** | **`RefactorSkipToFinalToApproverLevel`** | **Per-Approver-slot skip-to-final. Không bảng mới.** | | **32** | **`AddApprovalWorkflowToContract`** | **🎯 Contract V2 wire (Plan B) — 2 ALTER `Contracts` (+ApprovalWorkflowId +CurrentApprovalLevelOrder) mirror PE Mig 23-24. Không bảng mới.** | | **33** | **`AddContractLevelOpinions`** | **1 bảng `ContractLevelOpinions` (UNIQUE ContractId+LevelId, mirror PE Mig 26). Section "Ý kiến cấp duyệt" V2.** | | **34** | **`AddEmployeeProfiles`** | **HRM core — `EmployeeProfiles` + satellite tables (N-satellite per parent).** | | **35** | **`AddHrmConfigs`** | **HRM config catalogs (LeaveTypes / Holidays UNIQUE composite / ...).** | | **36** | **`AddMeetingRooms`** | **Office — `MeetingRooms` + booking.** | | **37** | **`ExtendApplicableTypeForWorkflowApps`** | **Enum extend `ApplicableType` (+Proposal +WorkflowApps). Không bảng mới.** | | **38** | **`AddProposals`** | **Module Đề xuất (Proposal) — tables + workflow V2 mirror PE/Contract.** | | **39** | **`AddWorkflowApps`** | **Workflow Apps skeleton — Leave/OT/Travel/Vehicle/Ticket request tables.** | | **40** | **`AddAttendances`** | **Chấm công (Attendance) tables + Dashboard NS skeleton.** | | **41** | **`WireWorkflowAppsApprovalV2`** | **🎯 P11-A — 4 `{Leave,Ot,Travel,Vehicle}LevelOpinions` + `WorkflowAppCodeSequences` + 4 ALTER RejectedFromStatus + enum TravelRequest=9.** | | **42** | **`AddLeaveBalances`** | **🎯 P11-B — 1 bảng `LeaveBalances` (User×LeaveType×Year, UNIQUE composite + FK LeaveTypes Restrict). Trừ phép tự động khi đơn nghỉ duyệt cuối.** | Tổng: **91 bảng** (Mig 42, + `__EFMigrationsHistory` hệ thống). > ⚠️ **ERD chi tiết §11-15 cover Mig 12-26.** Mig 16-21 + 27-42 (DepartmentApprovals, flat-refactor, Contract V2, HRM/Office/Proposal/WorkflowApps/Attendance/LeaveBalance) — migration index ở bảng trên + skill `ef-core-migration`; schema field chi tiết xem entity `Configurations/` + session logs. Full per-table ERD sections cho module mới = **deferred backlog** (không silent — xem STATUS Maintenance backlog). ## 8bis. Bảng mới sau Migration 9-11 ### Per-type Details (7 bảng — Migration 9) Mỗi loại HĐ có bảng riêng. Common base: `Id`, `ContractId` FK Cascade, `Order` int, `ThanhTien` decimal(18,2), `GhiChu` nvarchar(1000) + `AuditableEntity` (CreatedAt/By/UpdatedAt/By). | Bảng | Loại HĐ (Type) | Field đặc trưng | |---|---|---| | `ThauPhuDetails` | 1 | HangMuc, DonViTinh, KhoiLuong, DonGia, ThoiGianHoanThanh | | `GiaoKhoanDetails` | 2 | MaCongViec, TenCongViec, KhoiLuong, DonGia, YeuCauKyThuat | | `NhaCungCapDetails` | 3 | MaSP, TenSP, ThongSoKyThuat, SoLuong, DonGia, ThoiGianGiao, XuatXu | | `DichVuDetails` | 4 | MaDichVu, TenDichVu, ThoiGian, DonGia, TuNgay/DenNgay | | `MuaBanDetails` | 5 | MaSP, TenSP, SoLuong, DonGia, **ThueVAT** decimal(5,2), XuatXu | | `NguyenTacNccDetails` | 6 | NhomSP, TenSP, **DonGiaToiThieu/ToiDa** (range), DieuKien | | `NguyenTacDvDetails` | 7 | LoaiDichVu, TenDichVu, DonGiaToiThieu/ToiDa, PhamViDichVu, SLA | ### ContractChangelogs (Migration 9) Unified audit log cho mọi entity HĐ: - `Id`, `ContractId` FK Cascade - `EntityType` int enum: 1=Contract, 2=Detail, 3=Workflow, 4=Comment, 5=Attachment - `EntityId` Guid? (PK của child entity, null nếu là Header) - `Action` int enum: 1=Insert, 2=Update, 3=Delete, 4=Transition - `PhaseAtChange` int? (snapshot Phase tại thời điểm change) - `UserId` Guid? + `UserName` nvarchar(200) (denormalize cho readable) - `Summary` nvarchar(500), `FieldChangesJson` nvarchar(max), `ContextNote` nvarchar(2000) - Indexes: (ContractId, CreatedAt), (ContractId, EntityType) ### 4 Master Catalogs (Migration 10) Phục vụ autocomplete trong Details add form (FE HTML5 datalist). | Bảng | Field | Dùng cho HĐ Detail | |---|---|---| | `UnitsOfMeasure` | Code, Name, Description | Tất cả 7 type (donViTinh) | | `MaterialItems` | Code, Name, Category, DefaultUnit, Specification, OriginCountry, IsActive | NCC + Mua bán + Nguyên tắc NCC | | `ServiceItems` | Code, Name, Category, DefaultUnit, Description, IsActive | Dịch vụ + Nguyên tắc DV | | `WorkItems` | Code, Name, Category, DefaultUnit, Description, IsActive | Thầu phụ + Giao khoán | Common: `AuditableEntity`, `IX__Code` UNIQUE filtered `IsDeleted=0`, `IX_
_Category`, `HasQueryFilter !IsDeleted`. ### Identity columns thêm (Migration 11) **Role:** - `ShortName` nvarchar(50) — Mã viết tắt VN (QTV/BOD/CCM/PRO/FIN/...) - `Description` đã có — full Vietnamese label **User:** - `DepartmentId` Guid? FK Departments **OnDelete Restrict** (không xóa dept nếu còn user reference) - `Position` nvarchar(200) — chức vụ free text - `IX_Users_DepartmentId` ## 9. Versioned workflow invariants ``` 1. UNIQUE (WorkflowDefinitions.Code, Version) → không 2 row cùng Code + Version (enforce qua IX unique) 2. Chỉ 1 WorkflowDefinition.IsActive = true per ContractType tại 1 thời điểm → enforce qua CreateWorkflowDefinitionCommand: UPDATE deactivate trước INSERT, cùng transaction 3. Contract.WorkflowDefinitionId pinned at create → không update sau đó → CreateContractCommandHandler pick active version 1 lần, save 4. ON DELETE Restrict FK Contract.WorkflowDefinitionId → WorkflowDefinitions.Id → admin không thể DELETE WorkflowDefinition nếu còn Contract pin → admin archive = set IsActive=false thôi, row vẫn tồn tại 5. Runtime policy resolution order (ContractWorkflowService): a. If contract.WorkflowDefinitionId NOT NULL → load def → FromDefinition b. Else if admin override ở WorkflowTypeAssignments for contract.Type → Registry.ByName c. Else → Registry.For(contract.Type) (hardcoded Standard/SkipCcm) 6. WorkflowStepApprover.Kind - 1=Role: AssignmentValue là RoleName (Domain/Identity/AppRoles constants) - 2=User: AssignmentValue là UserId Guid string - Runtime guard hiện tại chỉ dùng Role-kind (User-kind data model ready, enable iter sau) ``` ## 11. PurchaseEvaluation module (Migration 12 — 10 bảng mới) Module tiền-HĐ: phiếu trình duyệt so sánh giá N NCC × M hạng mục. Sau khi DaDuyet → user click "Tạo HĐ từ phiếu" → gen Contract draft kế thừa Supplier/Project/GiaTri, link qua `PurchaseEvaluation.ContractId`. ### Core (7 bảng): | Bảng | Mục đích | |---|---| | `PurchaseEvaluations` | Header. MaPhieu auto PE-YYYYMM-XXXX, Type enum (1=DuyetNcc A, 2=DuyetNccPhuongAn B), Phase (7 state + TuChoi), WorkflowDefinitionId pinned at create, SelectedSupplierId (winner), PaymentTerms JSON (D section form), ContractId? FK kế thừa. AuditableEntity. | | `PurchaseEvaluationSuppliers` | N:M Phiếu × Supplier. DisplayName ("TGN-30 ngày"), ContactName/Email/Phone (E section), PaymentTermText per NCC, Note (chip "ĐÃ CHỐT SO SÁNH LẦN 1/2"), Order. UK(EvaluationId, SupplierId). | | `PurchaseEvaluationDetails` | Hạng mục so sánh. GroupCode (A.I/II/III/IV), GroupName (Bê tông/Phụ gia...), ItemCode, NoiDung, ĐơnViTinh, KhoiLuongNganSach/ThiCong, DonGiaNganSach, ThanhTienNganSach. | | `PurchaseEvaluationQuotes` | Báo giá per NCC per Detail (matrix cell). FK Detail + Supplier-row, BgVat/ChuaVat/ThanhTien, IsSelected flag, Note. UK(DetailId, SupplierId). | | `PurchaseEvaluationApprovals` | Workflow history (giống ContractApprovals). FromPhase/ToPhase/Decision/Comment/ApprovedAt. | | `PurchaseEvaluationChangelogs` | Audit log unified (EntityType: Header/Supplier/Detail/Quote/Workflow/Attachment + Action: Insert/Update/Delete/Transition). | | `PurchaseEvaluationAttachments` | File upload — báo giá NCC gửi, bản vẽ, phiếu export. Purpose enum. | ### Workflow config (3 bảng — tái dùng pattern HĐ versioned): | Bảng | Mục đích | |---|---| | `PurchaseEvaluationWorkflowDefinitions` | Versioned definition per Type. Code+Version UNIQUE, IsActive (1 per Type). Seed v01: QT-DN-A (3-step) + QT-DN-B (5-step). | | `PurchaseEvaluationWorkflowSteps` | Ordered steps: Order+Phase+Name+SlaDays. | | `PurchaseEvaluationWorkflowStepApprovers` | Role/User kind + AssignmentValue (reuse WorkflowApproverKind enum từ HĐ). | ### State machine PE Phase: ``` A (NccOnly 3-step): DangSoanThao → ChoPurchasing → ChoCCM → ChoCEODuyetNCC → DaDuyet bất kỳ phase duyệt reject → DangSoanThao DangSoanThao → TuChoi (cancel) B (NccWithPlan 5-step): DangSoanThao → ChoPurchasing → ChoDuAn → ChoCCM → ChoCEODuyetPA → ChoCEODuyetNCC → DaDuyet Role mapping: Drafter/DeptManager → Procurement (PRO) → ProjectManager (PM) → CostControl (CCM) → Director (BOD) ``` ### Kế thừa HĐ flow: ``` PE.Phase=DaDuyet && SelectedSupplierId && !ContractId → user FE click "Tạo HĐ từ phiếu" → pick ContractType (1-7) + TenHopDong + bypassCCM flag → POST /api/purchase-evaluations/{id}/create-contract → CreateContractFromEvaluationCommand: 1. Verify PE state 2. new Contract { SupplierId=PE.Selected, ProjectId=PE.Project, ... } 3. GiaTri = sum(PE.Details.ThanhTienNganSach) 4. DraftData = PE.PaymentTerms (carry) 5. WorkflowDefinitionId = active ContractWorkflowDefinition[Type] 6. Gen MaHopDong (ContractCodeGenerator SERIALIZABLE) 7. PE.ContractId = contract.Id (link 2 chiều) 8. Changelog cả 2 bảng → navigate /contracts/{newId} ``` ## 12. Budget module (Migration 14 — 4 bảng mới) Module quản lý ngân sách dự án. Liên kết nullable Contract & PurchaseEvaluation cho đối chiếu chi phí. Workflow simple 3-step (chưa versioned, hardcoded `BudgetPolicy.Default`). ### Core (4 bảng): | Bảng | Mục đích | |---|---| | `Budgets` | Header. **MaNganSach** `NS-{YYYYMM}-{Random:4d}` UK filtered, **TenNganSach** required, Description, **NamNganSach** int (year), **ProjectId** FK Restrict, **DepartmentId?** FK Restrict, **DrafterUserId** FK Restrict, **Phase** enum (5-state), **TongNganSach** decimal(18,2) auto-recompute từ Sum(BudgetDetails.ThanhTien), **SlaDeadline** DateTime?, **SlaWarningSent** bool. AuditableEntity (soft delete). | | `BudgetDetails` | Flat row pattern (giống PE Details, KHÔNG nested). **BudgetId** FK Cascade, **GroupCode** (50 char) + **GroupName** (200) — phân nhóm hạng mục, **ItemCode** (100), **NoiDung** (500) required, **DonViTinh** (50), **KhoiLuong** decimal(18,4), **DonGia** decimal(18,2), **ThanhTien** decimal(18,2), **Order** int, **GhiChu** (1000). Index (BudgetId, Order). | | `BudgetApprovals` | Workflow history. FromPhase/ToPhase/Decision (reuse `ApprovalDecision` enum), ApproverUserId, Comment, ApprovedAt. Index (BudgetId, ApprovedAt). | | `BudgetChangelogs` | Audit log unified. **EntityType** enum `BudgetEntityType` (1=Header, 2=Detail, 3=Workflow), EntityId Guid?, **Action** enum `ChangelogAction` (Insert/Update/Delete/Transition), PhaseAtChange enum?, UserId Guid? + UserName denorm, Summary (500), FieldChangesJson nvarchar(max), ContextNote (2000). Indexes (BudgetId, CreatedAt) + (BudgetId, EntityType). | ### Link nullable từ Contract & PE: ```sql ALTER TABLE Contracts ADD BudgetId UNIQUEIDENTIFIER NULL; ALTER TABLE PurchaseEvaluations ADD BudgetId UNIQUEIDENTIFIER NULL; CREATE INDEX IX_Contracts_BudgetId ON Contracts (BudgetId); CREATE INDEX IX_PurchaseEvaluations_BudgetId ON PurchaseEvaluations (BudgetId); ``` Không có FK constraint cứng → Budget có thể bị soft-delete mà không ảnh hưởng HĐ/PE đã link. App layer guard validate khi chọn Budget (filter `Phase=DaDuyet && !IsDeleted`). ### State machine BudgetPhase (5-state): ``` DangSoanThao(1) ──Trình──► ChoCCM(2) ──Duyệt──► ChoCEO(3) ──Duyệt──► DaDuyet(4) ▲ │ │ └──Trả về───────────────┘ │ └──Trả về─────────────────────────────────────┘ └──Hủy──► TuChoi(99) Role mapping (BudgetPolicy.Default): - Drafter / DeptManager: DangSoanThao → ChoCCM (Trình) hoặc TuChoi (Hủy) - CostControl (CCM): ChoCCM → ChoCEO (Duyệt) hoặc DangSoanThao (Trả về) - Director / AuthSigner: ChoCEO → DaDuyet (Duyệt) hoặc DangSoanThao (Trả về) ``` ### Auto-recompute TongNganSach: ```csharp // AddBudgetDetailHandler / UpdateBudgetDetailHandler / DeleteBudgetDetailHandler budget.TongNganSach = budget.Details.Where(d => !d.IsDeleted).Sum(d => d.ThanhTien); db.Budgets.Update(budget); ``` Không trigger DB → app layer tính lại sau mỗi mutation Detail. Đơn giản, debug dễ. ### Pending refinement (Phase 8): - **`AddBudgetCodeSequences`** — atomic SERIALIZABLE sequence khi format chốt chính thức (mirror Contract/PE pattern). - **`AddBudgetVersionedWorkflow`** — nếu Solutions cần admin config UI thay vì hardcoded policy. Pattern: `BudgetWorkflowDefinitions` + `Steps` + `StepApprovers` + `Budget.WorkflowDefinitionId?` pinned at create. ## 13. PE Department Opinion (Migration 15 — 1 bảng mới) Section "Ý kiến 4 phòng ban" trên PHIẾU TRÌNH KÝ CHỌN TP/NCC — sign-off block 4 box (Phê duyệt / P.CCM / P.MuaHàng / SM-PM). Lưu thành table riêng (1:N với PE) thay vì bloat 12 column nullable trong PE header. ### Core (1 bảng): | Bảng | Mục đích | |---|---| | `PurchaseEvaluationDepartmentOpinions` | PEId FK Cascade + **Kind** enum `PeDepartmentKind` (1=PheDuyet, 2=Ccm, 3=MuaHang, 4=SmPm) + Opinion text(2000) + SignedAt? + UserId? + UserName denorm. UNIQUE(PEId, Kind) — max 1 row mỗi phòng ban per phiếu. AuditableEntity. | ### Upsert behavior: - **Lần đầu (Add):** entity mới với Kind = X, Opinion = text, SignedAt = `Sign ? UtcNow : null` - **Lần sau (Update):** UPDATE in-place (gốc giữ Id), Opinion = new text. Sign=true → cập nhật SignedAt+UserId; Sign=false → giữ chữ ký cũ (chỉ update text) - Audit qua `PurchaseEvaluationChangelog` mỗi lần Upsert/Delete ```sql CREATE TABLE PurchaseEvaluationDepartmentOpinions ( Id UNIQUEIDENTIFIER PRIMARY KEY, PurchaseEvaluationId UNIQUEIDENTIFIER NOT NULL, Kind INT NOT NULL, Opinion NVARCHAR(2000) NULL, SignedAt DATETIME2 NULL, UserId UNIQUEIDENTIFIER NULL, UserName NVARCHAR(200) NULL, -- audit CreatedAt, CreatedBy, UpdatedAt, UpdatedBy, IsDeleted, DeletedAt, DeletedBy, CONSTRAINT FK_... FOREIGN KEY (PurchaseEvaluationId) REFERENCES PurchaseEvaluations(Id) ON DELETE CASCADE ); CREATE UNIQUE INDEX IX_PEDeptOpinions_PEId_Kind ON PurchaseEvaluationDepartmentOpinions (PurchaseEvaluationId, Kind); ``` ## 14. ApprovalWorkflow V2 schema (Migration 22-31, Session 17-23 — 3 bảng mới + 7 Allow* options per slot Approver) Schema riêng song song WorkflowDefinition V1 (Mig 21) — pin per phiếu PE. V1 vẫn giữ cho phiếu cũ; V2 mới là active cho phiếu tạo từ Session 17 trở đi. **Pattern cumulative cho admin opt-in flag per slot — proven 3× (memory `feedback_per_nv_permission_scope.md`):** - Mig 29 (S21 t5) — Refactor 6 Allow* options từ workflow-level (Mig 28 S21 t4) sang PER-NV: 5 flag F1+F3 xuống Level slot, 1 flag F2 xuống User. Backfill bulk SQL preserve admin config S21 t4. - Mig 30 (S22+5) — F4 `AllowApproverEditBudget` per slot Approver — admin tick cho NV chỉnh Section "Điều chỉnh ngân sách" khi đang duyệt. - Mig 31 (S23 t1) — F2 refactor sang Approver scope: ADD `AllowApproverSkipToFinal` per Level slot, DROP `Users.AllowDrafterSkipToFinal`. Semantic cũ Drafter-from-Nháp deprecated. **NO BACKFILL** (4 prod user lose flag value — admin re-config qua Designer). ### Core (3 bảng): ``` ApprovalWorkflows ├── Id (PK), Code, Version (UNIQUE Code+Version) ├── ApplicableType (1=DuyetNcc, 2=DuyetNccPhuongAn, 3=Contract) ├── Name, Description, IsActive, ActivatedAt ├── IsUserSelectable (Mig 25, S18) — admin pin/unpin cho user pick lúc create phiếu │ (Mig 28 cũ 6 column Allow* đã DROP trong Mig 29 — refactor sang per-NV) └── (audit) CreatedAt, UpdatedAt, CreatedBy, UpdatedBy ApprovalWorkflowSteps (FK Cascade ApprovalWorkflowId, FK Restrict DepartmentId) ├── Id (PK), ApprovalWorkflowId, Order (1-based, sort key — KHÔNG phải position) ├── Name (vd "Phòng A"), DepartmentId? (hint) └── INDEX (ApprovalWorkflowId, Order) + INDEX DepartmentId ApprovalWorkflowLevels (FK Cascade ApprovalWorkflowStepId, FK Restrict ApproverUserId) ├── Id (PK), ApprovalWorkflowStepId, Order (1/2/3 trong Step) ├── Name? (vd "Cấp 1"), ApproverUserId (1 NV cụ thể) │ ├── Mig 29 (S21 t5) — 5 advanced options per slot Approver (F1+F3): ├── AllowReturnOneLevel bit DEFAULT 0 — F1 mode: Trả về 1 Cấp trước (peer review) ├── AllowReturnOneStep bit DEFAULT 0 — F1 mode: Trả về 1 Bước trước ├── AllowReturnToAssignee bit DEFAULT 0 — F1 mode: Trả về Người chỉ định ├── AllowReturnToDrafter bit DEFAULT 1 — F1 mode: Trả về Drafter (S17 backward compat) ├── AllowApproverEditDetails bit DEFAULT 0 — F3: NV này chỉnh Section 2 lúc đang duyệt │ ├── Mig 30 (S22+5) — F4 admin opt-in per slot: ├── AllowApproverEditBudget bit DEFAULT 0 — F4: NV này chỉnh Section "Điều chỉnh ngân sách" │ ├── Mig 31 (S23 t1) — F2 refactor admin opt-in per slot: ├── AllowApproverSkipToFinal bit DEFAULT 0 — F2: NV này duyệt thẳng Cấp cuối (skip trung gian) │ (storage cũ Users.AllowDrafterSkipToFinal đã DROP) │ └── INDEX (ApprovalWorkflowStepId, Order) + INDEX ApproverUserId Users (AspNetUsers extension) ├── ... existing columns (FullName, DepartmentId, PositionLevel, CanBypassReview, etc) └── (Mig 29 AllowDrafterSkipToFinal column đã DROP trong Mig 31 — F2 refactor sang Approver scope) ``` **Convention quan trọng:** nhiều `ApprovalWorkflowLevel` rows cùng `Order` trong cùng Step = **same Cấp với N approvers** (OR-of-N). Ví dụ Cấp 1 có 2 NV: 2 row Level cùng `Order=1` khác `ApproverUserId`. ### Pin V2 vào PE (Mig 23-24): ``` PurchaseEvaluations (column mới) ├── ApprovalWorkflowId Guid? (FK Restrict ApprovalWorkflows) — pin lúc create └── CurrentApprovalLevelOrder int? — track Cấp đang chờ duyệt (1/2/3) ``` Combined với `CurrentWorkflowStepIndex int?` (Mig 21) để track full state V2: - `CurrentWorkflowStepIndex` = 0-based index của Step (sau khi sort by `Order`) - `CurrentApprovalLevelOrder` = `Order` của Cấp đang chờ trong Step đó Service iterate: `steps[CurrentStepIndex].Levels.Where(l => l.Order == CurrentLevelOrder)` → list approvers, match `actor.Id ∈ ApproverUserId`. ### Match approver V2 (khác V1): | Schema | Match logic | Approver type | |---|---|---| | V1 (Mig 21) | `actor.Dept == step.Dept && actor.PositionLevel >= step.PositionLevel` OR Approvers Role/User explicit | Group qua Dept+Level | | V2 (Mig 22-25) | `actor.Id == any level.ApproverUserId where level.Order == currentLevelOrder` | NV cụ thể 1-1 | ### State transitions (V2 + V1 cùng): ``` DangSoanThao ──Drafter trình──► ChoDuyet (idx=0, levelOrder=1 nếu V2) TraLai ──Drafter sửa+gửi lại──► ChoDuyet (idx=0, levelOrder=1) — chạy LẠI từ đầu ChoDuyet ──Approver duyệt cấp──► ChoDuyet (advance levelOrder hoặc idx) ──Approver Trả lại──► TraLai (clear pointers) ──Approver Từ chối──► TuChoi (terminal khoá) ──last step+level done──► DaDuyet (terminal) ``` ### Service branch theo schema pin: ```csharp // PurchaseEvaluationWorkflowService.TransitionAsync (Mig 24 wire) if (evaluation.ApprovalWorkflowId is Guid awId) await ApproveV2Async(...) // iterate ApprovalWorkflowSteps + Levels match ApproverUserId else await ApproveV1LegacyAsync(...) // iterate WorkflowSteps match Dept+PositionLevel ``` ### Designer constraints (FE Validator + BE Validator): - Tối đa 3 Cấp/Bước (`Order ∈ {1,2,3}`) - Sequential gating: `1` → `1+2` → `1+2+3` (không cho `2` nếu thiếu `1`, không `1+3` nếu thiếu `2`) - No duplicate `(Order, ApproverUserId)` cùng Step (1 NV không thêm 2 lần cùng Cấp) - Mỗi Cấp có N NV (OR-of-N) - Đổi Phòng → clear toàn bộ approvers (NV cũ có thể không thuộc Phòng mới) ### IsUserSelectable filter logic (Mig 25, Session 18): `IsUserSelectable` độc lập với `IsActive`: - `IsActive` = "đang áp dụng (default mới)" — max 1 per ApplicableType - `IsUserSelectable` = "cho user pick lúc create phiếu" — multiple version cùng selectable Default behavior (sau Mig 25 backfill): active workflows tự động `IsUserSelectable=1` (giữ behavior cũ — Workspace dropdown vẫn hiện active workflow). Archived versions = `0` default, admin có thể toggle qua Designer khi muốn user pick lại version cũ. `CreateAwDefinitionCommand` set `IsUserSelectable=true` cho version mới (mirror IsActive default). API `PATCH /api/approval-workflows-v2/{id}/user-selectable` — admin toggle stick/unstick (policy `Workflows.Create`). Workspace `PeWorkspaceCreateView` query `.filter(w => w.isUserSelectable)` — chỉ hiện workflows admin đã ghim. ### Pending Session 20+: - Contract V2 wire (Mig 27/28): mirror PE pattern — thêm `Contract.ApprovalWorkflowId` + `CurrentApprovalLevelOrder` + `ContractWorkflowService.ApproveV2Async` + `ContractLevelOpinions` (mirror Mig 26) - Drop legacy V1: sau khi không còn phiếu pin `WorkflowDefinitionId` → drop `WorkflowDefinitions` + `WorkflowSteps` + `WorkflowStepApprovers` + drop deprecated columns `RejectedAtStepIndex` / `RejectedFromPhase` (Mig cleanup) - Drop Mig 15 `PurchaseEvaluationDepartmentOpinions` cho V2 phiếu (sau khi tất cả phiếu V2 chỉ dùng Mig 26 LevelOpinions). Phiếu V1 legacy giữ Mig 15 backward compat. ## 15. PE Level Opinions V2 (Migration 26 — 1 bảng mới, Session 19) **Mục đích:** Thay thế Section 5 "Ý kiến 4 phòng ban" CỨNG (Mig 15 — PheDuyet/CCM/MuaHàng/SmPm) → động theo `ApprovalWorkflowsV2` (Mig 22-25). Mỗi (PE × Level) = 1 row sign-off với ý kiến + chữ ký NV. **Bối cảnh decision (đáng giữ nguyên văn cho session sau):** User UAT Section 5 cũ "có 4 box CỨNG" — không scale theo workflow V2 cấu hình động (N Bước × N Cấp × N NV). Q&A 5 câu chốt spec trước code (memory `feedback_drastic_refactor_scope` áp dụng): - **Q1=1B (gắn — KHÔNG rời):** Comment khi NV nhấn Duyệt qua Workflow Panel → auto sync sang OpinionBox của NV đó. Section 5 read-only summary, KHÔNG có form input rời (anti-pattern Mig 15 cũ — user phải double action: duyệt + ký). - **Q2=2A+Admin override:** NV chính chủ duyệt (match `ApproverUserId == actor.Id`). Admin override → fallback first level group, lưu `SignedByUserId = admin.Id`. FE banner amber "⚠ Admin duyệt thay" khi `SignedByUserId !== ApproverUserId` → minh bạch ai thực sự duyệt. - **Q3=V2 hết:** Phiếu V2 (pin `ApprovalWorkflowId`) → render dynamic. Phiếu V1 legacy (chỉ `WorkflowDefinitionId`) → fallback Mig 15 readOnly (giữ data history, KHÔNG drop). Mig 15 deprecated cho V2 phiếu, drop sau UAT confirm. - **Q4=4C+placeholder:** Phase=DaDuyet/TuChoi → khoá hoàn toàn. Comment empty/whitespace → ghi `"(duyệt — không ý kiến)"` placeholder (vẫn tính là sign-off). - **Q5=5A grid layout:** Group theo Step (header "Bước N — Phòng X" badge emerald + hint "(N người duyệt)") → grid-cols-2 cho N approvers. "Bước 1 phòng A có 2 NV → 2 box ngang hàng" — wrap row khi N>2. **Bảng `PurchaseEvaluationLevelOpinions`:** ``` Id uniqueidentifier PK PurchaseEvaluationId FK Cascade → PurchaseEvaluations ApprovalWorkflowLevelId FK Restrict → ApprovalWorkflowLevels (Mig 22) Comment nvarchar(2000) -- ý kiến hoặc placeholder SignedAt datetime2 NOT NULL -- luôn có khi UPSERT SignedByUserId uniqueidentifier NOT NULL -- NV chính chủ HOẶC Admin override SignedByFullName nvarchar(200) NOT NULL -- denorm: tránh user xóa/đổi tên + AuditableEntity audit fields (CreatedAt/UpdatedAt/IsDeleted/...) UNIQUE (PurchaseEvaluationId, ApprovalWorkflowLevelId) INDEX (ApprovalWorkflowLevelId) ``` **Constraint design rationale (KHÔNG paraphrase):** - **UNIQUE composite (PEId, LevelId):** 1 row per (phiếu × Cấp). Latest write wins khi Service ApproveV2Async UPSERT — phiếu loop qua TraLai → resubmit → cùng NV duyệt lại = OVERWRITE row cũ (không tạo duplicate). Multi-NV cùng Cấp (OR-of-N) = nhiều LevelId distinct → mỗi NV có row riêng nếu duyệt. - **FK Cascade Pe:** Xoá phiếu (soft-delete actually) → opinions cascade. - **FK Restrict Level:** Admin xoá Level (qua Designer V2) bị chặn nếu opinion tồn tại — protect history sign-off. - **`SignedByUserId` KHÔNG nav (denorm `SignedByFullName`):** User có thể bị soft-delete / đổi tên → opinion vẫn render lịch sử đúng tên tại thời điểm ký. Tránh cascade phức tạp khi xoá user. **Service hook pattern (ApproveV2Async):** ```csharp // Sau khi log approval row → UPSERT opinion cho Cấp hiện tại var matchingLevel = pendingLevelGroup .FirstOrDefault(l => l.ApproverUserId == actorUserId) // NV chính chủ ?? pendingLevelGroup.First(); // Admin fallback first var existing = await db.PurchaseEvaluationLevelOpinions .FirstOrDefaultAsync(o => o.PEId == pe.Id && o.LevelId == matchingLevel.Id, ct); var normalizedComment = string.IsNullOrWhiteSpace(comment) ? "(duyệt — không ý kiến)" // Q4 bonus placeholder : comment.Trim(); // Upsert: existing → update; null → Add new ``` **Reject KHÔNG sync** (Trả lại / Từ chối) — giữ snapshot opinion cũ nếu có. **Lưu ý anti-pattern (so với Mig 15 cũ):** - ❌ Mig 15 dùng endpoint POST `/pe/{id}/opinions` cho user nhập rời + 4 box CỨNG → user phải nhấn 2 action (Duyệt workflow + Ký opinion box) → UX kém + risk inconsistency - ✅ Mig 26 dùng Service hook UPSERT khi nhấn Duyệt → 1 action user, sync auto → Pattern **"derived state qua Service hook, KHÔNG endpoint CRUD riêng"** áp dụng được cho: - Audit trail tự động khi save entity - Notification interceptor SaveChangesInterceptor - Code generation (gen mã HĐ khi DangDongDau) — đã có ### Pending Session 20+: - Contract Section 5 V2 dynamic — mirror PE Mig 26 pattern. Tạo `ContractLevelOpinions` (Mig sau Contract V2 wire). Audit-reuse pattern memory `feedback_audit_reuse_before_clone` áp dụng. - Drop Mig 15 cho V2 phiếu — cleanup sau UAT confirm. - Test V2 Service hook — Domain test ApproveV2 + UPSERT opinion match logic + Admin override match firstLevel + comment empty placeholder. ## 16. Liên quan - [`database-guide.md`](database-guide.md) — conventions + migration workflow + cheatsheet đầy đủ - [`../architecture.md`](../architecture.md) — layered architecture + data flow - [`../workflow-contract.md`](../workflow-contract.md) — state machine spec + versioned - [`../flows/`](../flows/) — sequence diagrams