[CLAUDE] Docs: database-guide + 6 flow diagrams
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>
This commit is contained in:
@ -68,6 +68,8 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
|
||||
| [`docs/CLAUDE.md`](docs/CLAUDE.md) | Full context — tech stack, architecture |
|
||||
| [`docs/workflow-contract.md`](docs/workflow-contract.md) | State machine 9 phase + role matrix |
|
||||
| [`docs/forms-spec.md`](docs/forms-spec.md) | Catalog 8 form + quy định mã HĐ |
|
||||
| [`docs/database/database-guide.md`](docs/database/database-guide.md) | DB conventions + schema hiện tại + planned + ERD |
|
||||
| [`docs/flows/README.md`](docs/flows/README.md) | Index 6 flow (auth, permission, contract create/approve, form render, SLA) |
|
||||
|
||||
## ⚠️ Kết thúc session
|
||||
|
||||
|
||||
@ -60,8 +60,17 @@ SOLUTION_ERP/
|
||||
│ ├── PROJECT-MAP.md
|
||||
│ ├── forms-spec.md ⭐ 8 form catalog + RG-001 code format
|
||||
│ ├── workflow-contract.md ⭐ 9 phase state machine + role matrix
|
||||
│ ├── database/
|
||||
│ │ └── database-guide.md ⭐ Conventions, schema hiện tại + planned, ERD, migration workflow
|
||||
│ ├── flows/ ⭐ Sequence/state diagram mỗi feature
|
||||
│ │ ├── README.md (index)
|
||||
│ │ ├── auth-flow.md ✅ implemented
|
||||
│ │ ├── permission-flow.md 📝 Phase 1 đợt 2
|
||||
│ │ ├── contract-creation-flow.md 📝 Phase 2
|
||||
│ │ ├── contract-approval-flow.md 📝 Phase 3
|
||||
│ │ ├── form-render-flow.md 📝 Phase 2
|
||||
│ │ └── sla-expiry-flow.md 📝 Phase 3
|
||||
│ ├── guides/ setup, cicd, code-rules...
|
||||
│ ├── database/ schema docs
|
||||
│ └── changelog/
|
||||
│ ├── migration-todos.md Roadmap 5 phase
|
||||
│ └── sessions/ YYYY-MM-DD session logs
|
||||
|
||||
@ -14,7 +14,8 @@ _(không có — Phase 1 foundation xong, chờ quyết định bước tiếp)_
|
||||
|
||||
| Ngày | Ai | Task | Commit |
|
||||
|---|---|---|---|
|
||||
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE (Clean Arch + Identity + JWT + migration) + FE (2 app, Tailwind 4, Router, AuthContext, Login) — E2E login pass qua Vite proxy | (chưa commit, sắp xong) |
|
||||
| 2026-04-21 | Claude | **Docs addition** — `database-guide.md` (conventions + schema + ERD + migration workflow) + `flows/` 6 doc (auth implemented + permission/contract-create/contract-approve/form-render/sla-expiry planned) | (sắp commit) |
|
||||
| 2026-04-21 | Claude | **Phase 1 foundation HOÀN TẤT** — BE (Clean Arch + Identity + JWT + migration) + FE (2 app, Tailwind 4, Router, AuthContext, Login) — E2E login pass qua Vite proxy | `702411f` |
|
||||
| 2026-04-21 | Claude | **Phase 0 HOÀN TẤT** — scaffold + parse FORM/QUY_TRINH + docs + skills + git init | `25dad7f` |
|
||||
|
||||
Session logs:
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
- [x] Parse 8 form → `docs/forms-spec.md`
|
||||
- [x] Parse quy trình → `docs/workflow-contract.md`
|
||||
- [x] Viết `docs/{CLAUDE,STATUS,PROJECT-MAP}.md`
|
||||
- [x] Viết `docs/database/database-guide.md` (conventions + schema + ERD + migration workflow)
|
||||
- [x] Viết `docs/flows/` — README + 6 flow doc (auth, permission, contract-create, contract-approve, form-render, sla-expiry)
|
||||
- [x] Viết `.gitignore`, `README.md`, `global.json`, `docker-compose.yml`
|
||||
- [x] Tạo placeholder skill folders: `contract-workflow`, `form-engine`, `permission-matrix`
|
||||
- [x] `git init` + commit đầu (`25dad7f`)
|
||||
|
||||
463
docs/database/database-guide.md
Normal file
463
docs/database/database-guide.md
Normal file
@ -0,0 +1,463 @@
|
||||
# Database Guide — SOLUTION_ERP
|
||||
|
||||
> **DB engine:** SQL Server 2022 (LocalDB dev, full instance prod)
|
||||
> **ORM:** EF Core 10 Code-First migrations
|
||||
> **DbContext:** [`ApplicationDbContext`](../../src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs) extends `IdentityDbContext<User, Role, Guid>`
|
||||
|
||||
## 1. Connection strings
|
||||
|
||||
| Env | ConnectionString | DB name |
|
||||
|---|---|---|
|
||||
| Dev (default) | `Server=(localdb)\MSSQLLocalDB;Database=SolutionErp_Dev;Trusted_Connection=True` | `SolutionErp_Dev` |
|
||||
| Design-time (EF CLI) | same server, `Database=SolutionErp_Design` | `SolutionErp_Design` |
|
||||
| Prod | `Server=<prod>;Database=SolutionErp;User=<>;Password=<>;TrustServerCertificate=true` | `SolutionErp` |
|
||||
|
||||
Cả 3 DB dùng chung schema, khác tên → có thể drop `SolutionErp_Design` không mất dữ liệu.
|
||||
|
||||
## 2. Conventions
|
||||
|
||||
### Naming
|
||||
|
||||
| Item | Rule | Ví dụ |
|
||||
|---|---|---|
|
||||
| Schema | 1 schema duy nhất: `dbo` | — |
|
||||
| Table | PascalCase tiếng Anh, số nhiều | `Contracts`, `Suppliers`, `ContractApprovals` |
|
||||
| Column | PascalCase | `FullName`, `CreatedAt`, `SupplierId` |
|
||||
| PK | `Id` | `Guid` (không auto-increment) |
|
||||
| FK | `{EntityName}Id` | `SupplierId`, `ContractId` |
|
||||
| Index | `IX_{Table}_{Col1}_{Col2}` | `IX_Contracts_Phase_SupplierId` |
|
||||
| Unique | `UX_{Table}_{Col}` | `UX_Suppliers_Code` |
|
||||
| Migration | `YYYYMMDDHHMMSS_{Name}` (auto) | `20260421034520_Init` |
|
||||
|
||||
### Data types
|
||||
|
||||
| C# type | SQL type | Notes |
|
||||
|---|---|---|
|
||||
| `Guid` | `uniqueidentifier` | PK + FK default |
|
||||
| `string` | `nvarchar(max)` default | Luôn set `HasMaxLength(n)` trong config |
|
||||
| `DateTime` | `datetime2` | UTC — luôn qua `IDateTime.UtcNow` |
|
||||
| `decimal` (tiền) | `decimal(18,2)` | `HasPrecision(18, 2)` |
|
||||
| `int` enum | `int` | `HasConversion<int>()` |
|
||||
| `bool` | `bit` | — |
|
||||
| JSON blob | `nvarchar(max)` | `HasColumnType("nvarchar(max)")` — dùng `HasConversion` serialize |
|
||||
|
||||
### Audit fields (mọi entity extend `BaseEntity`)
|
||||
|
||||
```csharp
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public DateTime CreatedAt { get; set; } // Set by AuditingInterceptor
|
||||
public DateTime? UpdatedAt { get; set; } // Set by AuditingInterceptor
|
||||
public Guid? CreatedBy { get; set; } // From ICurrentUser.UserId
|
||||
public Guid? UpdatedBy { get; set; } // From ICurrentUser.UserId
|
||||
```
|
||||
|
||||
### Soft delete (entity extend `AuditableEntity`)
|
||||
|
||||
```csharp
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public Guid? DeletedBy { get; set; }
|
||||
```
|
||||
|
||||
[`AuditingInterceptor`](../../src/Backend/SolutionErp.Infrastructure/Persistence/Interceptors/AuditingInterceptor.cs) tự động:
|
||||
- `Added` entity → set `CreatedAt`, `CreatedBy`
|
||||
- `Modified` entity → set `UpdatedAt`, `UpdatedBy`
|
||||
- `Deleted` entity kiểu `AuditableEntity` → convert sang `Modified` + set `IsDeleted=true`, `DeletedAt`, `DeletedBy` (soft delete)
|
||||
|
||||
**Query filter soft delete:** config `modelBuilder.Entity<T>().HasQueryFilter(e => !e.IsDeleted)` cho mọi `AuditableEntity`. Sẽ làm Phase 1 đợt 2.
|
||||
|
||||
## 3. Schema hiện tại (sau migration `Init`)
|
||||
|
||||
### Identity tables (đã có sau migration 1)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Users ||--o{ UserRoles : has
|
||||
Roles ||--o{ UserRoles : has
|
||||
Users ||--o{ UserClaims : has
|
||||
Users ||--o{ UserLogins : has
|
||||
Users ||--o{ UserTokens : has
|
||||
Roles ||--o{ RoleClaims : has
|
||||
|
||||
Users {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar FullName "200"
|
||||
nvarchar Email
|
||||
nvarchar UserName
|
||||
nvarchar PasswordHash
|
||||
nvarchar RefreshToken "512"
|
||||
datetime2 RefreshTokenExpiresAt
|
||||
bit IsActive
|
||||
datetime2 CreatedAt
|
||||
datetime2 UpdatedAt
|
||||
bit EmailConfirmed
|
||||
bit LockoutEnabled
|
||||
datetime2 LockoutEnd
|
||||
int AccessFailedCount
|
||||
}
|
||||
Roles {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar Name
|
||||
nvarchar NormalizedName
|
||||
nvarchar Description "500"
|
||||
datetime2 CreatedAt
|
||||
}
|
||||
UserRoles {
|
||||
uniqueidentifier UserId PK,FK
|
||||
uniqueidentifier RoleId PK,FK
|
||||
}
|
||||
UserClaims {
|
||||
int Id PK
|
||||
uniqueidentifier UserId FK
|
||||
nvarchar ClaimType
|
||||
nvarchar ClaimValue
|
||||
}
|
||||
UserLogins {
|
||||
nvarchar LoginProvider PK
|
||||
nvarchar ProviderKey PK
|
||||
uniqueidentifier UserId FK
|
||||
}
|
||||
UserTokens {
|
||||
uniqueidentifier UserId PK,FK
|
||||
nvarchar LoginProvider PK
|
||||
nvarchar Name PK
|
||||
nvarchar Value
|
||||
}
|
||||
RoleClaims {
|
||||
int Id PK
|
||||
uniqueidentifier RoleId FK
|
||||
nvarchar ClaimType
|
||||
nvarchar ClaimValue
|
||||
}
|
||||
```
|
||||
|
||||
### Seed data hiện tại
|
||||
|
||||
[`DbInitializer`](../../src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs) auto-run khi API start:
|
||||
|
||||
**12 role:**
|
||||
|
||||
| Role | Ý nghĩa (theo workflow-contract.md) |
|
||||
|---|---|
|
||||
| `Admin` | Quản trị hệ thống |
|
||||
| `Drafter` | Người soạn thảo (QS/NV.PB) |
|
||||
| `DeptManager` | Trưởng Phòng ban (TPB) |
|
||||
| `ProjectManager` | Giám đốc Dự án (PM) |
|
||||
| `Procurement` | Phòng Cung ứng (PRO) |
|
||||
| `CostControl` | Phòng Kiểm soát Chi phí (CCM) |
|
||||
| `Finance` | Phòng Tài chính (FIN) |
|
||||
| `Accounting` | Phòng Kế toán (ACT) |
|
||||
| `Equipment` | Phòng Thiết bị (EQU) |
|
||||
| `Director` | Ban Giám đốc (BOD) |
|
||||
| `AuthorizedSigner` | Người được ủy quyền ký (NĐUQ) |
|
||||
| `HrAdmin` | Nhân sự - Hành chính (HRA) |
|
||||
|
||||
**1 admin user:**
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Email | `admin@solutionerp.local` |
|
||||
| Password | `Admin@123456` |
|
||||
| FullName | `Administrator` |
|
||||
| Roles | `[Admin]` |
|
||||
|
||||
## 4. Schema dự kiến
|
||||
|
||||
### Phase 1 đợt 2 — Master data
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Suppliers {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar Code "50 UNIQUE"
|
||||
nvarchar Name "200"
|
||||
nvarchar TaxCode "20"
|
||||
nvarchar Phone "20"
|
||||
nvarchar Email "100"
|
||||
nvarchar Address "500"
|
||||
int Type "NCC/NTP/TD/DVDV"
|
||||
datetime2 CreatedAt
|
||||
datetime2 UpdatedAt
|
||||
uniqueidentifier CreatedBy FK
|
||||
uniqueidentifier UpdatedBy FK
|
||||
bit IsDeleted
|
||||
}
|
||||
Projects {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar Code "50 UNIQUE"
|
||||
nvarchar Name "200"
|
||||
date StartDate
|
||||
date EndDate
|
||||
uniqueidentifier ManagerUserId FK
|
||||
}
|
||||
Departments {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar Code "50 UNIQUE"
|
||||
nvarchar Name "200"
|
||||
uniqueidentifier ManagerUserId FK
|
||||
}
|
||||
Users ||--o{ Projects : manages
|
||||
Users ||--o{ Departments : manages
|
||||
```
|
||||
|
||||
### Phase 1 đợt 2 — Permission Matrix
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
MenuItems ||--o{ Permissions : has
|
||||
Roles ||--o{ Permissions : has
|
||||
|
||||
MenuItems {
|
||||
nvarchar Key PK "PascalCase, vd Contracts"
|
||||
nvarchar Label "200 tieng Viet"
|
||||
nvarchar ParentKey FK "NULL neu root"
|
||||
int Order
|
||||
nvarchar Icon "50 lucide icon name"
|
||||
}
|
||||
Permissions {
|
||||
uniqueidentifier Id PK
|
||||
uniqueidentifier RoleId FK
|
||||
nvarchar MenuKey FK
|
||||
bit CanRead
|
||||
bit CanCreate
|
||||
bit CanUpdate
|
||||
bit CanDelete
|
||||
}
|
||||
```
|
||||
|
||||
Unique key: `(RoleId, MenuKey)`.
|
||||
|
||||
### Phase 3 — Contract + Workflow
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Contracts ||--o{ ContractApprovals : has
|
||||
Contracts ||--o{ ContractComments : has
|
||||
Contracts ||--o{ ContractAttachments : has
|
||||
Contracts }o--|| Suppliers : references
|
||||
Contracts }o--|| Projects : references
|
||||
Contracts }o--|| Departments : drafted_in
|
||||
Contracts }o--|| ContractTemplates : uses
|
||||
|
||||
Contracts {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar MaHopDong "100 nullable until Phase5"
|
||||
int Type "ContractType enum"
|
||||
int Phase "ContractPhase enum 9 state"
|
||||
uniqueidentifier SupplierId FK
|
||||
uniqueidentifier ProjectId FK
|
||||
uniqueidentifier DepartmentId FK
|
||||
uniqueidentifier DrafterUserId FK
|
||||
uniqueidentifier TemplateId FK
|
||||
decimal GiaTri "18 2"
|
||||
bit BypassProcurementAndCCM
|
||||
datetime2 SlaDeadline "khi nao phase hien tai het han"
|
||||
nvarchar DraftData "nvarchar max JSON field values"
|
||||
}
|
||||
ContractApprovals {
|
||||
uniqueidentifier Id PK
|
||||
uniqueidentifier ContractId FK
|
||||
int Phase
|
||||
uniqueidentifier ApproverUserId FK
|
||||
datetime2 ApprovedAt
|
||||
int Decision "Pending/Approve/Reject/AutoApprove"
|
||||
nvarchar Comment "1000"
|
||||
}
|
||||
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"
|
||||
nvarchar Purpose "50 Scan/Export/Template"
|
||||
datetime2 UploadedAt
|
||||
}
|
||||
ContractTemplates {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar FormCode "20 UNIQUE vd FO-002.05"
|
||||
nvarchar Name "200"
|
||||
int ContractType
|
||||
nvarchar TemplatePath "500"
|
||||
nvarchar FieldSpec "nvarchar max JSON"
|
||||
bit IsActive
|
||||
}
|
||||
ContractClauses {
|
||||
uniqueidentifier Id PK
|
||||
nvarchar Code "20 UNIQUE vd FO-002.04"
|
||||
nvarchar Name "200"
|
||||
nvarchar Content "nvarchar max rich text"
|
||||
int Version
|
||||
bit IsActive
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3 — Contract Code Generator
|
||||
|
||||
`ContractCodeSequence` bảng đơn giản để tránh race condition khi gen seq:
|
||||
|
||||
```sql
|
||||
CREATE TABLE dbo.ContractCodeSequences (
|
||||
Prefix NVARCHAR(100) NOT NULL PRIMARY KEY, -- "FLOCK 01/HĐTP/SOL&HD" hoặc "2026/NCC/SOL&AKATI"
|
||||
LastSeq INT NOT NULL,
|
||||
UpdatedAt DATETIME2 NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Logic trong `ContractCodeGenerator`:
|
||||
1. Begin transaction `SERIALIZABLE`
|
||||
2. `SELECT LastSeq FROM ContractCodeSequences WITH (UPDLOCK, HOLDLOCK) WHERE Prefix = @prefix`
|
||||
3. Nếu không có → INSERT với `LastSeq = 1`
|
||||
4. Nếu có → `UPDATE ContractCodeSequences SET LastSeq = LastSeq + 1`
|
||||
5. Return `{prefix}/{LastSeq:D2}`
|
||||
6. Commit
|
||||
|
||||
## 5. Migration workflow
|
||||
|
||||
### Tạo migration mới
|
||||
|
||||
```powershell
|
||||
cd D:\Dropbox\CONG_VIEC\SOLUTION\SOLUTION_ERP
|
||||
dotnet ef migrations add <Name> `
|
||||
--project src\Backend\SolutionErp.Infrastructure `
|
||||
--startup-project src\Backend\SolutionErp.Api `
|
||||
--output-dir Persistence\Migrations
|
||||
```
|
||||
|
||||
Ví dụ Phase 1 đợt 2:
|
||||
```
|
||||
dotnet ef migrations add AddMasterData
|
||||
dotnet ef migrations add AddPermissions
|
||||
dotnet ef migrations add AddContractDraft
|
||||
```
|
||||
|
||||
Phase 3:
|
||||
```
|
||||
dotnet ef migrations add AddContractWorkflow
|
||||
dotnet ef migrations add AddContractCodeSequence
|
||||
```
|
||||
|
||||
### Apply migration
|
||||
|
||||
```powershell
|
||||
dotnet ef database update `
|
||||
--project src\Backend\SolutionErp.Infrastructure `
|
||||
--startup-project src\Backend\SolutionErp.Api
|
||||
```
|
||||
|
||||
Hoặc tự động khi API start (đã config trong `DbInitializer.InitializeAsync` → `db.Database.MigrateAsync()`).
|
||||
|
||||
### Revert migration
|
||||
|
||||
```powershell
|
||||
# Rollback 1 bước
|
||||
dotnet ef database update <PreviousMigrationName>
|
||||
|
||||
# Xóa file migration chưa apply
|
||||
dotnet ef migrations remove
|
||||
```
|
||||
|
||||
### Revert DB về trạng thái clean
|
||||
|
||||
```sql
|
||||
-- Chạy trong SSMS hoặc sqlcmd
|
||||
DROP DATABASE SolutionErp_Dev;
|
||||
-- Hoặc giữ DB, chỉ xóa data:
|
||||
EXEC sp_MSforeachtable 'DELETE FROM ?';
|
||||
```
|
||||
|
||||
Rồi chạy lại API → tự migrate + seed.
|
||||
|
||||
## 6. Index strategy (Phase 3-4 optimize)
|
||||
|
||||
### Hot queries cần index
|
||||
|
||||
| Query | Index đề xuất |
|
||||
|---|---|
|
||||
| Inbox — HĐ theo phase + role của user | `IX_Contracts_Phase_IsDeleted` |
|
||||
| List HĐ theo NCC | `IX_Contracts_SupplierId_IsDeleted` |
|
||||
| List HĐ theo dự án | `IX_Contracts_ProjectId_IsDeleted` |
|
||||
| Approval của 1 HĐ | `IX_ContractApprovals_ContractId_Phase` |
|
||||
| Comment thread | `IX_ContractComments_ContractId_CreatedAt` |
|
||||
| Audit log | `IX_AuditLogs_EntityId_CreatedAt` |
|
||||
|
||||
### Index unique
|
||||
|
||||
| Unique | Cột |
|
||||
|---|---|
|
||||
| `UX_Suppliers_Code` | `Code` |
|
||||
| `UX_Projects_Code` | `Code` |
|
||||
| `UX_Departments_Code` | `Code` |
|
||||
| `UX_Contracts_MaHopDong` | `MaHopDong` (WHERE `MaHopDong IS NOT NULL` — filtered) |
|
||||
| `UX_Users_Email` | đã có qua Identity |
|
||||
| `UX_MenuItems_Key` | PK |
|
||||
| `UX_Permissions_Role_Menu` | `(RoleId, MenuKey)` |
|
||||
|
||||
## 7. Backup & restore (prod)
|
||||
|
||||
### Backup daily (SQL Agent job)
|
||||
|
||||
```sql
|
||||
BACKUP DATABASE SolutionErp
|
||||
TO DISK = 'D:\Backups\SolutionErp_full.bak'
|
||||
WITH INIT, COMPRESSION, CHECKSUM;
|
||||
```
|
||||
|
||||
### Backup log mỗi 15 phút (nếu full recovery model)
|
||||
|
||||
```sql
|
||||
BACKUP LOG SolutionErp
|
||||
TO DISK = 'D:\Backups\SolutionErp_log.trn'
|
||||
WITH INIT;
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```sql
|
||||
RESTORE DATABASE SolutionErp
|
||||
FROM DISK = 'D:\Backups\SolutionErp_full.bak'
|
||||
WITH REPLACE, RECOVERY;
|
||||
```
|
||||
|
||||
Chi tiết runbook ở Phase 5 `docs/guides/cicd.md`.
|
||||
|
||||
## 8. Common pitfalls
|
||||
|
||||
- **Quên `HasMaxLength`** → `nvarchar(max)`, gây bloat + index lỗi. Luôn set.
|
||||
- **Lưu DateTime local thay vì UTC** → sai timezone khi deploy server khác múi giờ. Dùng `IDateTime.UtcNow` (tiêm qua DI), KHÔNG `DateTime.Now`.
|
||||
- **Soft delete quên filter** → user thấy data đã xóa. Luôn `HasQueryFilter(e => !e.IsDeleted)` ở config.
|
||||
- **FK không có index** → slow join. EF Core tự tạo index cho FK, nhưng vẫn phải verify sau migration.
|
||||
- **Migration bị commit thiếu `.Designer.cs`** → team khác không apply được. Commit cả 3 file (`{name}.cs`, `{name}.Designer.cs`, `ApplicationDbContextModelSnapshot.cs`).
|
||||
- **Chạy `dotnet ef` khi API đang chạy** → lock conflict. Dừng API trước khi migrate ở dev.
|
||||
- **Gen mã HĐ ngoài transaction** → race condition. Luôn dùng `ContractCodeGenerator` với lock.
|
||||
|
||||
## 9. Quick SQL cheatsheet
|
||||
|
||||
```sql
|
||||
-- Xem 10 HĐ mới nhất
|
||||
SELECT TOP 10 Id, MaHopDong, Phase, CreatedAt FROM Contracts ORDER BY CreatedAt DESC;
|
||||
|
||||
-- Đếm HĐ theo phase
|
||||
SELECT Phase, COUNT(*) FROM Contracts WHERE IsDeleted = 0 GROUP BY Phase;
|
||||
|
||||
-- List user + roles
|
||||
SELECT u.Email, u.FullName, r.Name AS Role
|
||||
FROM Users u
|
||||
LEFT JOIN UserRoles ur ON u.Id = ur.UserId
|
||||
LEFT JOIN Roles r ON ur.RoleId = r.Id
|
||||
ORDER BY u.Email;
|
||||
|
||||
-- Reset admin password (emergency)
|
||||
-- Cách an toàn: không edit PasswordHash trực tiếp, dùng UserManager.ResetPasswordAsync qua API
|
||||
|
||||
-- Clear toàn bộ dev data (giữ migrations)
|
||||
EXEC sp_MSforeachtable 'DELETE FROM ?';
|
||||
```
|
||||
30
docs/flows/README.md
Normal file
30
docs/flows/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Flows — SOLUTION_ERP
|
||||
|
||||
> Document các luồng (process / sequence) chính của hệ thống. Mỗi flow có mermaid sequence diagram + API calls + side effects + edge case.
|
||||
|
||||
## Index
|
||||
|
||||
| Flow | Phase | Trạng thái | Doc |
|
||||
|---|---|---|---|
|
||||
| **Authentication** — login, refresh, logout, /me | 1 | ✅ Implemented | [`auth-flow.md`](auth-flow.md) |
|
||||
| **Permission resolution** — resolve menu + CRUD cho user | 1 đợt 2 | 📝 Planned | [`permission-flow.md`](permission-flow.md) |
|
||||
| **Contract creation** — tạo HĐ draft + fill form template | 2 | 📝 Planned | [`contract-creation-flow.md`](contract-creation-flow.md) |
|
||||
| **Contract approval** — state machine 9 phase | 3 | 📝 Planned | [`contract-approval-flow.md`](contract-approval-flow.md) |
|
||||
| **Form render** — template engine xuất docx/xlsx | 2 | 📝 Planned | [`form-render-flow.md`](form-render-flow.md) |
|
||||
| **SLA expiry auto-approve** — hosted service | 3 | 📝 Planned | [`sla-expiry-flow.md`](sla-expiry-flow.md) |
|
||||
|
||||
## Quy ước đọc
|
||||
|
||||
- **Actor:** vai trò khi thực hiện (user role hoặc system)
|
||||
- **Entry point:** ai/gì trigger
|
||||
- **API calls:** controller + endpoint (hoặc internal service)
|
||||
- **Side effects:** DB writes, file writes, notifications
|
||||
- **Edge cases:** các path lỗi / alternate
|
||||
|
||||
Tất cả mermaid sequence/state/flowchart có thể render ở VS Code (extension Markdown Preview Mermaid), GitHub, Gitea, Gitea MD preview.
|
||||
|
||||
## Liên quan
|
||||
|
||||
- [`../workflow-contract.md`](../workflow-contract.md) — spec 9 phase + role matrix (domain-centric)
|
||||
- [`../forms-spec.md`](../forms-spec.md) — 8 form + mã HĐ format
|
||||
- [`../database/database-guide.md`](../database/database-guide.md) — schema chi tiết
|
||||
212
docs/flows/auth-flow.md
Normal file
212
docs/flows/auth-flow.md
Normal file
@ -0,0 +1,212 @@
|
||||
# Auth Flow — Login / Refresh / Logout / Me
|
||||
|
||||
> **Status:** ✅ Implemented (Phase 1 foundation, commit `702411f`)
|
||||
> **Actors:** Anyone (login) | Authenticated user (refresh, me, logout)
|
||||
|
||||
## 1. Tổng quan
|
||||
|
||||
- **Schema:** JWT Bearer (HS256) + refresh token (random 64-byte base64)
|
||||
- **Access token:** 1 giờ (config `Jwt:AccessTokenExpiryMinutes`)
|
||||
- **Refresh token:** 7 ngày (config `Jwt:RefreshTokenExpiryDays`)
|
||||
- **Storage FE:** `localStorage` — key `solution-erp-admin-token` (fe-admin) / `solution-erp-user-token` (fe-user)
|
||||
- **Truyền:** header `Authorization: Bearer <token>` (axios interceptor auto)
|
||||
|
||||
## 2. Login
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant FE as Frontend<br/>(LoginPage)
|
||||
participant API as SolutionErp.Api<br/>AuthController
|
||||
participant M as MediatR<br/>LoginCommandHandler
|
||||
participant UM as UserManager<User>
|
||||
participant J as IJwtTokenService
|
||||
participant DB as SQL Server
|
||||
|
||||
U->>FE: Nhập email + password → Submit
|
||||
FE->>API: POST /api/auth/login<br/>{email, password}
|
||||
API->>M: Send(LoginCommand)
|
||||
|
||||
M->>M: Validate (FluentValidation)<br/>email format + password min 6
|
||||
alt Validation fail
|
||||
M-->>API: throw ValidationException
|
||||
API-->>FE: 400 ProblemDetails + errors{}
|
||||
FE->>U: Toast error
|
||||
end
|
||||
|
||||
M->>UM: FindByEmailAsync(email)
|
||||
UM->>DB: SELECT FROM Users WHERE Email = ?
|
||||
alt User not found OR IsActive = false
|
||||
M-->>API: throw UnauthorizedException
|
||||
API-->>FE: 401 "Email hoặc mật khẩu không đúng"
|
||||
end
|
||||
|
||||
M->>UM: CheckPasswordAsync(user, password)
|
||||
alt Password sai
|
||||
M-->>API: throw UnauthorizedException
|
||||
API-->>FE: 401
|
||||
end
|
||||
|
||||
M->>UM: GetRolesAsync(user)
|
||||
UM->>DB: SELECT Roles JOIN UserRoles
|
||||
UM-->>M: roles[]
|
||||
|
||||
M->>J: GenerateTokensAsync(user, roles)
|
||||
J->>J: Build claims (sub, email, jti, fullName, roles)
|
||||
J->>J: Sign HS256 + random refresh token
|
||||
J-->>M: (accessToken, refreshToken, refreshTokenExpiresAt)
|
||||
|
||||
M->>UM: UpdateAsync(user) — save refresh token + expiry
|
||||
UM->>DB: UPDATE Users SET RefreshToken=?, RefreshTokenExpiresAt=?
|
||||
|
||||
M-->>API: AuthResponseDto
|
||||
API-->>FE: 200 {accessToken, refreshToken, user: {id, email, fullName, roles}}
|
||||
|
||||
FE->>FE: localStorage.setItem('solution-erp-*-token', accessToken)<br/>+ lưu user JSON
|
||||
FE->>FE: setUser(user) via AuthContext
|
||||
FE->>U: Redirect /dashboard hoặc /inbox + toast success
|
||||
```
|
||||
|
||||
**Code pointers:**
|
||||
- FE: [`fe-admin/src/pages/LoginPage.tsx`](../../fe-admin/src/pages/LoginPage.tsx), [`fe-admin/src/contexts/AuthContext.tsx`](../../fe-admin/src/contexts/AuthContext.tsx)
|
||||
- BE controller: [`AuthController.Login`](../../src/Backend/SolutionErp.Api/Controllers/AuthController.cs)
|
||||
- Handler: [`LoginCommandHandler`](../../src/Backend/SolutionErp.Application/Auth/Commands/Login/LoginCommand.cs)
|
||||
- Token gen: [`JwtTokenService`](../../src/Backend/SolutionErp.Infrastructure/Identity/JwtTokenService.cs)
|
||||
|
||||
## 3. Authenticated request (mọi API khác)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant AX as axios interceptor
|
||||
participant API as API
|
||||
participant JWT as JwtBearerMiddleware
|
||||
participant CTRL as Controller
|
||||
|
||||
FE->>AX: api.get('/suppliers')
|
||||
AX->>AX: Đọc localStorage token
|
||||
AX->>API: GET /api/suppliers<br/>Authorization: Bearer <token>
|
||||
|
||||
API->>JWT: ValidateToken
|
||||
alt Token invalid / expired
|
||||
JWT-->>API: 401
|
||||
API-->>AX: 401
|
||||
AX->>AX: Clear localStorage + redirect /login
|
||||
end
|
||||
|
||||
JWT->>API: HttpContext.User = ClaimsPrincipal
|
||||
API->>CTRL: Invoke action (có [Authorize])
|
||||
CTRL->>CTRL: ICurrentUser.UserId (từ claim "sub")
|
||||
CTRL-->>FE: 200 data
|
||||
```
|
||||
|
||||
**401 handling:** [`fe-admin/src/lib/api.ts`](../../fe-admin/src/lib/api.ts) interceptor catches 401 → clear localStorage → `window.location.href = '/login'`.
|
||||
|
||||
## 4. Refresh token
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant API as AuthController.Refresh
|
||||
participant M as RefreshTokenCommandHandler
|
||||
participant UM as UserManager
|
||||
participant J as IJwtTokenService
|
||||
participant DB
|
||||
|
||||
Note over FE: Access token expired<br/>(server trả 401 từ /api/whatever)
|
||||
FE->>API: POST /api/auth/refresh<br/>{refreshToken}
|
||||
API->>M: Send(RefreshTokenCommand)
|
||||
|
||||
M->>M: Validate not empty
|
||||
M->>UM: Users.First(RefreshToken == ?)
|
||||
UM->>DB: SELECT
|
||||
alt Not found OR RefreshTokenExpiresAt < UtcNow
|
||||
M-->>API: throw UnauthorizedException
|
||||
API-->>FE: 401 "Refresh token không hợp lệ hoặc đã hết hạn"
|
||||
FE->>FE: Clear localStorage + redirect /login
|
||||
end
|
||||
|
||||
M->>UM: GetRolesAsync(user)
|
||||
M->>J: GenerateTokensAsync(user, roles)
|
||||
J-->>M: new (access, refresh, expiry)
|
||||
|
||||
M->>UM: UpdateAsync — rotate refresh token
|
||||
DB-->>UM: OK
|
||||
|
||||
M-->>API: AuthResponseDto
|
||||
API-->>FE: 200 new tokens
|
||||
FE->>FE: Update localStorage + retry original request
|
||||
```
|
||||
|
||||
**⚠️ Chưa implement ở FE:** hiện tại 401 → logout luôn. Phase 1 đợt 2 sẽ thêm logic auto-refresh trong axios interceptor (retry failed request sau khi refresh thành công, queue các request song song).
|
||||
|
||||
## 5. /me (lấy user hiện tại)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U
|
||||
participant FE
|
||||
participant API as AuthController.Me
|
||||
participant M as GetMeQueryHandler
|
||||
participant CU as ICurrentUser
|
||||
participant UM as UserManager
|
||||
|
||||
U->>FE: App bootstrap (sau login)
|
||||
FE->>API: GET /api/auth/me<br/>Bearer <token>
|
||||
API->>M: Send(GetMeQuery)
|
||||
|
||||
M->>CU: UserId (from claim "sub")
|
||||
alt Not authenticated
|
||||
M-->>API: throw UnauthorizedException
|
||||
end
|
||||
|
||||
M->>UM: FindByIdAsync(userId.ToString())
|
||||
alt Not found
|
||||
M-->>API: throw UnauthorizedException
|
||||
end
|
||||
|
||||
M->>UM: GetRolesAsync(user)
|
||||
M-->>API: UserInfoDto{id, email, fullName, roles}
|
||||
API-->>FE: 200
|
||||
```
|
||||
|
||||
**Use case:** FE dùng để refresh user info sau page reload (nếu localStorage cũ nhưng role có thể thay đổi). Hiện Phase 1 FE lấy user từ localStorage trực tiếp — Phase 1 đợt 2 sẽ gọi `/me` ở mount AuthContext để verify token + sync role.
|
||||
|
||||
## 6. Logout
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U
|
||||
participant FE
|
||||
participant API as AuthController.Logout
|
||||
|
||||
U->>FE: Click "Đăng xuất"
|
||||
FE->>FE: logout() in AuthContext:<br/>localStorage.removeItem(token + user)<br/>setUser(null)
|
||||
FE->>API: POST /api/auth/logout (optional)
|
||||
API-->>FE: 204 No Content
|
||||
FE->>FE: Redirect /login (via ProtectedRoute)
|
||||
```
|
||||
|
||||
**Lưu ý:** Hiện endpoint logout chỉ trả 204. Phase 3 sẽ upgrade: invalidate refresh token trong DB + log audit event.
|
||||
|
||||
## 7. Edge cases
|
||||
|
||||
| Case | Hiện tại handle | Phase nào fix |
|
||||
|---|---|---|
|
||||
| Access token expired mid-request | FE 401 → logout | Phase 1 đợt 2: auto refresh |
|
||||
| Refresh token expired | FE 401 → logout | OK — đúng hành vi |
|
||||
| User bị disable (`IsActive=false`) sau khi đã login | Token vẫn valid tới khi expire | Phase 4: check `IsActive` trong middleware |
|
||||
| Admin reset password | Refresh token cũ vẫn valid | Phase 4: invalidate refresh token khi reset password |
|
||||
| Multi-device login | Ghi đè refresh token → device cũ bị logout khi cần refresh | OK cho Phase 1; Phase 4 optional: bảng `UserRefreshTokens` riêng |
|
||||
| JWT key leak | Toàn bộ user có thể forge token | Phase 5: dùng user secrets prod + rotate annually |
|
||||
| Clock skew giữa API + client | `ClockSkew = 1 minute` đã config | OK |
|
||||
|
||||
## 8. Security checklist (Phase 5 review)
|
||||
|
||||
- [ ] `Jwt:Secret` trong user-secrets / env var prod (không trong appsettings)
|
||||
- [ ] HTTPS enforce (`RequireHttpsMetadata = true` production)
|
||||
- [ ] Rate limit `/api/auth/login` (5 attempts/min/IP) — prevent brute force
|
||||
- [ ] Account lockout sau N lần sai password (config `UserLockout` trong Identity)
|
||||
- [ ] Password policy production (min 12 chars, unique, etc.)
|
||||
- [ ] Audit log cho login success/fail
|
||||
- [ ] Refresh token rotation detection (nếu dùng refresh cũ sau khi đã rotate → compromise alert)
|
||||
278
docs/flows/contract-approval-flow.md
Normal file
278
docs/flows/contract-approval-flow.md
Normal file
@ -0,0 +1,278 @@
|
||||
# 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
|
||||
235
docs/flows/contract-creation-flow.md
Normal file
235
docs/flows/contract-creation-flow.md
Normal file
@ -0,0 +1,235 @@
|
||||
# Contract Creation Flow
|
||||
|
||||
> **Status:** 📝 Planned (Phase 2)
|
||||
> **Actors:** Drafter (QS/NV.PB)
|
||||
> **Entry:** User mở `/contracts/new` ở fe-user
|
||||
|
||||
## 1. Tổng quan
|
||||
|
||||
Drafter chọn loại HĐ → chọn template form → điền field động → preview → lưu draft. Sau đó có thể tiếp tục soạn hoặc submit lên phase `DangGopY` (xem [`contract-approval-flow.md`](contract-approval-flow.md)).
|
||||
|
||||
## 2. Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor D as Drafter
|
||||
participant FE as fe-user<br/>/contracts/new
|
||||
participant API as ContractsController
|
||||
participant CMD as CreateContractCommandHandler
|
||||
participant TMPL as FormsController<br/>.GetTemplate
|
||||
participant REN as IFormRenderer
|
||||
participant DB
|
||||
participant FS as File Storage<br/>(wwwroot/uploads)
|
||||
|
||||
D->>FE: Click "Tạo HĐ mới"
|
||||
|
||||
D->>FE: Step 1 — Chọn loại HĐ<br/>(ContractType dropdown)
|
||||
FE->>API: GET /api/forms/templates?type={contractType}
|
||||
API->>DB: SELECT ContractTemplates WHERE Type = ? AND IsActive = 1
|
||||
API-->>FE: Template list<br/>[{id, formCode, name, fieldSpec}]
|
||||
|
||||
D->>FE: Step 2 — Chọn template (vd FO-002.05 Giao khoán)
|
||||
FE->>TMPL: GET /api/forms/templates/{id}
|
||||
TMPL->>DB: SELECT template + FieldSpec JSON
|
||||
TMPL-->>FE: fieldSpec (array of {name, type, required, ...})
|
||||
|
||||
FE->>FE: Dynamic form builder render form<br/>theo fieldSpec
|
||||
|
||||
D->>FE: Step 3 — Điền field (NCC, dự án,<br/>giá trị, hạng mục, nghiệm thu…)
|
||||
|
||||
D->>FE: Click "Preview"
|
||||
FE->>API: POST /api/forms/render<br/>{templateId, data}
|
||||
API->>REN: DocxRenderer.Render(template, data)
|
||||
REN->>REN: Load .docx template<br/>replace placeholder {{field}}<br/>with values
|
||||
REN->>FS: Write temp file uploads/preview/{guid}.docx
|
||||
REN-->>API: (bytes + temp path)
|
||||
API-->>FE: 200 file blob (inline hoặc Content-Disposition: inline)
|
||||
FE->>FE: Display PDF preview<br/>(convert server-side via LibreOffice)
|
||||
|
||||
D->>FE: Step 4 — Click "Lưu draft"
|
||||
FE->>API: POST /api/contracts<br/>{type, supplierId, projectId,<br/>templateId, draftData}
|
||||
API->>CMD: Send(CreateContractCommand)
|
||||
CMD->>CMD: Validate (FluentValidation)<br/>supplier exists, project active...
|
||||
CMD->>DB: INSERT Contracts (Phase=DangSoanThao,<br/>DraftData=JSON.serialize)
|
||||
CMD-->>API: ContractId
|
||||
API-->>FE: 201 Created + Location header
|
||||
|
||||
FE->>D: Redirect /contracts/{id}<br/>toast "Đã lưu draft"
|
||||
```
|
||||
|
||||
## 3. API chi tiết
|
||||
|
||||
### `GET /api/forms/templates?type=HopDongGiaoKhoan`
|
||||
|
||||
Response:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "a0b1-...",
|
||||
"formCode": "FO-002.05",
|
||||
"name": "Hợp đồng Giao khoán",
|
||||
"contractType": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/forms/templates/{id}`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "a0b1-...",
|
||||
"formCode": "FO-002.05",
|
||||
"name": "Hợp đồng Giao khoán",
|
||||
"fieldSpec": {
|
||||
"sections": [
|
||||
{
|
||||
"title": "Thông tin hai bên",
|
||||
"fields": [
|
||||
{ "name": "benA_tenCongTy", "label": "Tên Bên A", "type": "string", "required": true },
|
||||
{ "name": "benA_diaChi", "label": "Địa chỉ Bên A", "type": "text" },
|
||||
{ "name": "benA_maSoThue", "label": "MST Bên A", "type": "string", "pattern": "^[0-9]{10,13}$" },
|
||||
{ "name": "benB_supplierId", "label": "NCC (Bên B)", "type": "reference", "table": "Suppliers" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Giá trị hợp đồng",
|
||||
"fields": [
|
||||
{ "name": "giaTri", "label": "Giá trị (VND)", "type": "decimal", "required": true, "min": 0 },
|
||||
{ "name": "ngayKy", "label": "Ngày ký dự kiến", "type": "date" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/forms/render`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"templateId": "a0b1-...",
|
||||
"data": {
|
||||
"benA_tenCongTy": "Công ty TNHH Xây dựng Solutions",
|
||||
"benA_diaChi": "123 Đường ABC, TP.HCM",
|
||||
"benB_supplierId": "d9e2-...",
|
||||
"giaTri": 150000000,
|
||||
"ngayKy": "2026-05-10"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response: binary stream `.docx` (or `.pdf` nếu convert).
|
||||
|
||||
### `POST /api/contracts`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"type": 2,
|
||||
"supplierId": "d9e2-...",
|
||||
"projectId": "p7a1-...",
|
||||
"departmentId": "dp3b-...",
|
||||
"templateId": "a0b1-...",
|
||||
"giaTri": 150000000,
|
||||
"draftData": { /* full field values */ }
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "c5d6-...",
|
||||
"phase": "DangSoanThao",
|
||||
"createdAt": "2026-04-21T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Validation rules (FluentValidation)
|
||||
|
||||
```csharp
|
||||
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
|
||||
{
|
||||
public CreateContractCommandValidator(IApplicationDbContext db)
|
||||
{
|
||||
RuleFor(x => x.Type).IsInEnum();
|
||||
RuleFor(x => x.SupplierId).NotEmpty().MustAsync(SupplierExists);
|
||||
RuleFor(x => x.ProjectId).NotEmpty().MustAsync(ProjectActive);
|
||||
RuleFor(x => x.TemplateId).NotEmpty().MustAsync(TemplateActive);
|
||||
RuleFor(x => x.GiaTri).GreaterThan(0);
|
||||
RuleFor(x => x.DraftData).NotNull();
|
||||
// Template field validation — đọc FieldSpec + validate draftData match
|
||||
RuleFor(x => x).CustomAsync(async (cmd, ctx, ct) =>
|
||||
{
|
||||
var tpl = await db.ContractTemplates.FindAsync(cmd.TemplateId, ct);
|
||||
var spec = JsonSerializer.Deserialize<FormFieldSpec>(tpl.FieldSpec);
|
||||
// check required fields present in draftData
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Side effects
|
||||
|
||||
| Action | Side effect |
|
||||
|---|---|
|
||||
| `POST /contracts` | INSERT `Contracts` (Phase=`DangSoanThao`) |
|
||||
| | AuditInterceptor: set `CreatedAt`, `CreatedBy` = Drafter.Id |
|
||||
| | (Phase 3) Emit `ContractCreatedEvent` domain event |
|
||||
| `POST /forms/render` preview | Write temp file `wwwroot/uploads/preview/{guid}.docx` (auto cleanup sau 1h) |
|
||||
| `POST /forms/render` với `persist=true` | Save final file vào `ContractAttachments` với `Purpose='Export'` |
|
||||
|
||||
## 6. UI wireframe (fe-user)
|
||||
|
||||
```
|
||||
/contracts/new
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Tạo hợp đồng mới │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ Bước 1/4: Chọn loại hợp đồng │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Trọn gói │ │ Giao │ │ Mua bán │ ... │
|
||||
│ │ NC+VT │ │ khoán │ │ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ Bước 2/4: Chọn template │
|
||||
│ ○ FO-002.02 Trọn gói nhân công + vật tư │
|
||||
│ ● FO-002.05 Giao khoán ← đã chọn │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ Bước 3/4: Điền thông tin │
|
||||
│ [Thông tin 2 bên] │
|
||||
│ Tên Bên A: [Công ty TNHH ...] │
|
||||
│ NCC (Bên B): [Autocomplete → chọn NCC] │
|
||||
│ [Giá trị HĐ] │
|
||||
│ Giá trị: [150,000,000 VND] │
|
||||
│ ... │
|
||||
├────────────────────────────────────────────────────┤
|
||||
│ Bước 4/4: Preview + Lưu │
|
||||
│ [PDF preview inline] │
|
||||
│ [← Quay lại] [💾 Lưu draft] [🚀 Submit góp ý] │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 7. Edge cases
|
||||
|
||||
| Case | Xử lý |
|
||||
|---|---|
|
||||
| NCC chưa có trong DB | FE modal "Tạo NCC mới" inline → callback back vào form |
|
||||
| Template bị deactivate khi đang soạn | Warning + cho phép tiếp tục với template cũ |
|
||||
| Draft bị trùng (user nhấn Save 2 lần) | Idempotency key header — return existing contract |
|
||||
| User mất kết nối khi preview | FE retry + show spinner; nếu fail 3 lần → disable button + show cached data |
|
||||
| Giá trị HĐ vượt ngân sách dự án | Warning (không block) — sẽ check lại ở phase CCM Review |
|
||||
| Template thiếu field bắt buộc | FormValidator block Save + highlight field đỏ |
|
||||
|
||||
## 8. Performance
|
||||
|
||||
- Template load 1 lần + cache TanStack Query `{queryKey: ['template', id], staleTime: 10min}`
|
||||
- Preview render: có thể tốn 1-3s cho .docx lớn → show spinner + allow cancel
|
||||
- Save draft < 500ms (chỉ INSERT 1 row)
|
||||
|
||||
## 9. Liên quan
|
||||
|
||||
- [`form-render-flow.md`](form-render-flow.md) — chi tiết render engine
|
||||
- [`contract-approval-flow.md`](contract-approval-flow.md) — sau Save, phase tiếp theo
|
||||
- [`../forms-spec.md`](../forms-spec.md) — spec 8 form
|
||||
219
docs/flows/form-render-flow.md
Normal file
219
docs/flows/form-render-flow.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Form Render Flow — Template Engine
|
||||
|
||||
> **Status:** 📝 Planned (Phase 2)
|
||||
> **Mục tiêu:** render .docx/.xlsx giống 100% mẫu gốc với field động đã điền
|
||||
> **Spec 8 form:** [`../forms-spec.md`](../forms-spec.md)
|
||||
|
||||
## 1. Stack lựa chọn
|
||||
|
||||
| Format | Library | Status | Lý do |
|
||||
|---|---|---|---|
|
||||
| `.docx` render | **DocumentFormat.OpenXml** 3.x | Chọn mặc định | Free, maintained by MS, dùng đủ cho template placeholder |
|
||||
| `.docx` render (alternative) | Aspose.Words | Option phí | Dễ dùng hơn, render PDF in-process, nhưng cần license |
|
||||
| `.xlsx` render | **ClosedXML** 0.105+ | Chọn mặc định | Free, LGPL, API dễ; support formula, merged cells, style |
|
||||
| `.xlsx` render (alternative) | EPPlus 7+ | Không chọn | License commercial sau v5; non-commercial license risky |
|
||||
| PDF convert | LibreOffice headless (`soffice --convert-to pdf`) | Production path | Free, chất lượng OK |
|
||||
| PDF convert (dev only) | Aspose.Words `SaveAs PDF` | Option nếu mua Aspose | In-process, không cần external binary |
|
||||
|
||||
**Quyết định Phase 2:** OpenXml + ClosedXML + LibreOffice. Nếu render DOCX phức tạp quá (table lồng, heading numbering) → đánh giá lại Aspose.
|
||||
|
||||
## 2. Template placeholder syntax
|
||||
|
||||
Sử dụng `{{fieldName}}` cho text đơn giản và `{{#loop}}...{{/loop}}` cho bảng lặp.
|
||||
|
||||
Ví dụ trong file .docx (FO-002.05 Giao khoán):
|
||||
|
||||
```
|
||||
Hợp đồng số: {{maHopDong}}
|
||||
Ngày: {{ngayKy}}
|
||||
|
||||
Bên A: {{benA_tenCongTy}}
|
||||
Địa chỉ: {{benA_diaChi}}
|
||||
MST: {{benA_maSoThue}}
|
||||
|
||||
Bên B: {{benB_tenNCC}}
|
||||
...
|
||||
|
||||
Danh mục công việc:
|
||||
| STT | Hạng mục | Đơn giá | Khối lượng | Thành tiền |
|
||||
| --- | -------- | ------- | ---------- | ---------- |
|
||||
{{#hangMuc}}
|
||||
| {{stt}} | {{tenHangMuc}} | {{donGia}} | {{khoiLuong}} | {{thanhTien}} |
|
||||
{{/hangMuc}}
|
||||
|
||||
Tổng giá trị: {{giaTri}} VND
|
||||
```
|
||||
|
||||
Field không có trong data → replace bằng rỗng hoặc `—` (config).
|
||||
|
||||
## 3. Flow render .docx
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as API / Workflow
|
||||
participant REN as IFormRenderer
|
||||
participant DOCX as DocxRenderer
|
||||
participant OX as OpenXml
|
||||
participant FS as File Storage
|
||||
|
||||
Caller->>REN: Render(templateId, dataDict)
|
||||
REN->>REN: Load ContractTemplate from DB<br/>(get TemplatePath + FieldSpec)
|
||||
|
||||
REN->>DOCX: RenderDocx(templatePath, dataDict)
|
||||
DOCX->>FS: Copy template → temp/{guid}.docx
|
||||
DOCX->>OX: Open WordprocessingDocument
|
||||
|
||||
DOCX->>DOCX: Find all Text elements<br/>(w:t nodes)
|
||||
loop Each w:t
|
||||
DOCX->>DOCX: Regex replace {{field}} → value
|
||||
end
|
||||
|
||||
DOCX->>DOCX: Process {{#loop}} blocks:<br/>1. Find row/paragraph có placeholder loop<br/>2. Clone n-1 lần cho n items<br/>3. Replace field trong từng clone
|
||||
DOCX->>OX: Save + Close
|
||||
OX-->>DOCX: byte[] hoặc path
|
||||
DOCX-->>REN: file bytes + metadata
|
||||
REN-->>Caller: RenderResult{bytes, fileName, contentType}
|
||||
```
|
||||
|
||||
## 4. Flow render .xlsx (FO-002.07 PO)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant REN as XlsxRenderer
|
||||
participant CX as ClosedXML
|
||||
participant FS
|
||||
|
||||
Caller->>REN: RenderXlsx(templateId, dataDict)
|
||||
REN->>FS: Copy template xlsx → temp
|
||||
REN->>CX: Open XLWorkbook
|
||||
|
||||
loop Each sheet
|
||||
REN->>CX: Iterate cells
|
||||
alt Cell value contains {{field}}
|
||||
REN->>CX: Replace value với data[field]<br/>(giữ style, format)
|
||||
end
|
||||
end
|
||||
|
||||
loop Each named range "LoopHangMuc"
|
||||
REN->>REN: Insert n-1 rows, copy style, fill data
|
||||
REN->>REN: Adjust formula references (=SUM(...))
|
||||
end
|
||||
|
||||
REN->>CX: Recalculate formulas
|
||||
REN->>CX: SaveAs byte[]
|
||||
CX-->>REN: xlsx bytes
|
||||
REN-->>Caller: RenderResult
|
||||
```
|
||||
|
||||
**ClosedXML edge:** phải gọi `workbook.CalculateMode = XLCalculateMode.Auto` + `workbook.FormulaParser.Calculate()` trước khi save, nếu không formula tổng vẫn giữ giá trị cũ.
|
||||
|
||||
## 5. PDF convert flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Input: .docx bytes]) --> Save[Lưu temp file .docx]
|
||||
Save --> Run[Run: soffice --headless --convert-to pdf<br/>--outdir /tmp in.docx]
|
||||
Run -->|exit 0| Read[Read /tmp/in.pdf bytes]
|
||||
Run -->|exit != 0| Fail[Log error<br/>return docx thay vì pdf]
|
||||
Read --> Cleanup[Cleanup temp files]
|
||||
Cleanup --> Return([Return PDF bytes])
|
||||
Fail --> Return
|
||||
```
|
||||
|
||||
**Deploy prod:**
|
||||
- Windows IIS app pool cần LibreOffice portable ở `C:\Apps\LibreOffice` + `PATH` env var
|
||||
- Subprocess timeout 30s (nếu > 30s → fail, user download docx thay)
|
||||
- Pool soffice instance (1 instance / 10 requests) — tránh spawn process mỗi request
|
||||
|
||||
## 6. API endpoints
|
||||
|
||||
### Preview (không lưu)
|
||||
|
||||
```
|
||||
POST /api/forms/render
|
||||
Body: { templateId, data, outputFormat: "docx" | "pdf" }
|
||||
Response: binary stream, Content-Disposition: inline (nếu pdf), attachment (nếu docx)
|
||||
```
|
||||
|
||||
### Finalize (lưu vào ContractAttachments)
|
||||
|
||||
```
|
||||
POST /api/contracts/{id}/render-final
|
||||
Body: { outputFormat: "docx" }
|
||||
Response: { attachmentId, url }
|
||||
Side effect: INSERT ContractAttachments (Purpose='Export')
|
||||
```
|
||||
|
||||
## 7. Template management (admin)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Admin
|
||||
participant FE as fe-admin<br/>/master/contract-templates
|
||||
participant API as FormsController.UploadTemplate
|
||||
participant VAL as TemplateValidator
|
||||
participant DB
|
||||
participant FS
|
||||
|
||||
Admin->>FE: Upload file .docx + metadata<br/>(formCode, name, contractType, fieldSpec JSON)
|
||||
FE->>API: POST /api/forms/templates<br/>multipart: file + metadata
|
||||
API->>VAL: ParsePlaceholders(file)
|
||||
VAL->>VAL: Extract all {{field}} tokens<br/>→ compare với fieldSpec
|
||||
alt Token không match spec
|
||||
VAL-->>API: warning "Field X trong template không có trong spec"
|
||||
end
|
||||
|
||||
API->>FS: Save file to wwwroot/templates/{guid}.docx
|
||||
API->>DB: INSERT ContractTemplate<br/>(FormCode, Name, TemplatePath, FieldSpec, IsActive=true)
|
||||
API-->>FE: 201
|
||||
```
|
||||
|
||||
## 8. Field types supported
|
||||
|
||||
| Type | Render behavior | Example |
|
||||
|---|---|---|
|
||||
| `string` | Replace direct | `"Công ty ABC"` |
|
||||
| `text` | Replace + preserve line breaks (`<w:br/>`) | `"Dòng 1\nDòng 2"` |
|
||||
| `number` | Format theo culture vi-VN | `150000000` → `"150.000.000"` |
|
||||
| `decimal` | Giá tiền: thêm `VND` suffix | `150000000` → `"150.000.000 VND"` |
|
||||
| `date` | Format `dd/MM/yyyy` | `2026-04-21` → `"21/04/2026"` |
|
||||
| `boolean` | Checkbox symbol | `true` → `"☑"`, `false` → `"☐"` |
|
||||
| `reference` | Lookup entity name | `supplierId → "Công ty NCC PVL"` |
|
||||
| `array` | Với `{{#loop}}` | danh mục hạng mục |
|
||||
|
||||
## 9. Edge cases
|
||||
|
||||
| Case | Xử lý |
|
||||
|---|---|
|
||||
| Template bị rename/xóa file vật lý | Render throw `FileNotFoundException` → API 500, log error |
|
||||
| Template có macro (`.docm`) | Reject ở upload — chỉ accept `.docx` |
|
||||
| Placeholder bị split giữa 2 `<w:t>` elements (Word quirk) | DocxRenderer normalize trước: merge adjacent `<w:t>` trong cùng run |
|
||||
| Field có ký tự đặc biệt (`&`, `<`, `>`) | OpenXml auto XML-escape khi set Text value |
|
||||
| Loop template có 0 items | Remove template row hoàn toàn (không để lại placeholder row rỗng) |
|
||||
| File size > 10MB | Chunk upload hoặc reject (Phase 4 optimize) |
|
||||
| Render concurrent (2 user render cùng template) | Mỗi request lock copy riêng vào temp file → không conflict |
|
||||
|
||||
## 10. Performance
|
||||
|
||||
| Operation | Budget | Note |
|
||||
|---|---|---|
|
||||
| Load template from DB + FS | < 100ms | Cache FileBytes in IMemoryCache 1h |
|
||||
| Render .docx (5 pages, 1 table 20 rows) | < 500ms | Stream-based, không full DOM |
|
||||
| Convert PDF via LibreOffice | 1-3s | Bottleneck, consider pool |
|
||||
| Full request (render + PDF + save attachment) | < 5s | Show spinner FE |
|
||||
|
||||
## 11. Testing checklist (Phase 2)
|
||||
|
||||
- [ ] FO-002.05 Giao khoán: render với 10 hạng mục → output giống mẫu 100%
|
||||
- [ ] FO-002.07 PO xlsx: formula `=SUM(E5:E30)` recalculate đúng
|
||||
- [ ] Template với ký tự Unicode (đ, ư, ă) → không lỗi encoding
|
||||
- [ ] Template với 0 items loop → không còn dòng trống
|
||||
- [ ] PDF convert với 50-page docx → timeout setting OK
|
||||
- [ ] Concurrent 10 request cùng 1 template → mỗi file output độc lập
|
||||
|
||||
## 12. Liên quan
|
||||
|
||||
- [`../forms-spec.md`](../forms-spec.md) — spec 8 form + field types
|
||||
- [`contract-creation-flow.md`](contract-creation-flow.md) — nơi gọi render
|
||||
- [`contract-approval-flow.md`](contract-approval-flow.md) — render lại khi có mã HĐ
|
||||
289
docs/flows/permission-flow.md
Normal file
289
docs/flows/permission-flow.md
Normal file
@ -0,0 +1,289 @@
|
||||
# Permission Flow — Resolution Menu + CRUD
|
||||
|
||||
> **Status:** 📝 Planned (Phase 1 đợt 2)
|
||||
> **Actors:** System (resolution) | Admin (configure matrix) | Any user (guard apply)
|
||||
|
||||
## 1. Mô hình
|
||||
|
||||
3 layer resolution:
|
||||
|
||||
```
|
||||
User
|
||||
└─ có nhiều Role (UserRoles table)
|
||||
└─ có Permission (Permissions table, row per MenuKey × CRUD)
|
||||
└─ gắn với MenuItem (tree Key → ParentKey)
|
||||
```
|
||||
|
||||
Quyết định cuối cùng: **union** của tất cả permission từ mọi role của user. Nếu bất kỳ role nào `CanRead=true` cho `MenuKey=Contracts` → user được đọc.
|
||||
|
||||
## 2. Ma trận Permission
|
||||
|
||||
```
|
||||
MenuKey Role.Admin Role.Drafter Role.CCM Role.BOD
|
||||
─────────────────────────────────────────────────────────────
|
||||
Dashboard R R R R
|
||||
Contracts CRUD CR (self) RU RU
|
||||
Suppliers CRUD R R R
|
||||
Projects CRUD R R R
|
||||
Users CRUD — — —
|
||||
Roles CRUD — — —
|
||||
Permissions CRUD — — —
|
||||
Reports R — R R
|
||||
```
|
||||
|
||||
(Chi tiết đầy đủ role × menu mapping ở [`../workflow-contract.md §5`](../workflow-contract.md))
|
||||
|
||||
## 3. Flow — user login → FE resolve menu
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant API as MenusController.GetMyTree
|
||||
participant Q as GetMyMenuTreeQueryHandler
|
||||
participant CU as ICurrentUser
|
||||
participant DB
|
||||
|
||||
U->>FE: Login thành công (có token)
|
||||
FE->>API: GET /api/menus/me
|
||||
API->>Q: Send(GetMyMenuTreeQuery)
|
||||
|
||||
Q->>CU: UserId + Roles
|
||||
CU-->>Q: userId, roles[]
|
||||
|
||||
Q->>DB: SELECT Permissions<br/>JOIN Roles ON Permissions.RoleId = Roles.Id<br/>JOIN MenuItems ON Permissions.MenuKey = MenuItems.Key<br/>WHERE Roles.Id IN (user's roles)
|
||||
|
||||
DB-->>Q: raw rows
|
||||
|
||||
Q->>Q: Group by MenuKey<br/>UNION CRUD flags (OR)<br/>Build tree (Key → ParentKey)
|
||||
|
||||
Q-->>API: MenuTreeDto[] with resolved CRUD per node
|
||||
API-->>FE: 200
|
||||
|
||||
FE->>FE: Cache in AuthContext + localStorage
|
||||
FE->>FE: Render sidebar (filter nodes có CanRead=true)
|
||||
```
|
||||
|
||||
**Example response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "Dashboard",
|
||||
"label": "Tổng quan",
|
||||
"icon": "LayoutDashboard",
|
||||
"order": 1,
|
||||
"parentKey": null,
|
||||
"canRead": true,
|
||||
"canCreate": false,
|
||||
"canUpdate": false,
|
||||
"canDelete": false,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"key": "Master",
|
||||
"label": "Danh mục",
|
||||
"icon": "Database",
|
||||
"order": 2,
|
||||
"parentKey": null,
|
||||
"children": [
|
||||
{
|
||||
"key": "Suppliers",
|
||||
"label": "Nhà cung cấp",
|
||||
"parentKey": "Master",
|
||||
"canRead": true, "canCreate": true, "canUpdate": true, "canDelete": false,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 4. Flow — FE guard render
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Component mount]) --> CheckMenu{usePermission<br/>can 'Contracts', 'Update'?}
|
||||
CheckMenu -->|no permission| Hide[Không render nút 'Sửa']
|
||||
CheckMenu -->|has permission| Show[Render nút 'Sửa']
|
||||
Show --> ClickEdit[User click 'Sửa']
|
||||
ClickEdit --> CallAPI[PATCH /api/contracts/:id]
|
||||
CallAPI --> BEGuard{Controller<br/>Authorize 'Contracts.Update'?}
|
||||
BEGuard -->|no| Reject[403 Forbidden]
|
||||
BEGuard -->|yes| Proceed[Update DB + return 200]
|
||||
```
|
||||
|
||||
**FE pattern (sẽ implement Phase 1 đợt 2):**
|
||||
|
||||
```tsx
|
||||
// usePermission.ts
|
||||
export function usePermission() {
|
||||
const { menu } = useAuth() // menu cached từ login
|
||||
return {
|
||||
can: (menuKey: string, action: 'Read' | 'Create' | 'Update' | 'Delete') => {
|
||||
const node = findInTree(menu, menuKey)
|
||||
return node?.[`can${action}`] ?? false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionGuard.tsx
|
||||
<PermissionGuard menuKey="Contracts" action="Update">
|
||||
<Button>Sửa</Button>
|
||||
</PermissionGuard>
|
||||
|
||||
// Route guard
|
||||
<Route
|
||||
path="/admin/permissions"
|
||||
element={
|
||||
<PermissionGuard menuKey="Permissions" action="Read" fallback={<Forbidden />}>
|
||||
<PermissionMatrixPage />
|
||||
</PermissionGuard>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
## 5. Flow — Admin configure permission matrix
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor A as Admin
|
||||
participant FE as Permission Matrix Page
|
||||
participant API as PermissionsController
|
||||
participant M as UpsertPermissionCommandHandler
|
||||
participant DB
|
||||
|
||||
A->>FE: Mở /admin/permissions
|
||||
FE->>API: GET /api/permissions?roleId={id}
|
||||
API-->>FE: Array permissions (row per menuKey)
|
||||
|
||||
A->>FE: Tick checkbox "Contracts.CanUpdate = true" cho role Drafter
|
||||
FE->>FE: Optimistic update UI
|
||||
|
||||
FE->>API: PUT /api/permissions<br/>{roleId, menuKey: "Contracts", canRead, canCreate, canUpdate, canDelete}
|
||||
API->>M: Send(UpsertPermissionCommand)
|
||||
|
||||
M->>DB: SELECT existing Permission<br/>WHERE RoleId=? AND MenuKey=?
|
||||
alt Exists
|
||||
M->>DB: UPDATE flags
|
||||
else Not exists
|
||||
M->>DB: INSERT new row
|
||||
end
|
||||
|
||||
M-->>API: 204
|
||||
API-->>FE: 204
|
||||
|
||||
Note over FE,A: User với role Drafter<br/>sẽ thấy nút Update HĐ<br/>sau khi họ refresh/re-login<br/>(hoặc SignalR notify Phase 3)
|
||||
```
|
||||
|
||||
**Ghi chú quan trọng:**
|
||||
- Permission update **KHÔNG** real-time đến user đang online ở Phase 1 đợt 2 — họ phải logout/login để thấy.
|
||||
- Phase 3 có thể thêm SignalR notify "permission changed" → FE auto refetch `/api/menus/me`.
|
||||
- Phase 4 có thể invalidate JWT khi permission đổi (rare change, nhưng secure).
|
||||
|
||||
## 6. Backend guard
|
||||
|
||||
```csharp
|
||||
// Api/Controllers/ContractsController.cs
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Policy = "Contracts.Update")] // custom policy
|
||||
public async Task<IActionResult> Update(Guid id, UpdateContractCommand cmd)
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Custom policy registration (Program.cs):**
|
||||
|
||||
```csharp
|
||||
services.AddAuthorization(opts =>
|
||||
{
|
||||
foreach (var menu in MenuKeys.All)
|
||||
{
|
||||
foreach (var action in new[] { "Read", "Create", "Update", "Delete" })
|
||||
{
|
||||
opts.AddPolicy($"{menu}.{action}", p =>
|
||||
p.Requirements.Add(new MenuPermissionRequirement(menu, action)));
|
||||
}
|
||||
}
|
||||
});
|
||||
services.AddSingleton<IAuthorizationHandler, MenuPermissionHandler>();
|
||||
```
|
||||
|
||||
**Handler:**
|
||||
|
||||
```csharp
|
||||
public class MenuPermissionHandler : AuthorizationHandler<MenuPermissionRequirement>
|
||||
{
|
||||
private readonly IApplicationDbContext _db;
|
||||
// ...
|
||||
protected override async Task HandleRequirementAsync(...)
|
||||
{
|
||||
var userId = context.User.GetUserId();
|
||||
var hasPermission = await _db.Permissions
|
||||
.Where(p => p.Role.Users.Any(u => u.Id == userId))
|
||||
.Where(p => p.MenuKey == req.MenuKey)
|
||||
.AnyAsync(p => req.Action switch
|
||||
{
|
||||
"Read" => p.CanRead,
|
||||
"Create" => p.CanCreate,
|
||||
"Update" => p.CanUpdate,
|
||||
"Delete" => p.CanDelete,
|
||||
_ => false,
|
||||
});
|
||||
if (hasPermission) context.Succeed(req);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Seed mặc định (Phase 1 đợt 2)
|
||||
|
||||
Seed trong `DbInitializer.InitializeAsync`:
|
||||
|
||||
1. **Menu tree seed** — từ `MenuKeys.cs` const class (Phase 1 đợt 2):
|
||||
|
||||
```
|
||||
Dashboard
|
||||
Master
|
||||
├── Suppliers
|
||||
├── Projects
|
||||
└── Departments
|
||||
Contracts
|
||||
Forms
|
||||
Approvals
|
||||
Reports
|
||||
System
|
||||
├── Users
|
||||
├── Roles
|
||||
└── Permissions
|
||||
```
|
||||
|
||||
2. **Default permissions:**
|
||||
- `Admin` role → full CRUD mọi menu
|
||||
- Các role khác → chỉ `Read` mặc định, admin config thêm sau qua UI
|
||||
|
||||
## 8. Edge cases
|
||||
|
||||
| Case | Xử lý |
|
||||
|---|---|
|
||||
| User có 0 role | Chỉ thấy `Dashboard` (nếu mở cho anonymous), không vào được menu khác |
|
||||
| Role bị xóa | `FK ON DELETE CASCADE` xóa permissions liên quan |
|
||||
| Menu bị remove khỏi `MenuKeys.cs` | Seed job cảnh báo + giữ orphan permissions (dev fix manual) |
|
||||
| Admin tự xóa quyền admin của mình | Check trong UpsertPermissionCommand — nếu target role là Admin + current user đang ở role Admin → `throw ForbiddenException("Không thể tự xóa quyền admin")` |
|
||||
| User có nhiều role conflict (1 role cho, 1 role cấm) | Union (OR) — có ít nhất 1 role cho là được |
|
||||
|
||||
## 9. Performance
|
||||
|
||||
- Menu tree cache trong `AuthContext` sau login → không hit API mỗi navigate
|
||||
- `/api/menus/me` response size ~5KB gzipped (với ~30 menu nodes)
|
||||
- Authorization handler cache scope request (1 query / request) — EF Core auto cache
|
||||
- Phase 4 optimize: Redis distributed cache cho permission matrix (nếu >100 concurrent users)
|
||||
|
||||
## 10. Testing checklist (Phase 1 đợt 2)
|
||||
|
||||
- [ ] Admin login → thấy tất cả menu
|
||||
- [ ] Tạo user role Drafter only → chỉ thấy menu Contracts + self HĐ
|
||||
- [ ] Tạo role tùy chỉnh "CCM Reviewer" chỉ read Contracts + read Reports → verify không thấy Master menu
|
||||
- [ ] User có 2 role (Drafter + Finance) → thấy union của cả 2
|
||||
- [ ] Admin xóa quyền Update của role Drafter → user Drafter refresh → không thấy nút Sửa
|
||||
- [ ] Backend 403 khi FE bypass (dev tools unhide nút) → gọi API trực tiếp bị chặn
|
||||
240
docs/flows/sla-expiry-flow.md
Normal file
240
docs/flows/sla-expiry-flow.md
Normal file
@ -0,0 +1,240 @@
|
||||
# SLA Expiry Auto-Approve Flow
|
||||
|
||||
> **Status:** 📝 Planned (Phase 3)
|
||||
> **Business rule nguồn:** [`../workflow-contract.md §4`](../workflow-contract.md) — *"Mỗi bộ phận chỉ có 01 ngày để xử lý. Nếu kéo dài hơn 01 ngày mà vẫn chưa xử lý xong, thì xem như đã thông qua."*
|
||||
|
||||
## 1. Mục đích
|
||||
|
||||
Tránh bottleneck khi 1 role giữ HĐ quá SLA. Hệ thống auto-approve để HĐ tiếp tục chạy. Mọi auto-approve được log rõ ràng (`Decision=AutoApprove`) để audit.
|
||||
|
||||
## 2. Data model liên quan
|
||||
|
||||
```csharp
|
||||
public class Contract : AuditableEntity
|
||||
{
|
||||
public ContractPhase Phase { get; set; }
|
||||
public DateTime? SlaDeadline { get; set; } // khi nào phase hiện tại hết hạn
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`SlaDeadline = UtcNow + PhaseSla` được set mỗi khi transition.
|
||||
|
||||
### SLA mỗi phase
|
||||
|
||||
| Phase | SLA | Từ workflow spec |
|
||||
|---|---|---|
|
||||
| DangSoanThao | 7 ngày | Drafter có 7d soạn thảo |
|
||||
| DangGopY | 7 ngày | Các phòng góp ý |
|
||||
| DangDamPhan | 7 ngày | Đàm phán |
|
||||
| DangInKy | 1 ngày | In + ký nháy |
|
||||
| DangKiemTraCCM | 3 ngày | CCM review |
|
||||
| DangTrinhKy | 1 ngày | BOD ký |
|
||||
| DangDongDau | (không SLA) | Phụ thuộc HRA |
|
||||
| DaPhatHanh | (final) | — |
|
||||
|
||||
## 3. Hosted service `SlaExpiryJob`
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([BackgroundService<br/>ExecuteAsync]) --> Loop{Loop}
|
||||
Loop --> Wait[Wait 15 minutes<br/>Task.Delay with CancellationToken]
|
||||
Wait --> Query[Query Contracts<br/>WHERE SlaDeadline IS NOT NULL<br/>AND SlaDeadline < UtcNow<br/>AND Phase NOT IN finished_phases]
|
||||
Query -->|count > 0| Process[Process each contract]
|
||||
Query -->|count = 0| Loop
|
||||
|
||||
Process --> Decide{Có role<br/>next để<br/>auto approve?}
|
||||
Decide -->|yes| AutoApprove[Transition → nextPhase<br/>với system actor<br/>Decision=AutoApprove]
|
||||
Decide -->|no<br/>(HĐ đang ở final phase)| Skip[Skip]
|
||||
|
||||
AutoApprove --> Log[Log audit + send notify]
|
||||
Log --> Loop
|
||||
Skip --> Loop
|
||||
```
|
||||
|
||||
## 4. Implementation skeleton
|
||||
|
||||
```csharp
|
||||
// Infrastructure/HostedServices/SlaExpiryJob.cs
|
||||
public class SlaExpiryJob : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
private readonly ILogger<SlaExpiryJob> _logger;
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15);
|
||||
|
||||
public SlaExpiryJob(IServiceProvider sp, ILogger<SlaExpiryJob> logger)
|
||||
{
|
||||
_sp = sp;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _sp.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
|
||||
var workflow = scope.ServiceProvider.GetRequiredService<IContractWorkflowService>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var expired = await db.Contracts
|
||||
.Where(c => c.SlaDeadline != null && c.SlaDeadline < now)
|
||||
.Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation("SlaExpiryJob: {Count} contracts expired", expired.Count);
|
||||
|
||||
foreach (var contract in expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
await workflow.AutoApproveExpiredAsync(contract, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SlaExpiryJob: failed to auto-approve {ContractId}", contract.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SlaExpiryJob: iteration failed");
|
||||
}
|
||||
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Auto-approve logic
|
||||
|
||||
```csharp
|
||||
public async Task AutoApproveExpiredAsync(Contract contract, CancellationToken ct)
|
||||
{
|
||||
var nextPhase = DetermineNextPhase(contract);
|
||||
if (nextPhase is null) return; // no next phase → stay (rare)
|
||||
|
||||
// Record approval với system actor
|
||||
var approval = new ContractApproval
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
Phase = contract.Phase,
|
||||
ApproverUserId = null, // system (hoặc Guid Empty)
|
||||
Decision = ApprovalDecision.AutoApprove,
|
||||
Comment = $"Tự động duyệt do quá SLA {contract.Phase} (deadline {contract.SlaDeadline:yyyy-MM-dd HH:mm})",
|
||||
ApprovedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.ContractApprovals.Add(approval);
|
||||
|
||||
// Update phase + reset deadline cho phase mới
|
||||
contract.Phase = nextPhase.Value;
|
||||
contract.SlaDeadline = DateTime.UtcNow.Add(GetSlaForPhase(nextPhase.Value));
|
||||
|
||||
// Gen mã HĐ nếu transition tới DangDongDau
|
||||
if (nextPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong))
|
||||
{
|
||||
contract.MaHopDong = await _codeGen.GenerateAsync(contract, ct);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
// Notify drafter + role next
|
||||
await _notifications.NotifyAutoApproveAsync(contract, approval);
|
||||
}
|
||||
|
||||
private ContractPhase? DetermineNextPhase(Contract contract) => contract.Phase switch
|
||||
{
|
||||
ContractPhase.DangSoanThao => ContractPhase.DangGopY,
|
||||
ContractPhase.DangGopY => ContractPhase.DangDamPhan,
|
||||
ContractPhase.DangDamPhan => ContractPhase.DangInKy,
|
||||
ContractPhase.DangInKy => ContractPhase.DangKiemTraCCM,
|
||||
ContractPhase.DangKiemTraCCM => ContractPhase.DangTrinhKy,
|
||||
ContractPhase.DangTrinhKy => ContractPhase.DangDongDau,
|
||||
ContractPhase.DangDongDau => ContractPhase.DaPhatHanh,
|
||||
_ => null,
|
||||
};
|
||||
```
|
||||
|
||||
## 6. Warning notification (còn 20% SLA)
|
||||
|
||||
Thêm 1 job warning tách biệt (hoặc merge vào cùng `SlaExpiryJob`):
|
||||
|
||||
```csharp
|
||||
// Query contracts với SlaDeadline sắp hết (80% đã trôi qua)
|
||||
var warning = await db.Contracts
|
||||
.Where(c => c.SlaDeadline != null)
|
||||
.Where(c => c.SlaDeadline > now && c.SlaDeadline - now < FractionRemaining(c.Phase, 0.2))
|
||||
.Where(c => !c.SlaWarningSent) // boolean flag để không gửi 2 lần
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var c in warning)
|
||||
{
|
||||
await _notifications.NotifySlaWarningAsync(c);
|
||||
c.SlaWarningSent = true;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
```
|
||||
|
||||
Reset `SlaWarningSent = false` khi chuyển phase.
|
||||
|
||||
## 7. Registration (Program.cs)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddHostedService<SlaExpiryJob>();
|
||||
```
|
||||
|
||||
Hosted service chạy cùng vòng đời app. Trên IIS với multiple worker processes → mỗi worker chạy 1 instance → có thể duplicate auto-approve 1 HĐ. **Fix:** single worker process prod (IIS app pool `maxProcesses = 1`) hoặc distributed lock qua Redis (Phase 4).
|
||||
|
||||
## 8. Monitoring
|
||||
|
||||
| Metric | Alert threshold |
|
||||
|---|---|
|
||||
| Contracts auto-approved / day | > 20% tổng transition → review quy trình |
|
||||
| Job iteration duration | > 5s → DB query slow, cần index `SlaDeadline` |
|
||||
| Job errors in last hour | > 3 → page oncall |
|
||||
| HĐ có SLA quá hạn > 24h (job không process) | > 0 → system down / deadlock |
|
||||
|
||||
Log structured với Serilog:
|
||||
```
|
||||
[SlaExpiryJob] Auto-approved contract {ContractId} from {OldPhase} → {NewPhase} (expired {Hours}h ago)
|
||||
```
|
||||
|
||||
## 9. Edge cases
|
||||
|
||||
| Case | Xử lý |
|
||||
|---|---|
|
||||
| Job đang chạy → app crash giữa chừng | Không save changes → next iteration xử lý lại |
|
||||
| Contract đã được user duyệt ngay lúc job chạy | Optimistic concurrency (RowVersion) — job fail → skip, next iteration sẽ thấy phase đã đổi |
|
||||
| Contract đã transition tới final phase (`DaPhatHanh`) nhưng `SlaDeadline` không được clear | Query filter loại luôn → không xử lý |
|
||||
| Timezone server khác UTC | Luôn compare bằng UTC (`DateTime.UtcNow`, `SlaDeadline` lưu UTC) |
|
||||
| Admin pause auto-approve (ví dụ kỳ lễ không ai làm việc) | Thêm config `Sla:Enabled = false` → job skip loop |
|
||||
| SLA config thay đổi (ví dụ từ 7 → 10 ngày) | Chỉ áp dụng cho transition mới, HĐ đang dở vẫn dùng deadline cũ |
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### Unit test
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task AutoApprove_transitions_to_next_phase()
|
||||
{
|
||||
var contract = new Contract { Phase = ContractPhase.DangGopY, SlaDeadline = DateTime.UtcNow.AddHours(-1) };
|
||||
await _service.AutoApproveExpiredAsync(contract, default);
|
||||
Assert.Equal(ContractPhase.DangDamPhan, contract.Phase);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration test
|
||||
```csharp
|
||||
// Seed HĐ với SlaDeadline = 5 phút trước
|
||||
// Start SlaExpiryJob với interval 1s
|
||||
// Wait 2s
|
||||
// Query DB → phase phải đã advance + có ContractApproval với Decision=AutoApprove
|
||||
```
|
||||
|
||||
## 11. Liên quan
|
||||
|
||||
- [`contract-approval-flow.md`](contract-approval-flow.md) — manual transition (ngược lại với auto)
|
||||
- [`../workflow-contract.md`](../workflow-contract.md) — SLA rule gốc
|
||||
Reference in New Issue
Block a user