# Contract Approval Flow — State Machine 9 Phase > **Status:** 📝 Planned (Phase 3) > **Spec gốc:** [`../workflow-contract.md`](../workflow-contract.md) (state machine + role matrix) > **Entry:** User click "Chuyển phase tiếp" ở `/contracts/{id}` detail page ## 1. Tổng quan state machine ```mermaid stateDiagram-v2 [*] --> DangChon: Tạo (mặc định) DangChon --> DangSoanThao: PB chọn NCC DangSoanThao --> DangGopY: Drafter submit DangGopY --> DangDamPhan: Nhận xong comment DangDamPhan --> DangInKy: Thỏa thuận xong DangInKy --> DangKiemTraCCM: Đã ký nháy, chuyển CCM DangKiemTraCCM --> DangTrinhKy: CCM duyệt DangTrinhKy --> DangDongDau: BOD ký (GEN mã HĐ!) DangDongDau --> DaPhatHanh: HRA đóng dấu DaPhatHanh --> [*] DangSoanThao --> TuChoi: Drafter hủy DangGopY --> DangSoanThao: Revise DangKiemTraCCM --> DangSoanThao: CCM reject DangTrinhKy --> DangSoanThao: BOD reject TuChoi --> [*] ``` SLA mỗi phase: xem [`../workflow-contract.md §4`](../workflow-contract.md). Tổng ~19 ngày. ## 2. Transition API — `POST /api/contracts/{id}/transitions` ### Request ```json { "targetPhase": "DangGopY", "decision": "Approve", "comment": "Đã hoàn thiện draft, chuyển góp ý." } ``` ### Response ```json { "contractId": "c5d6-...", "oldPhase": "DangSoanThao", "newPhase": "DangGopY", "slaDeadline": "2026-04-28T10:00:00Z", "actor": { "id": "u1-...", "fullName": "Nguyen Van A" }, "notifiedUsers": ["u2-...", "u3-...", "u4-..."] } ``` ## 3. Handler flow ```mermaid sequenceDiagram actor U as User
(role cụ thể) participant FE as fe-user participant API as ContractsController
.Transition participant M as TransitionContractCommandHandler participant WF as IContractWorkflowService participant CG as IContractCodeGenerator participant NS as INotificationService participant DB U->>FE: Click "Chuyển phase" + nhập comment FE->>API: POST /api/contracts/{id}/transitions
{targetPhase, decision, comment} API->>M: Send(TransitionContractCommand) M->>DB: SELECT Contract WHERE Id = ? alt Not found M-->>API: throw NotFoundException end M->>WF: ValidateTransition(contract, targetPhase, userRole) WF->>WF: Check state rule:
1. currentPhase allows transition to targetPhase?
2. user role đủ quyền ở phase này?
3. bypass rule với Chủ đầu tư? alt Invalid transition WF-->>M: throw ForbiddenException("không được phép") end alt targetPhase == DangDongDau (BOD ký) M->>CG: GenerateAsync(project, type, supplier) CG->>DB: BEGIN TRAN SERIALIZABLE CG->>DB: SELECT LastSeq WITH UPDLOCK CG->>DB: UPDATE LastSeq + 1 CG->>DB: COMMIT CG-->>M: "FLOCK 01/HĐGK/SOL&PVL/03" M->>DB: UPDATE Contract SET MaHopDong = ? end M->>DB: INSERT ContractApproval
(ContractId, Phase=currentPhase,
ApproverUserId, Decision, Comment) M->>DB: UPDATE Contract SET Phase = targetPhase,
SlaDeadline = UtcNow + PhaseSla M->>NS: NotifyPhaseChangeAsync(contract, oldPhase, newPhase) NS->>NS: Query users trong role eligible
for newPhase NS->>NS: Send email (MailKit) + in-app notify Note over NS: (Phase 3 Iteration 2) SignalR
push notification real-time M-->>API: TransitionResultDto API-->>FE: 200 FE->>FE: Invalidate TanStack query
['contracts', id] + toast success FE->>FE: Refresh timeline UI ``` ## 4. Guard rules (IContractWorkflowService) ```csharp public class ContractWorkflowService : IContractWorkflowService { // Adjacency + role matrix — xem workflow-contract.md private static readonly Dictionary<(ContractPhase From, ContractPhase To), string[]> _transitions = new() { [(ContractPhase.DangChon, ContractPhase.DangSoanThao)] = [AppRoles.Drafter, AppRoles.DeptManager], [(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter], [(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.Admin], [(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter], [(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl], [(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager], [(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter], [(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl], [(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl], [(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin], }; // Bypass CĐT: HopDongChuDauTu → có thể nhảy từ DangInKy thẳng tới DangTrinhKy (bỏ CCM) public void ValidateTransition(Contract contract, ContractPhase target, IReadOnlyList userRoles) { // 1. Check adjacency var key = (contract.Phase, target); if (!_transitions.TryGetValue(key, out var allowedRoles)) throw new ForbiddenException($"Không thể chuyển {contract.Phase} → {target}"); // 2. Check role if (!userRoles.Any(r => allowedRoles.Contains(r)) && !userRoles.Contains(AppRoles.Admin)) throw new ForbiddenException("Role không đủ quyền duyệt phase này"); // 3. Bypass rule if (contract.BypassProcurementAndCCM && contract.Phase == ContractPhase.DangInKy && target == ContractPhase.DangTrinhKy) return; // OK skip CCM // 4. Business rule: không cho skip phase // (adjacency table đã enforce — chỉ transition nào khai báo mới pass) } } ``` ## 5. Reject flow (ví dụ CCM reject) ```mermaid sequenceDiagram actor CCM participant FE participant API participant M as TransitionHandler participant NS participant DB CCM->>FE: Click "Yêu cầu sửa" + nhập comment FE->>API: POST /contracts/{id}/transitions
{targetPhase: "DangSoanThao", decision: "Reject", comment: "Điều khoản 5 cần rõ hơn"} API->>M: Send M->>DB: INSERT ContractApproval
(Phase=DangKiemTraCCM, Decision=Reject, Comment) M->>DB: UPDATE Contract.Phase = DangSoanThao
SlaDeadline = UtcNow + 7d Note over M: KHÔNG xóa lịch sử approval các phase cũ
— giữ history M->>NS: NotifyRejection(contract, drafter) NS->>NS: Email drafter + in-app badge M-->>API: 200 ``` **Lưu ý:** Drafter nhận thông báo + comment thread tự động có entry mới. Họ fix, rồi submit lại → re-trigger DangGopY (quay lại loop comment → duyệt). ## 6. Comment thread Endpoint riêng (không phải transition): ``` POST /api/contracts/{id}/comments Body: { content: "NCC này từng trễ giao hàng HĐ trước, cân nhắc thêm điều khoản phạt" } ``` Ghi nhận: - `ContractComments.Phase = contract.Phase hiện tại` - Hiển thị cùng timeline với approval history ## 7. Timeline UI (fe-user `/contracts/{id}`) ``` ┌────────────────────────────────────────────────────────┐ │ HĐ: FLOCK 01/HĐGK/SOL&PVL/03 │ │ NCC: Công ty PVL · Dự án: FLOCK 01 │ │ Giá trị: 150,000,000 VND │ │ Phase hiện tại: 🟡 Đang kiểm tra CCM │ │ SLA: còn 2 ngày 4 giờ │ ├────────────────────────────────────────────────────────┤ │ Timeline │ │ │ │ ● DangSoanThao 2026-04-15 10:00 Nguyen Van A │ │ │ Drafter tạo draft │ │ │ │ │ ● DangGopY 2026-04-16 08:30 Tran Thi B (PM) │ │ │ "Scope work cần chi tiết hơn mục 3" │ │ ● Le Van C (CCM) │ │ │ "OK giá" │ │ │ │ │ ● DangDamPhan 2026-04-17 14:20 Nguyen Van A │ │ ● DangInKy 2026-04-18 09:15 NCC đã ký │ │ │ │ 🟡 DangKiemTraCCM (đang ở phase này) │ │ Chờ CCM kiểm tra │ │ │ │ ⚪ DangTrinhKy │ │ ⚪ DangDongDau │ │ ⚪ DaPhatHanh │ ├────────────────────────────────────────────────────────┤ │ Action (tùy role): │ │ [✅ Duyệt → TrinhKy] [❌ Yêu cầu sửa] │ │ │ │ Comment: │ │ [Textarea] │ │ [Gửi comment] │ └────────────────────────────────────────────────────────┘ ``` ## 8. Notifications | Event | Recipient | Channel | |---|---|---| | `DangSoanThao` → `DangGopY` | Tất cả role góp ý (PD, PM, PRO, CCM, FIN, ACT) | Email + in-app | | Chuyển `DangKiemTraCCM` | Mọi user role `CostControl` | Email + in-app | | Chuyển `DangTrinhKy` | Mọi user role `Director` + `AuthorizedSigner` | Email (high priority) + in-app | | SLA còn 20% thời gian | Role đang giữ phase | In-app warning | | SLA hết → auto-approve | Drafter + role giữ phase | Email + in-app | | Reject → quay về `DangSoanThao` | Drafter (người tạo) | Email + in-app | | Mã HĐ được gen khi BOD ký | Drafter | In-app | ## 9. Audit trail Mọi transition tạo 1 row trong `ContractApprovals`. Ngoài ra, nếu cần log granular hơn, Phase 4 thêm `AuditLogs` table ghi mọi command (không chỉ workflow): ```csharp public class AuditBehavior : IPipelineBehavior { public async Task Handle(TReq request, RequestHandlerDelegate next, CancellationToken ct) { var response = await next(); // Log: command name, user, payload, timestamp _db.AuditLogs.Add(new AuditLog { ... }); await _db.SaveChangesAsync(ct); return response; } } ``` ## 10. Edge cases | Case | Xử lý | |---|---| | User A và User B cùng role CCM click "Duyệt" đồng thời | Optimistic concurrency với `RowVersion` — người sau nhận 409 Conflict | | Drafter cố submit từ `DangSoanThao` → `DangKiemTraCCM` (skip phase) | Adjacency table reject → 403 | | BOD reject ở phase `DangTrinhKy` sau khi mã HĐ đã gen | Giữ mã HĐ (không revert seq) + update Phase=`DangSoanThao`. Khi BOD duyệt lại → vẫn dùng mã cũ (không gen lại) | | User role không map đúng phase (vd Finance cố duyệt `DangKiemTraCCM`) | Guard reject 403 | | HĐ với Chủ đầu tư (`BypassProcurementAndCCM=true`) | Từ `DangInKy` → cho phép nhảy `DangTrinhKy` bỏ qua CCM | | Xóa HĐ ở phase > `DangInKy` | Block — soft delete cũng không cho (business rule) | ## 11. Liên quan - [`../workflow-contract.md`](../workflow-contract.md) — spec gốc - [`sla-expiry-flow.md`](sla-expiry-flow.md) — auto-approve job - [`contract-creation-flow.md`](contract-creation-flow.md) — tạo HĐ trước khi vào flow này - [`form-render-flow.md`](form-render-flow.md) — render file khi chuyển phase