Compare commits

...

4 Commits

Author SHA1 Message Date
12daa7f6b0 [CLAUDE] Docs: Session 17 schema mới ApprovalWorkflowsV2 (Chunk D)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m16s
Update STATUS row mới + HANDOFF brief Session 17 + CLAUDE.md count
22 migration / 58 bảng.

Tóm tắt session:
- User chốt sau S16: schema flat Mig 21 vẫn không đúng intent → yêu
  cầu viết lại + thêm Menu mới "Duyệt NCC (Mới)" với cấu trúc explicit
  Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
- 4 commit (3 chunk per-commit + docs): Mig 22 + 3 bảng mới +
  Application CQRS + API + FE Designer mới.
- PE/Contract Service CHƯA wire — vẫn pin Mig 21 legacy.
- Sau UAT UIUX OK → Session sau pin ApprovalWorkflowId song song +
  Service rewrite + migrate data + drop legacy.
- Backward compat 100%, 77 test pass no regression.
2026-05-08 12:48:06 +07:00
2781c7ea09 [CLAUDE] FE-Admin: Designer Quy trình duyệt mới V2 (Chunk C)
Page mới `/system/approval-workflows-v2/:typeCode` mirror Designer cũ
nhưng theo schema Mig 22:
  Bước (Phòng) > N Cấp (mỗi cấp = 1 NV cụ thể qua Select duy nhất)

Files:
- fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx (new — 480 LOC)
  - Overview cards (Active version + History list per ApplicableType)
  - DefinitionCard read-only render Bước → Cấp với approver name + email
  - Designer dialog: Mã/Tên/Mô tả + reorder Step/Level (chevron up/down)
    + Add/Remove Step + Add/Remove Level + Select Phòng + Select NV duyệt
  - Validate: mỗi Step phải có ≥1 Level, mỗi Level phải có approverUserId
  - Auto-assign code QT-DN-V2-001 / QT-DN-PA-V2-001 / QT-HD-V2-001
- fe-admin/src/lib/menuKeys.ts (+2 const sync với BE MenuKeys)
- fe-admin/src/components/Layout.tsx (resolver: ApprovalWorkflowsV2 root +
  AwV2_<TypeCode> leaf → /system/approval-workflows-v2/<code>)
- fe-admin/src/App.tsx (import + 2 route)

Verify: npm build fe-admin OK, 1924 modules transformed, 0 TS error.

Next: Chunk D — STATUS + HANDOFF + CLAUDE.md update + final commit.
2026-05-08 12:45:00 +07:00
f6047d5218 [CLAUDE] Workflow: App CQRS + API ApprovalWorkflowsV2 (Chunk B)
3 handler MediatR + Validator + Controller cho schema mới Mig 22.

Files:
- Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs
  - GetAwAdminOverviewQuery (filter optional ApplicableType)
  - CreateAwDefinitionCommand + Validator (auto-increment Version
    theo Code, deactivate active version cùng ApplicableType)
  - DeleteAwDefinitionCommand (UAT helper — chưa pin nên unconditional)
  - DTO: AwDefinition/AwStep/AwLevel + TypeSummary
- Application/Common/Interfaces/IApplicationDbContext.cs (3 DbSet)
- Api/Controllers/ApprovalWorkflowsV2Controller.cs
  - Route /api/approval-workflows-v2
  - GET ?applicableType=N | POST | DELETE/{id}
  - Reuse policy Workflows.Read/Workflows.Create

Verify: build OK 0 error, IApplicationDbContext expose 3 DbSet mới.

Next: Chunk C — FE Designer page + route + Layout resolver.
2026-05-08 12:42:03 +07:00
c847dc0b24 [CLAUDE] Workflow: Mig 22 schema mới ApprovalWorkflowsV2 (Chunk A)
Session 17 — schema riêng UAT trước khi drop legacy WorkflowDefinition.
Cấu trúc 3 bảng theo yêu cầu user:
  Quy trình (Code+Name+ApplicableType)
    Bước (Phòng A — DepartmentId hint)
      Cấp (NV X — ApproverUserId 1 user cụ thể, KHÔNG OR-of-many)

Khác Mig 21: Levels match 1 NV CHÍNH XÁC qua ApproverUserId, không
match group Dept+PositionLevel/Role/User. Service sau UAT iterate
Steps OrderBy Order → Levels OrderBy Order → ApproverUserId duyệt.

Files:
- Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs (3 entity + enum
  ApplicableType: DuyetNcc/DuyetNccPhuongAn/Contract)
- Infra/Persistence/Configurations/ApprovalWorkflowConfiguration.cs
  (FK Cascade Step→Workflow, Level→Step; Restrict Department + User)
- Infra/Persistence/ApplicationDbContext.cs (3 DbSet)
- Infra/Persistence/DbInitializer.cs (2 menu mới: ApprovalWorkflowsV2
  root dưới System icon Workflow + AwV2_DuyetNcc leaf icon FileCheck)
- Domain/Identity/MenuKeys.cs (2 const + All array)
- Migration 20260508053749_AddApprovalWorkflowsV2 (3 table CREATE +
  2 UNIQUE + 3 index)

Verify:
- Build OK, 77 test pass (54 Domain + 23 Infra) ~3s
- Mig applied cả _Design + _Dev LocalDB

Next chunks:
- B: Application CQRS (Get/Create) + ApprovalWorkflowsV2Controller
- C: FE Designer page /system/approval-workflows-v2/:typeCode
- D: Docs + STATUS update
2026-05-08 12:39:37 +07:00
18 changed files with 5323 additions and 4 deletions

View File

@ -50,7 +50,7 @@ Kiến trúc: **.NET 10 Clean Architecture + 2 React FE (admin + user) + SQL Ser
- Audit fields: `CreatedAt`, `UpdatedAt`, `CreatedBy`, `UpdatedBy` (`BaseEntity`) - Audit fields: `CreatedAt`, `UpdatedAt`, `CreatedBy`, `UpdatedBy` (`BaseEntity`)
- Soft delete: `IsDeleted`, `DeletedAt`, `DeletedBy` (`AuditableEntity`) - Soft delete: `IsDeleted`, `DeletedAt`, `DeletedBy` (`AuditableEntity`)
- Migrations: `dotnet ef migrations add <Name> --project src/Backend/SolutionErp.Infrastructure --startup-project src/Backend/SolutionErp.Api` - Migrations: `dotnet ef migrations add <Name> --project src/Backend/SolutionErp.Infrastructure --startup-project src/Backend/SolutionErp.Api`
- **Hiện có 21 migration → 55 bảng** (Phase 9+ — Mig 21 `RefactorWorkflowToFlatModel` DRASTIC REFACTOR Session 16. Bỏ phase enum legacy (2-9 + 98 deprecated giữ data cũ), dùng ChoDuyet=10 đơn nhất + CurrentWorkflowStepIndex tracking. Workflow flat list (Phòng × Cấp × Approvers). WorkflowStep + DepartmentId+PositionLevel. Drop InnerStep entities (Mig 18+20). Service rewrite iterate steps OrderBy Order, advance pointer per approve. Match approver Dept+PositionLevel OR Approvers Role/User. PE 3-button Duyệt forward / Trả lại smart-reject jump-back / Từ chối khoá phiếu) - **Hiện có 22 migration → 58 bảng** (Phase 9+ — Mig 22 `AddApprovalWorkflowsV2` Session 17 schema mới UAT trước khi drop legacy: Quy trình > Bước (Phòng) > Cấp (NV cụ thể qua ApproverUserId — KHÔNG OR-of-many). Schema riêng `ApprovalWorkflows` + `ApprovalWorkflowSteps` + `ApprovalWorkflowLevels`. PE/Contract Service CHƯA wire — vẫn pin Mig 21 legacy. Mig 21 `RefactorWorkflowToFlatModel` DRASTIC REFACTOR Session 16. Phase enum simplify ChoDuyet=10 đơn nhất + CurrentWorkflowStepIndex tracking. Workflow flat list (Phòng × Cấp × Approvers). PE 3-button Duyệt/Trả lại/Từ chối)
### Modules ### Modules

View File

@ -1,6 +1,70 @@
# HANDOFF — Brief 5 phút cho session tiếp theo # HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-05-08 (Session 16**🎯 DRASTIC REFACTOR DONE: flat workflow Phòng × Cấp + Mig 21 + Service rewrite + FE Designer rewrite. 2 commit Chunk A+B. 96 → 77 test pass (drop 19 legacy). Backward compat: legacy phase 2-9+98 enum giữ cho data cũ.**) **Last updated:** 2026-05-08 (Session 17**🎯 SCHEMA MỚI ApprovalWorkflowsV2 + Menu "Duyệt NCC (Mới)" UAT. 4 commit. Schema riêng trên Mig 22. Cấu trúc Bước (Phòng) > Cấp (NV cụ thể qua ApproverUserId — KHÔNG OR-of-many). Chỉ admin Designer xong, Service PE/Contract chưa wire qua schema mới — UAT UIUX trước.**)
## TL;DR Session 17 (08/05 — Schema mới UAT trước khi drop legacy)
User chốt sau Session 16: schema flat Mig 21 vẫn không đúng intent. Yêu cầu **viết lại toàn bộ chỗ Quy trình Duyệt + thêm Menu mới "Duyệt NCC (Mới)"** với cấu trúc rõ ràng:
```
Mã Quy trình - Tên Quy trình
* Bước 1 - Phòng A
* Cấp 1 - NV X ← 1 user CỤ THỂ qua ApproverUserId
* Cấp 2 - NV Y
* Bước 2 - Phòng B
* Cấp 1 - NV Z
```
Khác Mig 21: mỗi Cấp = 1 NV chính xác, KHÔNG OR-of-many group Dept+PositionLevel/Role/User.
**4 commit (3 chunk per-commit + docs):**
### Chunk A (`c847dc0`) — Domain + EF + Mig 22 + Menu
- Domain `ApprovalWorkflowsV2/ApprovalWorkflow.cs` — 3 entity (ApprovalWorkflow + Step + Level) + enum `ApprovalWorkflowApplicableType` (DuyetNcc=1 / DuyetNccPhuongAn=2 / Contract=3)
- EF `ApprovalWorkflowConfiguration.cs` — UNIQUE (Code, Version), FK Cascade Step→Workflow + Level→Step, FK Restrict Department + ApproverUserId
- ApplicationDbContext +3 DbSet
- **Migration 22** `AddApprovalWorkflowsV2` — 3 CREATE TABLE + 1 UNIQUE + 4 INDEX. Applied cả `_Design` + `_Dev` LocalDB
- DbInitializer SeedMenusAsync: +menu `ApprovalWorkflowsV2` root dưới System (icon Workflow) + leaf `AwV2_DuyetNcc` (icon FileCheck, label "Duyệt NCC (Mới)")
- MenuKeys.cs +2 const trong All array
### Chunk B (`f6047d5`) — Application CQRS + API
- `Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs`:
- `GetAwAdminOverviewQuery(ApplicableType?)` — load 3-level Include + dept/user names map
- `CreateAwDefinitionCommand` + Validator — auto-increment Version theo Code, deactivate active version cùng ApplicableType
- `DeleteAwDefinitionCommand` — UAT helper unconditional (chưa pin)
- DTO AwDefinition/AwStep/AwLevel + AwTypeSummary
- IApplicationDbContext +3 DbSet
- `Api/Controllers/ApprovalWorkflowsV2Controller` — route `/api/approval-workflows-v2`, GET ?applicableType=N | POST | DELETE/{id}, reuse policy `Workflows.Read` + `Workflows.Create`
### Chunk C (`2781c7e`) — FE Designer
- `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx` (~480 LOC)
- Overview cards Active+History per ApplicableType
- DefinitionCard read-only: Bước (badge phòng emerald) → Cấp (badge violet C1/C2 + tên NV + email)
- Designer dialog: Mã/Tên/Mô tả + Add/Remove Step + reorder (chevron up/down) + Add/Remove Level + Select Phòng + Select NV duyệt
- Validate: mỗi Step ≥1 Level, mỗi Level phải có ApproverUserId
- Auto-assign code mặc định theo type: `QT-DN-V2-001` / `QT-DN-PA-V2-001` / `QT-HD-V2-001`
- Layout.tsx resolver +ApprovalWorkflowsV2 root → `/system/approval-workflows-v2`, +AwV2_<TypeCode> leaf → `/system/approval-workflows-v2/<code>`
- App.tsx +2 route
- menuKeys.ts +2 const sync với BE
### Chunk D — Docs (current)
STATUS + HANDOFF + project_solution_erp.md memory.
## ⚠️ Điều quan trọng cho Session 18+
1. **PE/Contract Service CHƯA wire** qua schema mới — vẫn pin `WorkflowDefinitionId` từ Mig 21 (legacy). Sau khi user UAT UIUX OK, Session sau cần:
- Thêm pinning `ApprovalWorkflowId` vào PE/Contract entity (nullable, song song WorkflowDefinitionId)
- Service rewrite: nếu pin V2 → iterate ApprovalWorkflowSteps OrderBy Order → ApprovalWorkflowLevels OrderBy Order → match `actor.Id == level.ApproverUserId` (chính xác 1-1)
- Migration data từ legacy → V2 (manual hoặc seed sample)
- Drop legacy WorkflowDefinition + WorkflowStep + Approver tables sau khi không phiếu nào pin
2. **Backward compat 100%** — schema mới chỉ thêm vào, không sửa cũ. Phiếu PE/Contract đang chạy không bị ảnh hưởng.
3. **77 test pass giữ nguyên** — không thêm test mới (Designer UI chưa critical, defer khi Service wire xong → test policy + match approver).
---
## TL;DR Session 16 (08/05 — Drastic refactor flat workflow EXECUTE) ## TL;DR Session 16 (08/05 — Drastic refactor flat workflow EXECUTE)

View File

@ -2,9 +2,9 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-05-08 (Session 16**🎯 DRASTIC REFACTOR: flat workflow Phòng × Cấp + Migration 21. Bỏ phase enum legacy 2-9, dùng `ChoDuyet=10` đơn nhất + `CurrentWorkflowStepIndex` tracking + `RejectedAtStepIndex` resume. WorkflowStep + DepartmentId/PositionLevel. Drop InnerStep entities (Mig 18+20 deprecated). Service rewrite PE + Contract. App CQRS DTOs simplified. Tests 96 → 77 (drop 19 N-stage/2-stage legacy). FE Designer rewrite flat UI. 2 commit Chunk A + B (`dbb0089`, `88a5be1`). Backward compat: legacy phase values 2-9 + 98 giữ enum cho data cũ.**) **Last updated:** 2026-05-08 (Session 17**🎯 SCHEMA MỚI hoàn toàn `ApprovalWorkflowsV2` (Mig 22) + Menu mới "Duyệt NCC (Mới)" UAT trước khi drop legacy. User chốt sau Session 16: "Thấy vẫn không đúng, viết lại toàn bộ chỗ Quy trình Duyệt + thêm Menu mới". Cấu trúc Quy trình > Bước (Phòng) > Cấp (NV cụ thể qua ApproverUserId — KHÔNG OR-of-many như Mig 21). 4 commit Chunk A+B+C (`c847dc0`, `f6047d5`, `2781c7e`) + Chunk D docs.**)
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 21 migrations, ~134 API endpoints, 32 FE pages. 77 unit test pass** (54 Domain + 23 Infra). 41 gotcha. 30 demo user. 6 skill. **5 PE display status** (Bản nháp / Đã gửi duyệt / Trả lại / Đã duyệt / Từ chối). **Phase enum simplified post-Mig 21**: DangSoanThao=1, ChoDuyet=10 (NEW generic), DaDuyet=7, TuChoi=99. Legacy 2-6 + 98 deprecated. **Flat workflow Mig 21**: WorkflowStep + DepartmentId+PositionLevel + Approvers. PE/Contract + CurrentWorkflowStepIndex/RejectedAtStepIndex tracking. Service iterate steps OrderBy Order, advance pointer per approve. Match approver Dept+PositionLevel (OR cùng cấp/dept). Hết step → DaDuyet/DaPhatHanh. ## 📍 Phase hiện tại: **Phase 9 active — UAT (workflow schema mới Mig 22 đợi user test)** — **58 DB tables (+3 Mig 22 ApprovalWorkflows + Steps + Levels), 22 migrations, ~137 API endpoints (+3 V2), 33 FE pages (+1 Designer V2). 77 unit test pass** (54 Domain + 23 Infra). 41 gotcha. 30 demo user. 6 skill. **5 PE display status** (Bản nháp / Đã gửi duyệt / Trả lại / Đã duyệt / Từ chối). **Phase enum simplified post-Mig 21**: DangSoanThao=1, ChoDuyet=10 (NEW generic), DaDuyet=7, TuChoi=99. Legacy 2-6 + 98 deprecated. **Workflow schemas đang đồng tồn tại**: (1) Mig 21 `WorkflowDefinition` flat — pin với PE/Contract đang chạy. (2) Mig 22 `ApprovalWorkflow` mới (UAT) — Bước (Phòng) > Cấp (NV ApproverUserId). Sau UAT → migrate data + drop legacy.
### 🌐 Production URLs ### 🌐 Production URLs
@ -61,6 +61,7 @@
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-05-08 | Claude | **🎯 SESSION 17 — Schema mới `ApprovalWorkflowsV2` (Mig 22) + Menu "Duyệt NCC (Mới)" UAT (3 commit per-chunk)** — User chốt sau Session 16 drastic refactor: "Thấy vẫn không đúng, viết lại toàn bộ chỗ Quy trình Duyệt + thêm 1 Menu chỗ quy trình duyệt nữa là Duyệt NCC (Mới)" với cấu trúc explicit `Mã Quy trình - Tên Quy trình / * Bước 1 - Phòng A / * Cấp 1 - NV X / * Cấp 2 - NV Y`. Schema riêng UAT trước khi drop legacy. Khác Mig 21: mỗi Cấp = 1 NV CỤ THỂ qua `ApproverUserId` (KHÔNG OR-of-many). **Chunk A (`c847dc0`)** Domain `ApprovalWorkflowsV2/ApprovalWorkflow.cs` — 3 entity (ApprovalWorkflow + Step + Level) + enum `ApprovalWorkflowApplicableType` (DuyetNcc/DuyetNccPhuongAn/Contract). EF `ApprovalWorkflowConfiguration.cs` 3 IEntityTypeConfiguration: ToTable + UNIQUE (Code, Version) + FK Cascade Step→Workflow + FK Cascade Level→Step + FK Restrict Department + FK Restrict User. ApplicationDbContext 3 DbSet. **Migration 22** `AddApprovalWorkflowsV2` — 3 CREATE TABLE + 1 UNIQUE + 4 INDEX, applied cả `_Design` + `_Dev` LocalDB. DbInitializer SeedMenusAsync +2 menu row (`ApprovalWorkflowsV2` root dưới System icon Workflow + `AwV2_DuyetNcc` leaf icon FileCheck). MenuKeys.cs +2 const + All array. **Chunk B (`f6047d5`)** Application `ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs` — 3 handler (GetAwAdminOverviewQuery filter optional ApplicableType, CreateAwDefinitionCommand auto-increment Version + deactivate active version cùng type, DeleteAwDefinitionCommand UAT helper unconditional) + Validator child rules NotEmpty Levels + ApproverUserId required. DTO AwDefinition/AwStep/AwLevel + AwTypeSummary. IApplicationDbContext +3 DbSet. Api Controller `ApprovalWorkflowsV2Controller` — GET ?applicableType=N | POST | DELETE/{id}, reuse policy Workflows.Read/Workflows.Create. **Chunk C (`2781c7e`)** FE-Admin `ApprovalWorkflowsV2Page.tsx` (~480 LOC) Designer mới mirror PE pattern nhưng theo schema V2: Overview cards Active+History per ApplicableType, DefinitionCard read-only render Bước → Cấp với approver name + email, Designer dialog Mã/Tên/Mô tả + reorder Step/Level (chevron up/down), Add/Remove Step + Level, Select Phòng + Select NV duyệt, validate level required NV. Auto-assign code QT-DN-V2-001 / QT-DN-PA-V2-001 / QT-HD-V2-001. Layout.tsx resolver +ApprovalWorkflowsV2/AwV2_<TypeCode>. App.tsx +2 route. menuKeys.ts +2 const sync BE. **KHÔNG đụng** PE/Contract Service (legacy WorkflowDefinition vẫn pin). Verify: dotnet build pass + 77 test pass (no regression) + npm build fe-admin pass. Backward compat 100% — schema mới chỉ thêm vào, không sửa cũ. **Next**: User UAT menu mới → confirm UX → Session sau migrate PE/Contract Service iterate ApprovalWorkflowSteps → drop legacy WorkflowDefinition. | `c847dc0` (A) · `f6047d5` (B) · `2781c7e` (C) · (current D docs) |
| 2026-05-08 | Claude | **🎯 SESSION 16 — DRASTIC REFACTOR flat workflow Phòng × Cấp (Mig 21, 2 commit Chunk A+B)** — Resume từ Session 15 defer plan. User chốt "bỏ phase enum hoàn toàn, dùng ChoDuyet=10 đơn nhất + currentStepIndex tracking". Per memory `feedback_drastic_refactor_scope`: dedicated session với context fresh, scope conservative 2x buffer (~8-10h estimate, actual ~3h). **Chunk A (`dbb0089`)** — Domain enum simplify (DangSoanThao=1, ChoDuyet=10 NEW, DaDuyet=7, TuChoi=99; legacy 2-6 + 98 deprecated giữ cho data cũ). WorkflowStep + DepartmentId Guid? FK Restrict + PositionLevel int? (PE + Contract mirror). PE/Contract entity + CurrentWorkflowStepIndex int? + RejectedAtStepIndex int?. Drop class WorkflowStepInnerStep + nav (PE + Contract). Drop *DepartmentApproval.InnerStepId column. EF Configurations: drop InnerStep config + restore simple unique non-filtered (Mig 19/20 filtered split reverse). DbContext drop DbSet<*WorkflowStepInnerStep> × 2. **Migration 21** `RefactorWorkflowToFlatModel` GỘP: 4 ALTER cols (PE/Contract CurrentStepIndex+RejectedAtStepIndex) + 2 ALTER (WorkflowStep DeptId+PositionLevel) + DROP TABLE x 2 (PEWorkflowStepInnerSteps + WorkflowStepInnerSteps Mig 18+20) + DROP InnerStepId column x 2 (PE+Contract DeptApproval) + DROP filtered indexes x 2 + restore simple unique x 2. PE + Contract Service rewrite TransitionAsync: phase transitions DangSoanThao→ChoDuyet (Drafter trình init idx=0) / ChoDuyet→ChoDuyet (advance idx) / ChoDuyet→DaDuyet/DaPhatHanh (last step done) / ChoDuyet→DangSoanThao (Trả lại save RejectedAtStepIndex) / ChoDuyet→TuChoi (Từ chối khoá vĩnh viễn). Match approver: actor.Dept==step.Dept AND actor.PositionLevel>=step.PositionLevel (OR cùng cấp/dept) OR Approvers.Kind=User match OR Kind=Role match. Admin role bypass policy. Last step done → gen mã HĐ (Contract only). App CQRS WorkflowStepDto + WorkflowStepInput drop InnerStep, add DepartmentId/DepartmentName/PositionLevel (PE + Contract mirror). Tests rewrite: DROP `PeNStageApprovalTests.cs` (6) + `ContractNStageApprovalTests.cs` (6) + `PeTwoStageApprovalTests.cs` (7) — legacy N-stage/2-stage no longer applicable. UPDATE `PeWorkflowAdminTests` signature. **96 → 77 test pass** (-19 legacy). 3-file rule Mig 21 (.cs + Designer + Snapshot) commit đủ. **Chunk B (`88a5be1`)** — FE-Admin Designer rewrite (PeWorkflowsPage + WorkflowsPage): drop InnerStepDto + EditInnerStep types, drop PHASE_OPTIONS auto-assign ChoDuyet=10, StepDto + EditStep + departmentId/positionLevel, copyFromDefinition simplified, Designer step UI rewrite (Tên + Phòng Select + Cấp Select + SLA + Approvers Role/User optional fallback, drop entire InnerSteps sub-section), DefinitionCard view hiển thị badge Phòng emerald + Cấp NV/PP/TP violet, save payload phase=10. types/purchaseEvaluation.ts (fe-admin + fe-user mirror) + ChoDuyet=10 enum + label "Đang duyệt" + color amber. **Chunk C (FE PeWorkflowPanel) SKIP** — existing UI compatible (workflow.nextPhases driven by BE simplified policy), reuse 3-button Trả lại/Từ chối logic Session 14 hoạt động trên ChoDuyet phase tự động. **KHÔNG đụng** Service Notify pattern + Changelog pattern (giữ hành vi Mig 16). Verify: dotnet build pass + Mig 21 LocalDB applied + 77 test pass + npm build × 2 pass. Memory `feedback_drastic_refactor_scope.md` validated: dedicated session approach hoạt động đúng dự đoán. | `dbb0089` (A) · `88a5be1` (B) | | 2026-05-08 | Claude | **🎯 SESSION 16 — DRASTIC REFACTOR flat workflow Phòng × Cấp (Mig 21, 2 commit Chunk A+B)** — Resume từ Session 15 defer plan. User chốt "bỏ phase enum hoàn toàn, dùng ChoDuyet=10 đơn nhất + currentStepIndex tracking". Per memory `feedback_drastic_refactor_scope`: dedicated session với context fresh, scope conservative 2x buffer (~8-10h estimate, actual ~3h). **Chunk A (`dbb0089`)** — Domain enum simplify (DangSoanThao=1, ChoDuyet=10 NEW, DaDuyet=7, TuChoi=99; legacy 2-6 + 98 deprecated giữ cho data cũ). WorkflowStep + DepartmentId Guid? FK Restrict + PositionLevel int? (PE + Contract mirror). PE/Contract entity + CurrentWorkflowStepIndex int? + RejectedAtStepIndex int?. Drop class WorkflowStepInnerStep + nav (PE + Contract). Drop *DepartmentApproval.InnerStepId column. EF Configurations: drop InnerStep config + restore simple unique non-filtered (Mig 19/20 filtered split reverse). DbContext drop DbSet<*WorkflowStepInnerStep> × 2. **Migration 21** `RefactorWorkflowToFlatModel` GỘP: 4 ALTER cols (PE/Contract CurrentStepIndex+RejectedAtStepIndex) + 2 ALTER (WorkflowStep DeptId+PositionLevel) + DROP TABLE x 2 (PEWorkflowStepInnerSteps + WorkflowStepInnerSteps Mig 18+20) + DROP InnerStepId column x 2 (PE+Contract DeptApproval) + DROP filtered indexes x 2 + restore simple unique x 2. PE + Contract Service rewrite TransitionAsync: phase transitions DangSoanThao→ChoDuyet (Drafter trình init idx=0) / ChoDuyet→ChoDuyet (advance idx) / ChoDuyet→DaDuyet/DaPhatHanh (last step done) / ChoDuyet→DangSoanThao (Trả lại save RejectedAtStepIndex) / ChoDuyet→TuChoi (Từ chối khoá vĩnh viễn). Match approver: actor.Dept==step.Dept AND actor.PositionLevel>=step.PositionLevel (OR cùng cấp/dept) OR Approvers.Kind=User match OR Kind=Role match. Admin role bypass policy. Last step done → gen mã HĐ (Contract only). App CQRS WorkflowStepDto + WorkflowStepInput drop InnerStep, add DepartmentId/DepartmentName/PositionLevel (PE + Contract mirror). Tests rewrite: DROP `PeNStageApprovalTests.cs` (6) + `ContractNStageApprovalTests.cs` (6) + `PeTwoStageApprovalTests.cs` (7) — legacy N-stage/2-stage no longer applicable. UPDATE `PeWorkflowAdminTests` signature. **96 → 77 test pass** (-19 legacy). 3-file rule Mig 21 (.cs + Designer + Snapshot) commit đủ. **Chunk B (`88a5be1`)** — FE-Admin Designer rewrite (PeWorkflowsPage + WorkflowsPage): drop InnerStepDto + EditInnerStep types, drop PHASE_OPTIONS auto-assign ChoDuyet=10, StepDto + EditStep + departmentId/positionLevel, copyFromDefinition simplified, Designer step UI rewrite (Tên + Phòng Select + Cấp Select + SLA + Approvers Role/User optional fallback, drop entire InnerSteps sub-section), DefinitionCard view hiển thị badge Phòng emerald + Cấp NV/PP/TP violet, save payload phase=10. types/purchaseEvaluation.ts (fe-admin + fe-user mirror) + ChoDuyet=10 enum + label "Đang duyệt" + color amber. **Chunk C (FE PeWorkflowPanel) SKIP** — existing UI compatible (workflow.nextPhases driven by BE simplified policy), reuse 3-button Trả lại/Từ chối logic Session 14 hoạt động trên ChoDuyet phase tự động. **KHÔNG đụng** Service Notify pattern + Changelog pattern (giữ hành vi Mig 16). Verify: dotnet build pass + Mig 21 LocalDB applied + 77 test pass + npm build × 2 pass. Memory `feedback_drastic_refactor_scope.md` validated: dedicated session approach hoạt động đúng dự đoán. | `dbb0089` (A) · `88a5be1` (B) |
| 2026-05-07 | Claude | **🎯 SESSION 15 — Tooltip diagnose "Lưu & Gửi Duyệt" + Plan drastic refactor flat workflow → DEFER** — User UAT live screenshot phiếu PE Bản nháp + báo "Lưu & Gửi Duyệt" KHÔNG hoạt động + suy đoán "trùng ID với phiếu khác". Chẩn đoán: button silent disabled khi `evaluation.workflow.nextPhases` không có forward phase (chỉ TuChoi/TraLai). FE chưa có visual feedback → user không biết. Improvement (commit `835cc7f`): compute `forwardPhase` once + add `submitDisabledReason` string giải thích reason (canEditPhase=false / readOnly / !forwardPhase với hint admin kiểm tra cấu hình quy trình) + button title attribute show reason hover hoặc forward phase label khi enabled + Dialog confirm show forward phase explicit "Sẽ chuyển sang Chờ Purchasing". Mirror fe-admin + fe-user. Build pass cả 2. **"Trùng ID" KHÔNG phải bug FE** — `PurchaseEvaluationWorkspacePage` URL state đúng (`+ Thêm mới` clear `id`, save set new), mỗi PE row unique GUID + MaPhieu. **Tiếp theo plan drastic refactor**: User chốt "bỏ phase enum hoàn toàn, dùng ChoDuyet=10 đơn nhất + currentStepIndex tracking" + workflow flat list (Phòng × Cấp × Users[]) thay InnerStep model. Surface 6 chunk plan + start Chunk A: edit Domain entities (Phase enum +ChoDuyet=10, WorkflowStep +DeptId/PositionLevel, drop InnerStep class+nav, PE/Contract +CurrentWorkflowStepIndex/RejectedAtStepIndex, *DeptApproval drop InnerStepId) + EF Configurations (drop InnerStep config + nav, restore simple unique non-filtered) + DbContext drop DbSets — 12 files trong working tree. Realize scope realistic ~8-10h (PolicyRegistry rewrite + 2 Service rewrite + App CQRS + 12 tests rewrite + Designer FE + Migration 21 + Docs) vượt session boundary + risk session context deep ~30 commits. **REVERT working tree** về `835cc7f` clean. Add memory `feedback_drastic_refactor_scope` decision rule: drastic refactor cần dedicated session, ước tính conservative (2x buffer), tránh mid-session big refactor. **Stats unchanged**: 96 test pass, 20 mig, 57 bảng. | `835cc7f` | | 2026-05-07 | Claude | **🎯 SESSION 15 — Tooltip diagnose "Lưu & Gửi Duyệt" + Plan drastic refactor flat workflow → DEFER** — User UAT live screenshot phiếu PE Bản nháp + báo "Lưu & Gửi Duyệt" KHÔNG hoạt động + suy đoán "trùng ID với phiếu khác". Chẩn đoán: button silent disabled khi `evaluation.workflow.nextPhases` không có forward phase (chỉ TuChoi/TraLai). FE chưa có visual feedback → user không biết. Improvement (commit `835cc7f`): compute `forwardPhase` once + add `submitDisabledReason` string giải thích reason (canEditPhase=false / readOnly / !forwardPhase với hint admin kiểm tra cấu hình quy trình) + button title attribute show reason hover hoặc forward phase label khi enabled + Dialog confirm show forward phase explicit "Sẽ chuyển sang Chờ Purchasing". Mirror fe-admin + fe-user. Build pass cả 2. **"Trùng ID" KHÔNG phải bug FE** — `PurchaseEvaluationWorkspacePage` URL state đúng (`+ Thêm mới` clear `id`, save set new), mỗi PE row unique GUID + MaPhieu. **Tiếp theo plan drastic refactor**: User chốt "bỏ phase enum hoàn toàn, dùng ChoDuyet=10 đơn nhất + currentStepIndex tracking" + workflow flat list (Phòng × Cấp × Users[]) thay InnerStep model. Surface 6 chunk plan + start Chunk A: edit Domain entities (Phase enum +ChoDuyet=10, WorkflowStep +DeptId/PositionLevel, drop InnerStep class+nav, PE/Contract +CurrentWorkflowStepIndex/RejectedAtStepIndex, *DeptApproval drop InnerStepId) + EF Configurations (drop InnerStep config + nav, restore simple unique non-filtered) + DbContext drop DbSets — 12 files trong working tree. Realize scope realistic ~8-10h (PolicyRegistry rewrite + 2 Service rewrite + App CQRS + 12 tests rewrite + Designer FE + Migration 21 + Docs) vượt session boundary + risk session context deep ~30 commits. **REVERT working tree** về `835cc7f` clean. Add memory `feedback_drastic_refactor_scope` decision rule: drastic refactor cần dedicated session, ước tính conservative (2x buffer), tránh mid-session big refactor. **Stats unchanged**: 96 test pass, 20 mig, 57 bảng. | `835cc7f` |
| 2026-05-07 | Claude | **🎯 SESSION 14 — PE 3-button workflow Duyệt/Trả lại/Từ chối + Task 2 sample seed in-progress** — User chỉ thị thay 2-button approval bằng 3 hành động rõ ràng cho approver: **Duyệt** (forward), **Trả lại** (về DangSoanThao + Drafter sửa, smart reject Mig 16 + clear N-stage rows + Drafter resume jump-back), **Từ chối** (Phase=TuChoi, phiếu khoá vĩnh viễn 17 handler Mig 16 lock edit, Drafter phải tạo phiếu mới). 1 commit (`0d77698`): Domain `PurchaseEvaluationPolicy.cs` NccOnly + NccWithPlan thêm `(X → TuChoi)` transition cho mọi phase trung gian (ChoPurchasing/ChoCCM/ChoDuAn/ChoCEODuyetPA/ChoCEODuyetNCC) với roles của phase. FromDefinition expand: mỗi step (trừ DangSoanThao) thêm (step.Phase → TuChoi) với roles step. Service `PurchaseEvaluationWorkflowService.TransitionAsync` — Reject branch tách 2 case: target=TuChoi giữ nguyên (KHÔNG override + KHÔNG set RejectedFromPhase + KHÔNG clear N-stage); target khác (DangSoanThao) → smart reject (force DangSoanThao + RejectedFromPhase + clear N-stage). FE PeWorkflowPanel (admin + user mirror): render 3 button rõ ràng "✓ Duyệt → X" brand / "← Trả lại (về Drafter sửa)" red / "✗ Hủy / Từ chối" red. Decision logic: target=TuChoi || isSendBack → Reject (2), else Approve (1). Dialog confirm: title rõ + Cancel case warning red "phiếu sẽ bị khoá hoàn toàn" + SendBack case hint amber "Phiếu về DangSoanThao, Drafter sửa rồi trình lại — workflow tự jump tới phase này". Tests: rename `Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao``Reject_To_DangSoanThao_Sets_RejectedFromPhase_TraLai` (target từ TuChoi → DangSoanThao). NEW `Reject_To_TuChoi_Locks_Permanently_No_RejectedFromPhase`. Update `NStage_Reject_Clears_InnerStep_Rows_At_Phase` target → DangSoanThao. **95 → 96 test pass** (+1 Từ chối). **Task 2 sample seed in-progress**: dotnet ef database update applied Mig 9-20 lên LocalDB SolutionErp_Dev (trước đó chỉ Mig 1-8 vì DesignTimeDbContextFactory hardcoded SolutionErp_Design ≠ runtime SolutionErp_Dev — gotcha tooling distinction). API start để DbInitializer auto-seed 30 demo user nhưng exit 255 sớm khi log buffer full → seed dở dang. Defer Task 2 cho session sau (cần guidance: seed manual SQL hoặc manual API run + sample N-stage workflow def + Update PositionLevel cho 30 users existing). | `0d77698` | | 2026-05-07 | Claude | **🎯 SESSION 14 — PE 3-button workflow Duyệt/Trả lại/Từ chối + Task 2 sample seed in-progress** — User chỉ thị thay 2-button approval bằng 3 hành động rõ ràng cho approver: **Duyệt** (forward), **Trả lại** (về DangSoanThao + Drafter sửa, smart reject Mig 16 + clear N-stage rows + Drafter resume jump-back), **Từ chối** (Phase=TuChoi, phiếu khoá vĩnh viễn 17 handler Mig 16 lock edit, Drafter phải tạo phiếu mới). 1 commit (`0d77698`): Domain `PurchaseEvaluationPolicy.cs` NccOnly + NccWithPlan thêm `(X → TuChoi)` transition cho mọi phase trung gian (ChoPurchasing/ChoCCM/ChoDuAn/ChoCEODuyetPA/ChoCEODuyetNCC) với roles của phase. FromDefinition expand: mỗi step (trừ DangSoanThao) thêm (step.Phase → TuChoi) với roles step. Service `PurchaseEvaluationWorkflowService.TransitionAsync` — Reject branch tách 2 case: target=TuChoi giữ nguyên (KHÔNG override + KHÔNG set RejectedFromPhase + KHÔNG clear N-stage); target khác (DangSoanThao) → smart reject (force DangSoanThao + RejectedFromPhase + clear N-stage). FE PeWorkflowPanel (admin + user mirror): render 3 button rõ ràng "✓ Duyệt → X" brand / "← Trả lại (về Drafter sửa)" red / "✗ Hủy / Từ chối" red. Decision logic: target=TuChoi || isSendBack → Reject (2), else Approve (1). Dialog confirm: title rõ + Cancel case warning red "phiếu sẽ bị khoá hoàn toàn" + SendBack case hint amber "Phiếu về DangSoanThao, Drafter sửa rồi trình lại — workflow tự jump tới phase này". Tests: rename `Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao``Reject_To_DangSoanThao_Sets_RejectedFromPhase_TraLai` (target từ TuChoi → DangSoanThao). NEW `Reject_To_TuChoi_Locks_Permanently_No_RejectedFromPhase`. Update `NStage_Reject_Clears_InnerStep_Rows_At_Phase` target → DangSoanThao. **95 → 96 test pass** (+1 Từ chối). **Task 2 sample seed in-progress**: dotnet ef database update applied Mig 9-20 lên LocalDB SolutionErp_Dev (trước đó chỉ Mig 1-8 vì DesignTimeDbContextFactory hardcoded SolutionErp_Design ≠ runtime SolutionErp_Dev — gotcha tooling distinction). API start để DbInitializer auto-seed 30 demo user nhưng exit 255 sớm khi log buffer full → seed dở dang. Defer Task 2 cho session sau (cần guidance: seed manual SQL hoặc manual API run + sample N-stage workflow def + Update PositionLevel cho 30 users existing). | `0d77698` |

View File

@ -13,6 +13,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { RolesPage } from '@/pages/system/RolesPage' import { RolesPage } from '@/pages/system/RolesPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage' import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage' import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage'
import { ApprovalWorkflowsV2Page } from '@/pages/system/ApprovalWorkflowsV2Page'
import { FormsPage } from '@/pages/forms/FormsPage' import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
@ -51,6 +52,9 @@ function App() {
<Route path="/system/workflows/:typeCode" element={<WorkflowsPage />} /> <Route path="/system/workflows/:typeCode" element={<WorkflowsPage />} />
<Route path="/system/pe-workflows" element={<PeWorkflowsPage />} /> <Route path="/system/pe-workflows" element={<PeWorkflowsPage />} />
<Route path="/system/pe-workflows/:typeCode" element={<PeWorkflowsPage />} /> <Route path="/system/pe-workflows/:typeCode" element={<PeWorkflowsPage />} />
{/* Quy trình duyệt MỚI (Mig 22 — UAT) */}
<Route path="/system/approval-workflows-v2" element={<ApprovalWorkflowsV2Page />} />
<Route path="/system/approval-workflows-v2/:typeCode" element={<ApprovalWorkflowsV2Page />} />
<Route path="/forms" element={<FormsPage />} /> <Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} /> <Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} /> <Route path="/contracts/new" element={<ContractCreatePage />} />

View File

@ -91,6 +91,17 @@ function resolvePath(key: string): string | null {
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}` if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}`
} }
// Quy trình duyệt MỚI (Mig 22 — Session 17): root = group bowed, leaf =
// type-specific designer. Sau UAT thay thế PeWorkflows + Workflows cũ.
if (key === 'ApprovalWorkflowsV2') return '/system/approval-workflows-v2'
const awV2Match = key.match(/^AwV2_(.+)$/)
if (awV2Match) {
const code = awV2Match[1]
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') {
return `/system/approval-workflows-v2/${code}`
}
}
return null return null
} }

View File

@ -14,6 +14,9 @@ export const MenuKeys = {
Permissions: 'Permissions', Permissions: 'Permissions',
PurchaseEvaluations: 'PurchaseEvaluations', PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows', PeWorkflows: 'PeWorkflows',
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08)
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
} as const } as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys] export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,638 @@
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
// Schema riêng UAT trước khi drop legacy. Cấu trúc:
// Quy trình (Mã + Tên + ApplicableType)
// Bước 1 — Phòng A
// Cấp 1 — NV X (1 user CỤ THỂ qua ApproverUserId)
// Cấp 2 — NV Y
//
// Khác Designer cũ (PE workflow): Levels match 1 NV chính xác (KHÔNG OR-of-many).
import { useMemo, useState, type FormEvent } from 'react'
import { useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Department, Paged } from '@/types/master'
// ===== Types (mirror BE AwAdminOverviewDto) =====
type LevelDto = {
id: string
order: number
name: string | null
approverUserId: string
approverUserName: string | null
approverEmail: string | null
}
type StepDto = {
id: string
order: number
name: string // "Phòng A" — display
departmentId: string | null
departmentName: string | null
levels: LevelDto[]
}
type DefinitionDto = {
id: string
code: string
version: number
applicableType: number
applicableTypeLabel: string
name: string
description: string | null
isActive: boolean
activatedAt: string | null
createdAt: string
steps: StepDto[]
}
type TypeSummaryDto = {
applicableType: number
applicableTypeLabel: string
active: DefinitionDto | null
history: DefinitionDto[]
}
type EditLevel = { name: string; approverUserId: string }
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
// FE typeCode → BE int (giống MenuKeys ApplicableType)
const TYPE_CODE_TO_INT: Record<string, number> = {
DuyetNcc: 1,
DuyetNccPhuongAn: 2,
Contract: 3,
}
const DEFAULT_CODE_BY_TYPE: Record<number, string> = {
1: 'QT-DN-V2-001',
2: 'QT-DN-PA-V2-001',
3: 'QT-HD-V2-001',
}
function copyFromDefinition(d: DefinitionDto): EditStep[] {
return d.steps.map(s => ({
name: s.name,
departmentId: s.departmentId,
levels: s.levels.map(l => ({ name: l.name ?? '', approverUserId: l.approverUserId })),
}))
}
export function ApprovalWorkflowsV2Page() {
const qc = useQueryClient()
const { typeCode } = useParams<{ typeCode?: string }>()
const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null
const overview = useQuery({
queryKey: ['approval-workflow-v2-overview', selectedTypeInt],
queryFn: async () => {
const params = selectedTypeInt ? { applicableType: selectedTypeInt } : {}
return (await api.get<{ types: TypeSummaryDto[] }>('/approval-workflows-v2', { params })).data
},
})
const currentType = selectedTypeInt
? overview.data?.types.find(t => t.applicableType === selectedTypeInt)
: null
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Workflow className="h-5 w-5" />
{currentType
? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}`
: 'Quy trình duyệt (Mới)'}
</span>
}
description={
currentType
? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.'
: 'Schema mới UAT — chọn loại quy trình từ menu bên trái.'
}
/>
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{overview.data && !currentType && (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{overview.data.types.map(t => (
<div key={t.applicableType} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">{t.applicableTypeLabel}</h3>
{t.active && (
<span className="rounded bg-brand-50 px-2 py-0.5 font-mono text-[10px] font-medium text-brand-700">
{t.active.code} v{String(t.active.version).padStart(2, '0')}
</span>
)}
</div>
<div className="mt-2 text-xs text-slate-500">
{t.active
? `${t.active.steps.length} bước · ${t.active.steps.reduce((s, x) => s + x.levels.length, 0)} cấp · ${t.history.length} version`
: 'Chưa có quy trình'}
</div>
</div>
))}
</div>
)}
{currentType && (
<TypePanel
type={currentType}
onSaved={() => qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })}
/>
)}
</div>
)
}
// ===== Per-type panel =====
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(null)
const qc = useQueryClient()
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/approval-workflows-v2/${id}`),
onSuccess: () => {
toast.success('Đã xoá version')
qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })
},
onError: err => toast.error(getErrorMessage(err)),
})
return (
<div className="space-y-4">
{type.active ? (
<DefinitionCard
def={type.active}
isActive
onClone={d => { setCloneFrom(d); setDesignerOpen(true) }}
onDelete={() => {
if (confirm(`Xoá version đang áp dụng "${type.active!.code} v${type.active!.version}"?`)) {
del.mutate(type.active!.id)
}
}}
/>
) : (
<div className="rounded-xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500">
Chưa quy trình cho loại này. Tạo version đu tiên bên dưới.
</div>
)}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}>
<Plus className="h-4 w-4" />
Tạo quy trình mới
</Button>
</div>
{type.history.filter(d => !d.isActive).length === 0 && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-500">
Chưa version . Khi tạo version mới, version hiện tại tự đng archive.
</div>
)}
<div className="space-y-2">
{type.history.filter(d => !d.isActive).map(d => (
<DefinitionCard
key={d.id}
def={d}
isActive={false}
onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }}
onDelete={() => {
if (confirm(`Xoá version "${d.code} v${d.version}"?`)) del.mutate(d.id)
}}
/>
))}
</div>
{designerOpen && (
<Designer
applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel}
cloneFrom={cloneFrom}
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
/>
)}
</div>
)
}
// ===== Definition card (read-only) =====
function DefinitionCard({
def,
isActive,
onClone,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onDelete: () => void
}) {
return (
<div className={`rounded-xl border bg-white p-5 shadow-sm ${isActive ? 'border-brand-200' : 'border-slate-200'}`}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold text-slate-900">{def.name}</h3>
<span className="rounded bg-slate-100 px-2 py-0.5 font-mono text-[11px] text-slate-600">
{def.code} v{String(def.version).padStart(2, '0')}
</span>
{isActive ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<CheckCircle2 className="h-3 w-3" />
Đang áp dụng
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<History className="h-3 w-3" />
Archived
</span>
)}
</div>
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
<ol className="mt-3 space-y-2">
{def.steps.map(s => (
<li key={s.id} className="rounded-lg border border-slate-100 bg-slate-50/30 p-3">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
{s.order}
</div>
<span className="text-sm font-medium text-slate-800">Bước {s.order} {s.name}</span>
{s.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{s.departmentName}
</span>
)}
</div>
<ul className="mt-2 ml-9 space-y-1">
{s.levels.length === 0 ? (
<li className="text-[11px] italic text-slate-400">Chưa cấp duyệt</li>
) : (
s.levels.map(l => (
<li key={l.id} className="flex items-center gap-2 text-xs">
<span className="rounded-full bg-violet-100 px-2 py-0.5 font-mono text-[10px] font-bold text-violet-700">
C{l.order}
</span>
<span className="text-slate-700">{l.name || `Cấp ${l.order}`}</span>
<span className="text-slate-400"></span>
<span className="font-medium text-slate-800">
{l.approverUserName ?? l.approverUserId}
</span>
{l.approverEmail && (
<span className="text-[10px] text-slate-400">({l.approverEmail})</span>
)}
</li>
))
)}
</ul>
</li>
))}
</ol>
</div>
<div className="flex flex-col gap-1.5">
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
<Plus className="h-3.5 w-3.5" />
Tạo từ bản này
</Button>
<Button variant="outline" size="sm" onClick={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
Xoá version
</Button>
</div>
</div>
</div>
)
}
// ===== Designer dialog =====
function Designer({
applicableType,
applicableTypeLabel,
cloneFrom,
onClose,
onSaved,
}: {
applicableType: number
applicableTypeLabel: string
cloneFrom: DefinitionDto | null
onClose: () => void
onSaved: () => void
}) {
const initialSteps: EditStep[] = useMemo(
() =>
cloneFrom
? copyFromDefinition(cloneFrom)
: [{ name: 'Phòng 1', departmentId: null, levels: [{ name: 'Cấp 1', approverUserId: '' }] }],
[cloneFrom],
)
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
const usersList = useQuery({
queryKey: ['users-for-approver-v2'],
queryFn: async () =>
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', {
params: { page: 1, pageSize: 200 },
})).data.items,
})
const departmentsList = useQuery({
queryKey: ['departments-list-v2'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const save = useMutation({
mutationFn: async () => {
// Validate có user trong tất cả Cấp
for (const s of steps) {
if (s.levels.length === 0) throw new Error(`Bước "${s.name}" chưa có cấp duyệt nào.`)
for (const l of s.levels) {
if (!l.approverUserId) throw new Error(`Bước "${s.name}" có cấp chưa chọn nhân viên duyệt.`)
}
}
await api.post('/approval-workflows-v2', {
applicableType,
code,
name,
description: description || null,
steps: steps.map((s, i) => ({
order: i + 1,
name: s.name,
departmentId: s.departmentId,
levels: s.levels.map((l, j) => ({
order: j + 1,
name: l.name || null,
approverUserId: l.approverUserId,
})),
})),
})
},
onSuccess: () => {
toast.success('Đã lưu quy trình mới. Version cũ đã archive.')
onSaved()
},
onError: err => toast.error(getErrorMessage(err)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (steps.length === 0) {
toast.error('Phải có ít nhất 1 bước')
return
}
save.mutate()
}
function moveStep(idx: number, dir: -1 | 1) {
const newIdx = idx + dir
if (newIdx < 0 || newIdx >= steps.length) return
const next = [...steps]
;[next[idx], next[newIdx]] = [next[newIdx], next[idx]]
setSteps(next)
}
function moveLevel(stepIdx: number, levelIdx: number, dir: -1 | 1) {
const step = steps[stepIdx]
const newIdx = levelIdx + dir
if (newIdx < 0 || newIdx >= step.levels.length) return
const next = [...step.levels]
;[next[levelIdx], next[newIdx]] = [next[newIdx], next[levelIdx]]
setSteps(steps.map((x, i) => (i === stepIdx ? { ...x, levels: next } : x)))
}
return (
<Dialog
open
onClose={onClose}
title={`Tạo quy trình mới — ${applicableTypeLabel}`}
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>Hủy</Button>
<Button onClick={submit} disabled={save.isPending} form="aw-v2-form">
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
</Button>
</>
}
>
<form id="aw-v2-form" onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> quy trình *</Label>
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
<div className="text-[11px] text-slate-400">Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.</div>
</div>
<div className="space-y-1.5">
<Label>Tên hiển thị *</Label>
<Input value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="col-span-2 space-y-1.5">
<Label> tả</Label>
<Textarea rows={2} value={description} onChange={e => setDescription(e.target.value)} />
</div>
</div>
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<div className="flex items-center justify-between">
<Label>Các bước duyệt mỗi bước = 1 Phòng × N cấp NV ({steps.length} bước)</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setSteps([...steps, {
name: `Phòng ${steps.length + 1}`,
departmentId: departmentsList.data?.[0]?.id ?? null,
levels: [{ name: 'Cấp 1', approverUserId: usersList.data?.[0]?.id ?? '' }],
}])}
>
<Plus className="h-3.5 w-3.5" />
Thêm bước
</Button>
</div>
{steps.map((s, idx) => (
<div key={idx} className="rounded-md border border-slate-200 bg-slate-50/40 p-3">
<div className="flex items-start gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-600 text-[11px] font-bold text-white">
{idx + 1}
</div>
<div className="grid flex-1 grid-cols-3 gap-2">
<div>
<Label className="text-[11px]">Tên bước</Label>
<Input
value={s.name}
onChange={e => setSteps(steps.map((x, i) => (i === idx ? { ...x, name: e.target.value } : x)))}
placeholder="Phòng A"
/>
</div>
<div className="col-span-2">
<Label className="text-[11px]">Phòng (hint hiển thị)</Label>
<Select
value={s.departmentId ?? ''}
onChange={e =>
setSteps(steps.map((x, i) => (i === idx ? { ...x, departmentId: e.target.value || null } : x)))
}
>
<option value=""> Không </option>
{departmentsList.data?.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
</div>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveStep(idx, -1)}
disabled={idx === 0}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
title="Lên"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => moveStep(idx, 1)}
disabled={idx === steps.length - 1}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
title="Xuống"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setSteps(steps.filter((_, i) => i !== idx))}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
title="Xóa bước"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Levels */}
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
Cấp duyệt ({s.levels.length})
</span>
<button
type="button"
onClick={() =>
setSteps(steps.map((x, i) =>
i === idx
? { ...x, levels: [...x.levels, { name: `Cấp ${x.levels.length + 1}`, approverUserId: usersList.data?.[0]?.id ?? '' }] }
: x,
))
}
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
>
+ Thêm cấp
</button>
</div>
{s.levels.map((l, li) => (
<div key={li} className="flex items-center gap-1.5">
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
C{li + 1}
</span>
<Input
value={l.name}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, name: e.target.value } : y)) } : x,
))
}
placeholder={`Cấp ${li + 1}`}
className="h-7 max-w-[120px] text-xs"
/>
<Select
value={l.approverUserId}
onChange={e =>
setSteps(steps.map((x, i) =>
i === idx
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
: x,
))
}
className="h-7 flex-1 text-xs"
>
<option value=""> Chọn NV duyệt </option>
{usersList.data?.map(u => (
<option key={u.id} value={u.id}>
{u.fullName} ({u.email})
</option>
))}
</Select>
<button
type="button"
onClick={() => moveLevel(idx, li, -1)}
disabled={li === 0}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
title="Lên"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => moveLevel(idx, li, 1)}
disabled={li === s.levels.length - 1}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
title="Xuống"
>
<ChevronDown className="h-3 w-3" />
</button>
<button
type="button"
onClick={() =>
setSteps(steps.map((x, i) =>
i === idx ? { ...x, levels: x.levels.filter((_, j) => j !== li) } : x,
))
}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
title="Xóa cấp"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
{s.levels.length === 0 && (
<div className="rounded border border-dashed border-slate-300 px-2 py-1.5 text-[11px] italic text-slate-400">
Chưa cấp. Bấm "+ Thêm cấp" đ chỉ đnh NV duyệt.
</div>
)}
</div>
</div>
))}
</div>
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
Quy tắc duyệt: tuần tự trong cùng Bước (Cấp 1 Cấp 2 ...), hết Cấp thì sang Bước kế.
Mỗi Cấp = 1 nhân viên cụ thể (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt.
</div>
</div>
</form>
</Dialog>
)
}

View File

@ -0,0 +1,38 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.ApprovalWorkflowsV2;
namespace SolutionErp.Api.Controllers;
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
// Schema riêng để UAT, KHÔNG đụng WorkflowDefinition cũ.
// Reuse policy "Workflows.Read"/"Workflows.Create" giống PE/Contract designer
// — admin đã có quyền quản lý workflow nói chung.
[ApiController]
[Route("api/approval-workflows-v2")]
[Authorize(Policy = "Workflows.Read")]
public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<AwAdminOverviewDto>> Overview(
[FromQuery] int? applicableType,
CancellationToken ct)
=> Ok(await mediator.Send(new GetAwAdminOverviewQuery(applicableType), ct));
[HttpPost]
[Authorize(Policy = "Workflows.Create")]
public async Task<ActionResult<object>> Create([FromBody] CreateAwDefinitionCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return Ok(new { id });
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "Workflows.Create")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteAwDefinitionCommand(id), ct);
return NoContent();
}
}

View File

@ -0,0 +1,279 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.ApprovalWorkflowsV2;
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
// Schema riêng để UAT trước khi drop legacy WorkflowDefinition cũ.
//
// Cấu trúc:
// Quy trình (Code + Name + ApplicableType)
// Bước 1 - Phòng A
// Cấp 1 - NV X (ApproverUserId 1 user cụ thể)
// Cấp 2 - NV Y
//
// Khác Mig 21: Levels match 1 NV CHÍNH XÁC qua ApproverUserId, không match
// group Dept+PositionLevel/Role/User.
public record AwLevelDto(
Guid Id,
int Order,
string? Name,
Guid ApproverUserId,
string? ApproverUserName,
string? ApproverEmail);
public record AwStepDto(
Guid Id,
int Order,
string Name,
Guid? DepartmentId,
string? DepartmentName,
List<AwLevelDto> Levels);
public record AwDefinitionDto(
Guid Id,
string Code,
int Version,
int ApplicableType,
string ApplicableTypeLabel,
string Name,
string? Description,
bool IsActive,
DateTime? ActivatedAt,
DateTime CreatedAt,
List<AwStepDto> Steps);
public record AwTypeSummaryDto(
int ApplicableType,
string ApplicableTypeLabel,
AwDefinitionDto? Active,
List<AwDefinitionDto> History);
public record AwAdminOverviewDto(List<AwTypeSummaryDto> Types);
internal static class AwLabels
{
public static readonly Dictionary<ApprovalWorkflowApplicableType, string> Type = new()
{
[ApprovalWorkflowApplicableType.DuyetNcc] = "Duyệt NCC",
[ApprovalWorkflowApplicableType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
[ApprovalWorkflowApplicableType.Contract] = "Hợp đồng",
};
}
// ========== GET overview ==========
// Filter `applicableType=null` → return tất cả, `=N` → chỉ type đó.
public record GetAwAdminOverviewQuery(int? ApplicableType = null) : IRequest<AwAdminOverviewDto>;
public class GetAwAdminOverviewQueryHandler(
IApplicationDbContext db,
UserManager<User> userManager) : IRequestHandler<GetAwAdminOverviewQuery, AwAdminOverviewDto>
{
public async Task<AwAdminOverviewDto> Handle(GetAwAdminOverviewQuery request, CancellationToken ct)
{
var query = db.ApprovalWorkflows.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.OrderByDescending(d => d.Version)
.AsQueryable();
if (request.ApplicableType is int t)
{
var typeEnum = (ApprovalWorkflowApplicableType)t;
query = query.Where(d => d.ApplicableType == typeEnum);
}
var definitions = await query.ToListAsync(ct);
// Resolve dept names
var deptIds = definitions
.SelectMany(d => d.Steps)
.Where(s => s.DepartmentId != null)
.Select(s => s.DepartmentId!.Value)
.Distinct().ToList();
var deptNames = deptIds.Count == 0
? new Dictionary<Guid, string>()
: await db.Departments.AsNoTracking()
.Where(d => deptIds.Contains(d.Id))
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
// Resolve user names
var userIds = definitions
.SelectMany(d => d.Steps)
.SelectMany(s => s.Levels)
.Select(l => l.ApproverUserId)
.Distinct().ToList();
var users = userIds.Count == 0
? new Dictionary<Guid, (string FullName, string? Email)>()
: await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.FullName, u.Email })
.ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);
AwDefinitionDto ToDto(ApprovalWorkflow d) => new(
d.Id,
d.Code,
d.Version,
(int)d.ApplicableType,
AwLabels.Type.GetValueOrDefault(d.ApplicableType, d.ApplicableType.ToString()),
d.Name,
d.Description,
d.IsActive,
d.ActivatedAt,
d.CreatedAt,
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
s.Id,
s.Order,
s.Name,
s.DepartmentId,
s.DepartmentId != null ? deptNames.GetValueOrDefault(s.DepartmentId.Value) : null,
s.Levels.OrderBy(l => l.Order).Select(l =>
{
users.TryGetValue(l.ApproverUserId, out var info);
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email);
}).ToList()
)).ToList());
var typesToReturn = request.ApplicableType is int onlyT
? new[] { (ApprovalWorkflowApplicableType)onlyT }
: Enum.GetValues<ApprovalWorkflowApplicableType>();
var result = typesToReturn
.Select(type =>
{
var versions = definitions.Where(d => d.ApplicableType == type).Select(ToDto).ToList();
return new AwTypeSummaryDto(
(int)type,
AwLabels.Type.GetValueOrDefault(type, type.ToString()),
versions.FirstOrDefault(v => v.IsActive),
versions);
})
.ToList();
return new AwAdminOverviewDto(result);
}
}
// ========== POST new version ==========
public record CreateAwLevelInput(int Order, string? Name, Guid ApproverUserId);
public record CreateAwStepInput(
int Order,
string Name,
Guid? DepartmentId,
List<CreateAwLevelInput> Levels);
public record CreateAwDefinitionCommand(
int ApplicableType,
string Code,
string Name,
string? Description,
List<CreateAwStepInput> Steps) : IRequest<Guid>;
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
{
public CreateAwDefinitionCommandValidator()
{
RuleFor(x => x.ApplicableType).Must(t => Enum.IsDefined(typeof(ApprovalWorkflowApplicableType), t))
.WithMessage("ApplicableType không hợp lệ.");
RuleFor(x => x.Code).NotEmpty().MaximumLength(100)
.Matches("^[A-Za-z0-9._-]+$")
.WithMessage("Code chỉ dùng chữ, số, và các ký tự . _ -");
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Steps).NotEmpty()
.WithMessage("Quy trình phải có ít nhất 1 bước.");
RuleForEach(x => x.Steps).ChildRules(step =>
{
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
step.RuleFor(s => s.Levels).NotEmpty()
.WithMessage("Mỗi bước phải có ít nhất 1 cấp duyệt.");
step.RuleForEach(s => s.Levels).ChildRules(level =>
{
level.RuleFor(l => l.Order).GreaterThanOrEqualTo(1);
level.RuleFor(l => l.Name).MaximumLength(200);
level.RuleFor(l => l.ApproverUserId).NotEmpty()
.WithMessage("Cấp duyệt phải chỉ định 1 nhân viên cụ thể.");
});
});
}
}
public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
: IRequestHandler<CreateAwDefinitionCommand, Guid>
{
public async Task<Guid> Handle(CreateAwDefinitionCommand request, CancellationToken ct)
{
var typeEnum = (ApprovalWorkflowApplicableType)request.ApplicableType;
// Auto-increment version theo Code (cùng Code = cùng "logical" workflow)
var nextVersion = await db.ApprovalWorkflows
.Where(w => w.Code == request.Code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
// Deactivate active version cho ApplicableType này (only ONE active per type)
var actives = await db.ApprovalWorkflows
.Where(w => w.ApplicableType == typeEnum && w.IsActive)
.ToListAsync(ct);
foreach (var old in actives) old.IsActive = false;
var def = new ApprovalWorkflow
{
Code = request.Code,
Version = nextVersion,
ApplicableType = typeEnum,
Name = request.Name,
Description = request.Description,
IsActive = true,
ActivatedAt = DateTime.UtcNow,
Steps = request.Steps.OrderBy(s => s.Order)
.Select(s => new ApprovalWorkflowStep
{
Order = s.Order,
Name = s.Name,
DepartmentId = s.DepartmentId,
Levels = s.Levels.OrderBy(l => l.Order)
.Select(l => new ApprovalWorkflowLevel
{
Order = l.Order,
Name = l.Name,
ApproverUserId = l.ApproverUserId,
}).ToList(),
})
.ToList(),
};
db.ApprovalWorkflows.Add(def);
await db.SaveChangesAsync(ct);
return def.Id;
}
}
// ========== DELETE version (chỉ khi chưa có phiếu pin) ==========
// Hiện chưa có phiếu nào pin schema mới → unconditional delete OK cho UAT.
// Sau UAT khi link với PE/Contract thật cần check usage trước khi delete.
public record DeleteAwDefinitionCommand(Guid Id) : IRequest;
public class DeleteAwDefinitionCommandHandler(IApplicationDbContext db)
: IRequestHandler<DeleteAwDefinitionCommand>
{
public async Task Handle(DeleteAwDefinitionCommand request, CancellationToken ct)
{
var def = await db.ApprovalWorkflows
.FirstOrDefaultAsync(d => d.Id == request.Id, ct)
?? throw new KeyNotFoundException($"ApprovalWorkflow {request.Id} không tồn tại.");
db.ApprovalWorkflows.Remove(def);
await db.SaveChangesAsync(ct);
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Budgets; using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details; using SolutionErp.Domain.Contracts.Details;
@ -64,6 +65,12 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; } DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; } DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
// Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi
// drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
DbSet<ApprovalWorkflow> ApprovalWorkflows { get; }
DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps { get; }
DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels { get; }
// Module Ngân sách (Phase 7) // Module Ngân sách (Phase 7)
DbSet<Budget> Budgets { get; } DbSet<Budget> Budgets { get; }
DbSet<BudgetDetail> BudgetDetails { get; } DbSet<BudgetDetail> BudgetDetails { get; }

View File

@ -0,0 +1,63 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.ApprovalWorkflowsV2;
// Quy trình duyệt mới (Mig 22 — Session 17, 2026-05-08).
// Schema riêng để UAT, KHÔNG đụng WorkflowDefinition cũ (Mig 21 flat). Sau UAT
// OK → migrate data PE/HĐ pin sang ApprovalWorkflowId + drop bảng cũ.
//
// Cấu trúc:
// Quy trình (Code + Name + ApplicableType)
// Bước 1 - Phòng A (DepartmentId optional hint)
// Cấp 1 - NV X (ApproverUserId specific)
// Cấp 2 - NV Y
// Bước 2 - Phòng B
// Cấp 1 - NV Z
// ...
//
// Service (sau khi UAT chốt): iterate Steps OrderBy Order. Mỗi step iterate
// Levels OrderBy Order. Mỗi level = 1 NV cụ thể duyệt. Hết level → next step.
// Hết step → DaDuyet.
public class ApprovalWorkflow : BaseEntity
{
public string Code { get; set; } = string.Empty; // Mã quy trình "QT-DN-V2-001"
public int Version { get; set; } // monotonically increases per Code
public ApprovalWorkflowApplicableType ApplicableType { get; set; } // module áp dụng
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
public List<ApprovalWorkflowStep> Steps { get; set; } = new();
}
public enum ApprovalWorkflowApplicableType
{
DuyetNcc = 1, // PE module — Duyệt NCC (default test target)
DuyetNccPhuongAn = 2, // PE — Duyệt NCC + Giải pháp
Contract = 3, // HĐ general (any ContractType)
}
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.
public class ApprovalWorkflowStep : BaseEntity
{
public Guid ApprovalWorkflowId { get; set; }
public int Order { get; set; } // 1-based
public string Name { get; set; } = string.Empty; // "Phòng A", "Phòng B" — display
public Guid? DepartmentId { get; set; } // hint phòng (optional, không strict match)
public ApprovalWorkflow? ApprovalWorkflow { get; set; }
public List<ApprovalWorkflowLevel> Levels { get; set; } = new();
}
// Cấp = 1 NV cụ thể. 1 bước có nhiều cấp theo Order. Approver = ApproverUserId
// chính xác (KHÔNG OR-of-many). Sequential trong cùng bước: cấp 1 → cấp 2 → ...
public class ApprovalWorkflowLevel : BaseEntity
{
public Guid ApprovalWorkflowStepId { get; set; }
public int Order { get; set; } // 1-based trong cùng step
public string? Name { get; set; } // "Cấp 1" — display optional
public Guid ApproverUserId { get; set; } // 1 NV cụ thể duyệt cấp này
public ApprovalWorkflowStep? Step { get; set; }
}

View File

@ -51,6 +51,20 @@ public static class MenuKeys
public const string PurchaseEvaluations = "PurchaseEvaluations"; // root group public const string PurchaseEvaluations = "PurchaseEvaluations"; // root group
public const string PeWorkflows = "PeWorkflows"; // workflow admin root public const string PeWorkflows = "PeWorkflows"; // workflow admin root
// ============================================================
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08):
// Schema riêng `ApprovalWorkflow` để UAT trước khi migrate hoàn toàn.
// Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
// Mã + Tên Quy trình
// Bước 1 - Phòng A
// Cấp 1 - NV X
// Cấp 2 - NV Y
// Bước 2 - Phòng B
// ...
// ============================================================
public const string ApprovalWorkflowsV2 = "ApprovalWorkflowsV2"; // root admin (mới)
public const string ApprovalWorkflowDuyetNccV2 = "AwV2_DuyetNcc"; // leaf cho Duyệt NCC mới
// ============================================================ // ============================================================
// Module Ngân sách (Phase 7) — 4 bảng quản lý ngân sách dự án/gói thầu. // Module Ngân sách (Phase 7) — 4 bảng quản lý ngân sách dự án/gói thầu.
// 1 root + 3 leaf action (Danh sách / Thao tác / Duyệt). // 1 root + 3 leaf action (Danh sách / Thao tác / Duyệt).
@ -81,6 +95,7 @@ public static class MenuKeys
PurchaseEvaluations, PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending, Budgets, BudgetList, BudgetCreate, BudgetPending,
System, Users, Roles, Permissions, Workflows, PeWorkflows, System, Users, Roles, Permissions, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, // Mig 22
]; ];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"]; public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Budgets; using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details; using SolutionErp.Domain.Contracts.Details;
@ -63,6 +64,11 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>(); public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>(); public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
// Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT.
public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>();
public DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps => Set<ApprovalWorkflowStep>();
public DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels => Set<ApprovalWorkflowLevel>();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs. // Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet<Budget> Budgets => Set<Budget>(); public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<BudgetDetail> BudgetDetails => Set<BudgetDetail>(); public DbSet<BudgetDetail> BudgetDetails => Set<BudgetDetail>();

View File

@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.ApprovalWorkflowsV2;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF config Mig 22 — schema mới riêng cho Quy trình duyệt v2 (UAT before drop legacy).
public class ApprovalWorkflowConfiguration : IEntityTypeConfiguration<ApprovalWorkflow>
{
public void Configure(EntityTypeBuilder<ApprovalWorkflow> e)
{
e.ToTable("ApprovalWorkflows");
e.Property(x => x.Code).HasMaxLength(100).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(1000);
e.Property(x => x.ApplicableType).HasConversion<int>();
e.HasIndex(x => new { x.Code, x.Version }).IsUnique();
e.HasIndex(x => new { x.ApplicableType, x.IsActive });
}
}
public class ApprovalWorkflowStepConfiguration : IEntityTypeConfiguration<ApprovalWorkflowStep>
{
public void Configure(EntityTypeBuilder<ApprovalWorkflowStep> e)
{
e.ToTable("ApprovalWorkflowSteps");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.HasOne(x => x.ApprovalWorkflow)
.WithMany(d => d.Steps)
.HasForeignKey(x => x.ApprovalWorkflowId)
.OnDelete(DeleteBehavior.Cascade);
// Department FK Restrict (optional hint).
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ApprovalWorkflowId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}
public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<ApprovalWorkflowLevel>
{
public void Configure(EntityTypeBuilder<ApprovalWorkflowLevel> e)
{
e.ToTable("ApprovalWorkflowLevels");
e.Property(x => x.Name).HasMaxLength(200);
e.HasOne(x => x.Step)
.WithMany(s => s.Levels)
.HasForeignKey(x => x.ApprovalWorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
// ApproverUserId FK Restrict — không cấu hình nav để giữ nhẹ
// (1 chiều, query qua join nếu cần admin xem detail).
e.HasOne<SolutionErp.Domain.Identity.User>()
.WithMany()
.HasForeignKey(x => x.ApproverUserId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ApprovalWorkflowStepId, x.Order });
e.HasIndex(x => x.ApproverUserId);
}
}

View File

@ -1308,6 +1308,10 @@ public static class DbInitializer
// Module Duyệt NCC (tiền-HĐ) // Module Duyệt NCC (tiền-HĐ)
(MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"), (MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"),
(MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"), (MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"),
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08): schema riêng
// UAT trước khi migrate hoàn toàn. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
(MenuKeys.ApprovalWorkflowsV2, "Quy trình duyệt (Mới)", MenuKeys.System, 96, "Workflow"),
(MenuKeys.ApprovalWorkflowDuyetNccV2, "Duyệt NCC (Mới)", MenuKeys.ApprovalWorkflowsV2, 1, "FileCheck"),
// Module Ngân sách (Phase 7) // Module Ngân sách (Phase 7)
(MenuKeys.Budgets, "Ngân sách", null, 27, "Wallet"), (MenuKeys.Budgets, "Ngân sách", null, 27, "Wallet"),
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"), (MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),

View File

@ -0,0 +1,143 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddApprovalWorkflowsV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ApprovalWorkflows",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Version = table.Column<int>(type: "int", nullable: false),
ApplicableType = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalWorkflows", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ApprovalWorkflowSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalWorkflowSteps", x => x.Id);
table.ForeignKey(
name: "FK_ApprovalWorkflowSteps_ApprovalWorkflows_ApprovalWorkflowId",
column: x => x.ApprovalWorkflowId,
principalTable: "ApprovalWorkflows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ApprovalWorkflowSteps_Departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ApprovalWorkflowLevels",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ApprovalWorkflowLevels", x => x.Id);
table.ForeignKey(
name: "FK_ApprovalWorkflowLevels_ApprovalWorkflowSteps_ApprovalWorkflowStepId",
column: x => x.ApprovalWorkflowStepId,
principalTable: "ApprovalWorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ApprovalWorkflowLevels_Users_ApproverUserId",
column: x => x.ApproverUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowLevels_ApprovalWorkflowStepId_Order",
table: "ApprovalWorkflowLevels",
columns: new[] { "ApprovalWorkflowStepId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowLevels_ApproverUserId",
table: "ApprovalWorkflowLevels",
column: "ApproverUserId");
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflows_ApplicableType_IsActive",
table: "ApprovalWorkflows",
columns: new[] { "ApplicableType", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflows_Code_Version",
table: "ApprovalWorkflows",
columns: new[] { "Code", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowSteps_ApprovalWorkflowId_Order",
table: "ApprovalWorkflowSteps",
columns: new[] { "ApprovalWorkflowId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_ApprovalWorkflowSteps_DepartmentId",
table: "ApprovalWorkflowSteps",
column: "DepartmentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApprovalWorkflowLevels");
migrationBuilder.DropTable(
name: "ApprovalWorkflowSteps");
migrationBuilder.DropTable(
name: "ApprovalWorkflows");
}
}
}

View File

@ -125,6 +125,141 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", (string)null); b.ToTable("UserTokens", (string)null);
}); });
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<int>("ApplicableType")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Version")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ApplicableType", "IsActive");
b.HasIndex("Code", "Version")
.IsUnique();
b.ToTable("ApprovalWorkflows", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowStepId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApproverUserId");
b.HasIndex("ApprovalWorkflowStepId", "Order");
b.ToTable("ApprovalWorkflowLevels", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("ApprovalWorkflowId", "Order");
b.ToTable("ApprovalWorkflowSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b => modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -3154,6 +3289,39 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", "Step")
.WithMany("Levels")
.HasForeignKey("ApprovalWorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("ApproverUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", "ApprovalWorkflow")
.WithMany("Steps")
.HasForeignKey("ApprovalWorkflowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ApprovalWorkflow");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b => modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
{ {
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget") b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
@ -3521,6 +3689,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Step"); b.Navigation("Step");
}); });
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b =>
{
b.Navigation("Levels");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b => modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{ {
b.Navigation("Approvals"); b.Navigation("Approvals");