docs/database/database-guide.md: - Conventions (naming, data types, audit fields, soft delete) - Schema hien tai (Identity tables sau migration Init) + seed 12 role + admin - Schema planned: Phase 1 dot 2 (Supplier/Project/Department + Permission Matrix) - Schema planned: Phase 3 (Contract + Approval + Comment + Attachment + Template + Clause + CodeSequence) - Mermaid ERD cho tung phase - Migration workflow (create/apply/revert) - Index strategy + unique indexes - Backup/restore SQL - Common pitfalls + SQL cheatsheet docs/flows/ — 6 flow documentation: - README.md: index - auth-flow.md: login/refresh/me/logout (IMPLEMENTED, sequence + edge cases + security checklist) - permission-flow.md: Phase 1 dot 2 - Role x MenuKey x CRUD resolution + FE guard + BE policy - contract-creation-flow.md: Phase 2 - Drafter flow chon template -> fill -> preview -> save draft - contract-approval-flow.md: Phase 3 - state machine 9 phase chi tiet + reject flow + timeline UI - form-render-flow.md: Phase 2 - OpenXml + ClosedXML + LibreOffice PDF convert - sla-expiry-flow.md: Phase 3 - BackgroundService auto-approve qua SLA + warning notify Update references: - CLAUDE.md (root): them 2 row Tai lieu quan trong - docs/CLAUDE.md: update project layout voi flows/ + database/ - docs/STATUS.md: log docs addition - docs/changelog/migration-todos.md: tick Phase 0 docs items Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
279 lines
12 KiB
Markdown
279 lines
12 KiB
Markdown
# 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<br/>(role cụ thể)
|
|
participant FE as fe-user
|
|
participant API as ContractsController<br/>.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<br/>{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:<br/>1. currentPhase allows transition to targetPhase?<br/>2. user role đủ quyền ở phase này?<br/>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<br/>(ContractId, Phase=currentPhase,<br/>ApproverUserId, Decision, Comment)
|
|
M->>DB: UPDATE Contract SET Phase = targetPhase,<br/>SlaDeadline = UtcNow + PhaseSla
|
|
|
|
M->>NS: NotifyPhaseChangeAsync(contract, oldPhase, newPhase)
|
|
NS->>NS: Query users trong role eligible<br/>for newPhase
|
|
NS->>NS: Send email (MailKit) + in-app notify
|
|
Note over NS: (Phase 3 Iteration 2) SignalR<br/>push notification real-time
|
|
|
|
M-->>API: TransitionResultDto
|
|
API-->>FE: 200
|
|
|
|
FE->>FE: Invalidate TanStack query<br/>['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<string> 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<br/>{targetPhase: "DangSoanThao", decision: "Reject", comment: "Điều khoản 5 cần rõ hơn"}
|
|
API->>M: Send
|
|
|
|
M->>DB: INSERT ContractApproval<br/>(Phase=DangKiemTraCCM, Decision=Reject, Comment)
|
|
M->>DB: UPDATE Contract.Phase = DangSoanThao<br/>SlaDeadline = UtcNow + 7d
|
|
|
|
Note over M: KHÔNG xóa lịch sử approval các phase cũ<br/>— 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<TReq, TRes> : IPipelineBehavior<TReq, TRes>
|
|
{
|
|
public async Task<TRes> Handle(TReq request, RequestHandlerDelegate<TRes> 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
|