From 49a5f57a50a29db60a77b2d4a8debe0062f1a7c9 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 11:15:28 +0700 Subject: [PATCH] [CLAUDE] Docs: database-guide + 6 flow diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 2 + docs/CLAUDE.md | 11 +- docs/STATUS.md | 3 +- docs/changelog/migration-todos.md | 2 + docs/database/database-guide.md | 463 +++++++++++++++++++++++++++ docs/flows/README.md | 30 ++ docs/flows/auth-flow.md | 212 ++++++++++++ docs/flows/contract-approval-flow.md | 278 ++++++++++++++++ docs/flows/contract-creation-flow.md | 235 ++++++++++++++ docs/flows/form-render-flow.md | 219 +++++++++++++ docs/flows/permission-flow.md | 289 +++++++++++++++++ docs/flows/sla-expiry-flow.md | 240 ++++++++++++++ 12 files changed, 1982 insertions(+), 2 deletions(-) create mode 100644 docs/database/database-guide.md create mode 100644 docs/flows/README.md create mode 100644 docs/flows/auth-flow.md create mode 100644 docs/flows/contract-approval-flow.md create mode 100644 docs/flows/contract-creation-flow.md create mode 100644 docs/flows/form-render-flow.md create mode 100644 docs/flows/permission-flow.md create mode 100644 docs/flows/sla-expiry-flow.md diff --git a/CLAUDE.md b/CLAUDE.md index b24eff5..534df2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index fa50c82..2f1dcaa 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index d58d4bd..2853ceb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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: diff --git a/docs/changelog/migration-todos.md b/docs/changelog/migration-todos.md index 87416c5..ebe6518 100644 --- a/docs/changelog/migration-todos.md +++ b/docs/changelog/migration-todos.md @@ -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`) diff --git a/docs/database/database-guide.md b/docs/database/database-guide.md new file mode 100644 index 0000000..748e6d8 --- /dev/null +++ b/docs/database/database-guide.md @@ -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` + +## 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=;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()` | +| `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().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 ` + --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 + +# 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 ?'; +``` diff --git a/docs/flows/README.md b/docs/flows/README.md new file mode 100644 index 0000000..d88f129 --- /dev/null +++ b/docs/flows/README.md @@ -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 diff --git a/docs/flows/auth-flow.md b/docs/flows/auth-flow.md new file mode 100644 index 0000000..102159b --- /dev/null +++ b/docs/flows/auth-flow.md @@ -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 ` (axios interceptor auto) + +## 2. Login + +```mermaid +sequenceDiagram + actor U as User + participant FE as Frontend
(LoginPage) + participant API as SolutionErp.Api
AuthController + participant M as MediatR
LoginCommandHandler + participant UM as UserManager + participant J as IJwtTokenService + participant DB as SQL Server + + U->>FE: Nhập email + password → Submit + FE->>API: POST /api/auth/login
{email, password} + API->>M: Send(LoginCommand) + + M->>M: Validate (FluentValidation)
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)
+ 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
Authorization: Bearer + + 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
(server trả 401 từ /api/whatever) + FE->>API: POST /api/auth/refresh
{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
Bearer + 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:
localStorage.removeItem(token + user)
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) diff --git a/docs/flows/contract-approval-flow.md b/docs/flows/contract-approval-flow.md new file mode 100644 index 0000000..d3eb78f --- /dev/null +++ b/docs/flows/contract-approval-flow.md @@ -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
(role cụ thể) + participant FE as fe-user + participant API as ContractsController
.Transition + participant M as TransitionContractCommandHandler + participant WF as IContractWorkflowService + participant CG as IContractCodeGenerator + participant NS as INotificationService + participant DB + + U->>FE: Click "Chuyển phase" + nhập comment + FE->>API: POST /api/contracts/{id}/transitions
{targetPhase, decision, comment} + API->>M: Send(TransitionContractCommand) + + M->>DB: SELECT Contract WHERE Id = ? + alt Not found + M-->>API: throw NotFoundException + end + + M->>WF: ValidateTransition(contract, targetPhase, userRole) + WF->>WF: Check state rule:
1. currentPhase allows transition to targetPhase?
2. user role đủ quyền ở phase này?
3. bypass rule với Chủ đầu tư? + alt Invalid transition + WF-->>M: throw ForbiddenException("không được phép") + end + + alt targetPhase == DangDongDau (BOD ký) + M->>CG: GenerateAsync(project, type, supplier) + CG->>DB: BEGIN TRAN SERIALIZABLE + CG->>DB: SELECT LastSeq WITH UPDLOCK + CG->>DB: UPDATE LastSeq + 1 + CG->>DB: COMMIT + CG-->>M: "FLOCK 01/HĐGK/SOL&PVL/03" + M->>DB: UPDATE Contract SET MaHopDong = ? + end + + M->>DB: INSERT ContractApproval
(ContractId, Phase=currentPhase,
ApproverUserId, Decision, Comment) + M->>DB: UPDATE Contract SET Phase = targetPhase,
SlaDeadline = UtcNow + PhaseSla + + M->>NS: NotifyPhaseChangeAsync(contract, oldPhase, newPhase) + NS->>NS: Query users trong role eligible
for newPhase + NS->>NS: Send email (MailKit) + in-app notify + Note over NS: (Phase 3 Iteration 2) SignalR
push notification real-time + + M-->>API: TransitionResultDto + API-->>FE: 200 + + FE->>FE: Invalidate TanStack query
['contracts', id] + toast success + FE->>FE: Refresh timeline UI +``` + +## 4. Guard rules (IContractWorkflowService) + +```csharp +public class ContractWorkflowService : IContractWorkflowService +{ + // Adjacency + role matrix — xem workflow-contract.md + private static readonly Dictionary<(ContractPhase From, ContractPhase To), string[]> _transitions = new() + { + [(ContractPhase.DangChon, ContractPhase.DangSoanThao)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter], + [(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.Admin], + [(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter], + [(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl], + [(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter], + [(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl], + [(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl], + [(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin], + }; + + // Bypass CĐT: HopDongChuDauTu → có thể nhảy từ DangInKy thẳng tới DangTrinhKy (bỏ CCM) + public void ValidateTransition(Contract contract, ContractPhase target, IReadOnlyList userRoles) + { + // 1. Check adjacency + var key = (contract.Phase, target); + if (!_transitions.TryGetValue(key, out var allowedRoles)) + throw new ForbiddenException($"Không thể chuyển {contract.Phase} → {target}"); + + // 2. Check role + if (!userRoles.Any(r => allowedRoles.Contains(r)) && !userRoles.Contains(AppRoles.Admin)) + throw new ForbiddenException("Role không đủ quyền duyệt phase này"); + + // 3. Bypass rule + if (contract.BypassProcurementAndCCM && contract.Phase == ContractPhase.DangInKy && target == ContractPhase.DangTrinhKy) + return; // OK skip CCM + + // 4. Business rule: không cho skip phase + // (adjacency table đã enforce — chỉ transition nào khai báo mới pass) + } +} +``` + +## 5. Reject flow (ví dụ CCM reject) + +```mermaid +sequenceDiagram + actor CCM + participant FE + participant API + participant M as TransitionHandler + participant NS + participant DB + + CCM->>FE: Click "Yêu cầu sửa" + nhập comment + FE->>API: POST /contracts/{id}/transitions
{targetPhase: "DangSoanThao", decision: "Reject", comment: "Điều khoản 5 cần rõ hơn"} + API->>M: Send + + M->>DB: INSERT ContractApproval
(Phase=DangKiemTraCCM, Decision=Reject, Comment) + M->>DB: UPDATE Contract.Phase = DangSoanThao
SlaDeadline = UtcNow + 7d + + Note over M: KHÔNG xóa lịch sử approval các phase cũ
— giữ history + + M->>NS: NotifyRejection(contract, drafter) + NS->>NS: Email drafter + in-app badge + M-->>API: 200 +``` + +**Lưu ý:** Drafter nhận thông báo + comment thread tự động có entry mới. Họ fix, rồi submit lại → re-trigger DangGopY (quay lại loop comment → duyệt). + +## 6. Comment thread + +Endpoint riêng (không phải transition): + +``` +POST /api/contracts/{id}/comments +Body: { content: "NCC này từng trễ giao hàng HĐ trước, cân nhắc thêm điều khoản phạt" } +``` + +Ghi nhận: +- `ContractComments.Phase = contract.Phase hiện tại` +- Hiển thị cùng timeline với approval history + +## 7. Timeline UI (fe-user `/contracts/{id}`) + +``` +┌────────────────────────────────────────────────────────┐ +│ HĐ: FLOCK 01/HĐGK/SOL&PVL/03 │ +│ NCC: Công ty PVL · Dự án: FLOCK 01 │ +│ Giá trị: 150,000,000 VND │ +│ Phase hiện tại: 🟡 Đang kiểm tra CCM │ +│ SLA: còn 2 ngày 4 giờ │ +├────────────────────────────────────────────────────────┤ +│ Timeline │ +│ │ +│ ● DangSoanThao 2026-04-15 10:00 Nguyen Van A │ +│ │ Drafter tạo draft │ +│ │ │ +│ ● DangGopY 2026-04-16 08:30 Tran Thi B (PM) │ +│ │ "Scope work cần chi tiết hơn mục 3" │ +│ ● Le Van C (CCM) │ +│ │ "OK giá" │ +│ │ │ +│ ● DangDamPhan 2026-04-17 14:20 Nguyen Van A │ +│ ● DangInKy 2026-04-18 09:15 NCC đã ký │ +│ │ +│ 🟡 DangKiemTraCCM (đang ở phase này) │ +│ Chờ CCM kiểm tra │ +│ │ +│ ⚪ DangTrinhKy │ +│ ⚪ DangDongDau │ +│ ⚪ DaPhatHanh │ +├────────────────────────────────────────────────────────┤ +│ Action (tùy role): │ +│ [✅ Duyệt → TrinhKy] [❌ Yêu cầu sửa] │ +│ │ +│ Comment: │ +│ [Textarea] │ +│ [Gửi comment] │ +└────────────────────────────────────────────────────────┘ +``` + +## 8. Notifications + +| Event | Recipient | Channel | +|---|---|---| +| `DangSoanThao` → `DangGopY` | Tất cả role góp ý (PD, PM, PRO, CCM, FIN, ACT) | Email + in-app | +| Chuyển `DangKiemTraCCM` | Mọi user role `CostControl` | Email + in-app | +| Chuyển `DangTrinhKy` | Mọi user role `Director` + `AuthorizedSigner` | Email (high priority) + in-app | +| SLA còn 20% thời gian | Role đang giữ phase | In-app warning | +| SLA hết → auto-approve | Drafter + role giữ phase | Email + in-app | +| Reject → quay về `DangSoanThao` | Drafter (người tạo) | Email + in-app | +| Mã HĐ được gen khi BOD ký | Drafter | In-app | + +## 9. Audit trail + +Mọi transition tạo 1 row trong `ContractApprovals`. Ngoài ra, nếu cần log granular hơn, Phase 4 thêm `AuditLogs` table ghi mọi command (không chỉ workflow): + +```csharp +public class AuditBehavior : IPipelineBehavior +{ + public async Task Handle(TReq request, RequestHandlerDelegate next, CancellationToken ct) + { + var response = await next(); + // Log: command name, user, payload, timestamp + _db.AuditLogs.Add(new AuditLog { ... }); + await _db.SaveChangesAsync(ct); + return response; + } +} +``` + +## 10. Edge cases + +| Case | Xử lý | +|---|---| +| User A và User B cùng role CCM click "Duyệt" đồng thời | Optimistic concurrency với `RowVersion` — người sau nhận 409 Conflict | +| Drafter cố submit từ `DangSoanThao` → `DangKiemTraCCM` (skip phase) | Adjacency table reject → 403 | +| BOD reject ở phase `DangTrinhKy` sau khi mã HĐ đã gen | Giữ mã HĐ (không revert seq) + update Phase=`DangSoanThao`. Khi BOD duyệt lại → vẫn dùng mã cũ (không gen lại) | +| User role không map đúng phase (vd Finance cố duyệt `DangKiemTraCCM`) | Guard reject 403 | +| HĐ với Chủ đầu tư (`BypassProcurementAndCCM=true`) | Từ `DangInKy` → cho phép nhảy `DangTrinhKy` bỏ qua CCM | +| Xóa HĐ ở phase > `DangInKy` | Block — soft delete cũng không cho (business rule) | + +## 11. Liên quan + +- [`../workflow-contract.md`](../workflow-contract.md) — spec gốc +- [`sla-expiry-flow.md`](sla-expiry-flow.md) — auto-approve job +- [`contract-creation-flow.md`](contract-creation-flow.md) — tạo HĐ trước khi vào flow này +- [`form-render-flow.md`](form-render-flow.md) — render file khi chuyển phase diff --git a/docs/flows/contract-creation-flow.md b/docs/flows/contract-creation-flow.md new file mode 100644 index 0000000..39cbc88 --- /dev/null +++ b/docs/flows/contract-creation-flow.md @@ -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
/contracts/new + participant API as ContractsController + participant CMD as CreateContractCommandHandler + participant TMPL as FormsController
.GetTemplate + participant REN as IFormRenderer + participant DB + participant FS as File Storage
(wwwroot/uploads) + + D->>FE: Click "Tạo HĐ mới" + + D->>FE: Step 1 — Chọn loại HĐ
(ContractType dropdown) + FE->>API: GET /api/forms/templates?type={contractType} + API->>DB: SELECT ContractTemplates WHERE Type = ? AND IsActive = 1 + API-->>FE: Template list
[{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
theo fieldSpec + + D->>FE: Step 3 — Điền field (NCC, dự án,
giá trị, hạng mục, nghiệm thu…) + + D->>FE: Click "Preview" + FE->>API: POST /api/forms/render
{templateId, data} + API->>REN: DocxRenderer.Render(template, data) + REN->>REN: Load .docx template
replace placeholder {{field}}
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
(convert server-side via LibreOffice) + + D->>FE: Step 4 — Click "Lưu draft" + FE->>API: POST /api/contracts
{type, supplierId, projectId,
templateId, draftData} + API->>CMD: Send(CreateContractCommand) + CMD->>CMD: Validate (FluentValidation)
supplier exists, project active... + CMD->>DB: INSERT Contracts (Phase=DangSoanThao,
DraftData=JSON.serialize) + CMD-->>API: ContractId + API-->>FE: 201 Created + Location header + + FE->>D: Redirect /contracts/{id}
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 +{ + 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(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 diff --git a/docs/flows/form-render-flow.md b/docs/flows/form-render-flow.md new file mode 100644 index 0000000..08f533f --- /dev/null +++ b/docs/flows/form-render-flow.md @@ -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
(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
(w:t nodes) + loop Each w:t + DOCX->>DOCX: Regex replace {{field}} → value + end + + DOCX->>DOCX: Process {{#loop}} blocks:
1. Find row/paragraph có placeholder loop
2. Clone n-1 lần cho n items
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]
(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
--outdir /tmp in.docx] + Run -->|exit 0| Read[Read /tmp/in.pdf bytes] + Run -->|exit != 0| Fail[Log error
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
/master/contract-templates + participant API as FormsController.UploadTemplate + participant VAL as TemplateValidator + participant DB + participant FS + + Admin->>FE: Upload file .docx + metadata
(formCode, name, contractType, fieldSpec JSON) + FE->>API: POST /api/forms/templates
multipart: file + metadata + API->>VAL: ParsePlaceholders(file) + VAL->>VAL: Extract all {{field}} tokens
→ 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
(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 (``) | `"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 `` elements (Word quirk) | DocxRenderer normalize trước: merge adjacent `` 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Đ diff --git a/docs/flows/permission-flow.md b/docs/flows/permission-flow.md new file mode 100644 index 0000000..3485b9e --- /dev/null +++ b/docs/flows/permission-flow.md @@ -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
JOIN Roles ON Permissions.RoleId = Roles.Id
JOIN MenuItems ON Permissions.MenuKey = MenuItems.Key
WHERE Roles.Id IN (user's roles) + + DB-->>Q: raw rows + + Q->>Q: Group by MenuKey
UNION CRUD flags (OR)
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
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
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 + + + + +// Route guard +}> + + + } +/> +``` + +## 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
{roleId, menuKey: "Contracts", canRead, canCreate, canUpdate, canDelete} + API->>M: Send(UpsertPermissionCommand) + + M->>DB: SELECT existing Permission
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
sẽ thấy nút Update HĐ
sau khi họ refresh/re-login
(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 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(); +``` + +**Handler:** + +```csharp +public class MenuPermissionHandler : AuthorizationHandler +{ + 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 diff --git a/docs/flows/sla-expiry-flow.md b/docs/flows/sla-expiry-flow.md new file mode 100644 index 0000000..9f9a78b --- /dev/null +++ b/docs/flows/sla-expiry-flow.md @@ -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
ExecuteAsync]) --> Loop{Loop} + Loop --> Wait[Wait 15 minutes
Task.Delay with CancellationToken] + Wait --> Query[Query Contracts
WHERE SlaDeadline IS NOT NULL
AND SlaDeadline < UtcNow
AND Phase NOT IN finished_phases] + Query -->|count > 0| Process[Process each contract] + Query -->|count = 0| Loop + + Process --> Decide{Có role
next để
auto approve?} + Decide -->|yes| AutoApprove[Transition → nextPhase
với system actor
Decision=AutoApprove] + Decide -->|no
(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 _logger; + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); + + public SlaExpiryJob(IServiceProvider sp, ILogger 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(); + var workflow = scope.ServiceProvider.GetRequiredService(); + 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(); +``` + +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