Files
solution-erp/docs/database/schema-diagram.md
pqhuy1987 eea86fdfe7
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m31s
[CLAUDE] Docs: Chunk E — chốt Session 21 turn 4 F1+F2+F3 PE Workflow advanced options (Mig 28)
Update docs theo rule §6.5 KEEP narrative:

- `docs/database/schema-diagram.md §14` title "Mig 22-28, S17-21" + thêm 6
  column Allow* inline trong Core ApprovalWorkflows block (inline comment
  F1/F2/F3 mapping). Bonus: 3 bảng (Steps + Levels) unchanged.

- `docs/STATUS.md` Last updated S21 t4 + count 27→28 mig (Mig 28 advanced
  options 6 column). UAT defer test count unchanged 84 (test-after-uat candidate
  bundle Plan C carry).

- `docs/HANDOFF.md` Insert TL;DR S21 t4 đầy đủ (trước S21 t3):
  - Q&A clarify 2 lượt chốt scope (F1 cả 2 mode admin, F1 Assignee runtime,
    F2 chỉ Cấp cuối, F3 Section 2 only, F2+F3 admin tick, F3 mọi approver
    active, test-after UAT)
  - 5 chunk narrative đầy đủ A schema → B BE → C FE Admin → D FE eOffice → E Docs
  - Pattern reusable: backward-compat option flags, boundary helper extension
  - State table cumulative + pending Plan C test-after catch-up

- NEW session log `docs/changelog/sessions/2026-05-13-1200-s21-turn4-pe-workflow-advanced-options.md`:
  - Trigger + Q&A 2 lượt
  - 5 chunk narrative chi tiết với code snippets
  - Pattern reusable 5 lessons learned
  - References file paths + spec context

Stats cumulative S21 t4:
- 28 mig (+1) · 59 tables · ~143 endpoints (+1) · 34 FE pages · 84 test pass
  (UAT defer test-after §7) · 45 gotcha unchanged · 17 memory · 6 skills
- 5 commits S21 t4 cumulative ready push remote

Pending: bro confirm push `0a3b747..HEAD` 8 commits ahead (S21 t3 + S21 t4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:11:14 +07:00

41 KiB
Raw Blame History

Schema Diagram — Luồng DB SOLUTION_ERP

ERD đầy đủ + mối quan hệ 36 table sau Migration 11 (Tier 3 + 4-bảng overhaul + 4 master catalogs + Role/User VN). Mermaid render ở VS Code / GitHub / Gitea.

1. Full ERD

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)

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)

flowchart LR
    Create[POST /contracts type=5]
    Create --> PickWD["SELECT TOP 1 WorkflowDefinition<br/>WHERE ContractType=5 AND IsActive=1<br/>→ Id=wf-v02"]
    PickWD --> C1["Contracts INSERT<br/>Phase=2, SLA=+7d, WorkflowDefinitionId=wf-v02"]

    Transition1[Transition 2→3]
    Transition1 --> LoadPolicy["Load wf-v02.Steps.Approvers<br/>WorkflowPolicyRegistry.FromDefinition(def)"]
    LoadPolicy --> Guard["Check allowed roles for<br/>(from=2, to=3)"]
    Guard --> C2["UPDATE Phase=3<br/>INSERT ContractApprovals<br/>INSERT Notifications bulk"]

    Comment[POST /comments]
    Comment --> C3["INSERT ContractComments<br/>INSERT Notifications"]

    NewVersion[Admin creates QT-MB-v03]
    NewVersion --> NV1["INSERT WorkflowDefinition v03 IsActive=1<br/>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<br/>UPSERT ContractCodeSequences"]
    CG --> C5["UPDATE Contract<br/>SET MaHopDong, Phase=8<br/>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.

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.DeletedModified, 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)

-- 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

SELECT TOP 1 Id
FROM WorkflowDefinitions
WHERE ContractType = @Type AND IsActive = 1
ORDER BY [Version] DESC;

Tạo version mới (atomic)

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)

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)

SELECT COUNT(*) FROM Notifications
WHERE RecipientUserId = @Me AND IsRead = 0;

Gen mã HĐ atomic

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)

Tổng: 55 bảng (+ __EFMigrationsHistory hệ thống).

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_<Table>_Code UNIQUE filtered IsDeleted=0, IX_<Table>_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).
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:

// 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
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-28, Session 17-21 — 3 bảng mới + 9 column)

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.

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 (S21 t4) — 6 advanced options "Cấu hình nâng cao" per workflow:
├── 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 (pick runtime)
├── AllowReturnToDrafter      bit DEFAULT 1  — F1 mode: Trả về Drafter (S17 backward compat)
├── AllowDrafterSkipToFinal   bit DEFAULT 0  — F2: Drafter trình thẳng Cấp cuối, skip trung gian
├── AllowApproverEditDetails  bit DEFAULT 0  — F3: Approver chỉnh Section 2 lúc đang duyệt
│
└── (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ể)
└── INDEX (ApprovalWorkflowStepId, Order) + INDEX ApproverUserId

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:

// 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: 11+21+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):

// 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