[CLAUDE] Workflow: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Phase 11 P11-A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s
Wire full approval workflow V2 cho Leave/OT/Travel/Vehicle — cookie-cutter
mirror Proposal (Mig 38). Trước đây skeleton Phase 1 (Create+List), giờ
ApproveV2 advance-level + UPSERT LevelOpinion + atomic codegen.
Schema (Mig 41 WireWorkflowAppsApprovalV2 — 84→89 tables, pure additive):
- 4 bảng {Leave,Ot,Travel,Vehicle}LevelOpinions (UNIQUE composite + Cascade
parent + Restrict Level — mirror ProposalLevelOpinion)
- 1 bảng WorkflowAppCodeSequences (shared atomic MaDonTu, Prefix-keyed)
- 4 cột RejectedFromStatus (smart return tracking)
- enum ApprovalWorkflowApplicableType.TravelRequest = 9
Application (LeaveOt + TravelVehicle ApprovalFeatures.cs — 30 handler):
- GetById detail (Include LevelOpinions + JOIN Step/Level) · UpdateDraft
- Submit (gen MaDonTu + DaGuiDuyet + level=1, verify ApplicableType per module)
- Approve (verify actor==ApproverUserId OR Admin, UPSERT opinion latest-write-wins,
advance level OR terminal DaDuyet, empty comment → placeholder)
- Reject (→TuChoi) · Return (→TraLai + RejectedFromStatus)
Api: 4 controller +6 route mỗi cái (GET/{id}, PUT/{id}, submit/approve/reject/return)
Infra: DbInitializer seed 4 workflow V2 mẫu (QT-NP/OT/CT/XE-V2-001) → UAT test ngay
FE: WorkflowAppDetailPage.tsx declarative 4-kind (fe-admin+fe-user SHA256 identical)
— workflow status + opinion timeline + action buttons; gỡ banner skeleton + row nav
Tests: +11 WorkflowAppApproveV2Tests (130→141 PASS) — state machine + UPSERT
invariant + guards + codegen + forbidden + placeholder (Leave full + Ot smoke)
Verify: build 0 error · 141 test PASS · FE build ×2 · reviewer checklist
(ApplicableType per-module + cross-module DbSet + [Authorize] — no copy-paste bug)
Known-minor (unreachable): Reject/Return actor-check skip nếu CurrentApprovalLevelOrder
null — nhưng DaGuiDuyet luôn có set (defer hardening).
ItTicket KHÔNG đụng (kanban, no workflow V2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -66,7 +66,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
|
|||||||
|
|
||||||
## 🧠 SOLUTION_ERP BE conventions (S40)
|
## 🧠 SOLUTION_ERP BE conventions (S40)
|
||||||
- **BE .NET 10:** PascalCase entities + DTO records + command names. CQRS+MediatR+FluentValidation+AutoMapper. Repository qua `IApplicationDbContext`. `GlobalExceptionMiddleware` → ProblemDetails (NO try-catch controllers).
|
- **BE .NET 10:** PascalCase entities + DTO records + command names. CQRS+MediatR+FluentValidation+AutoMapper. Repository qua `IApplicationDbContext`. `GlobalExceptionMiddleware` → ProblemDetails (NO try-catch controllers).
|
||||||
- **State S40:** 40 mig (last `AddAttendances`) · 84 SQL tables · ~211 endpoints · 130 test baseline (test-specialist owns). Phase 9 UAT skip per chunk (`feedback_uat_skip_verify`).
|
- **State S41:** 41 mig (last `WireWorkflowAppsApprovalV2`) · 89 SQL tables · ~211 endpoints · 130 test baseline (test-specialist owns). Phase 9 UAT skip per chunk (`feedback_uat_skip_verify`).
|
||||||
- **Build:** `dotnet build SolutionErp.slnx` clean 0 err. Commit `[CLAUDE] <scope>: <msg>` + Co-Authored-By Claude Opus 4.8 (1M context).
|
- **Build:** `dotnet build SolutionErp.slnx` clean 0 err. Commit `[CLAUDE] <scope>: <msg>` + Co-Authored-By Claude Opus 4.8 (1M context).
|
||||||
- **Pin (KHÔNG `*`/latest):** MediatR `12.4.1` (14 fail DI) · Swashbuckle `6.9.0` · Node CI `20.x` · LibreOffice `25.8.6` · @microsoft/signalr `8.0.7`.
|
- **Pin (KHÔNG `*`/latest):** MediatR `12.4.1` (14 fail DI) · Swashbuckle `6.9.0` · Node CI `20.x` · LibreOffice `25.8.6` · @microsoft/signalr `8.0.7`.
|
||||||
|
|
||||||
@ -74,6 +74,10 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
|
|||||||
|
|
||||||
## 📅 Recent activity (FIFO — older → archive/git)
|
## 📅 Recent activity (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **S42 P11-A SEED — 4 sample ApprovalWorkflow V2 for WorkflowApps (DbInitializer.cs only, +4 method ~210 LOC + 4 call):** Case 1 mechanical mirror `SeedSampleProposalWorkflowV2Async` EXACT × 4 (Leave5/Ot6/Travel9/Vehicle7). Each: idempotent `AnyAsync(ApplicableType==X)` guard → resolve approver `binh.le@solutions.com.vn` (SAME user as Proposal/Contract seed, null→LogWarning+return) → 1 ApprovalWorkflow (Version=1, IsActive+IsUserSelectable=true, ActivatedAt=UtcNow) + 1 Step (Order=1, Name="Cấp duyệt", DepartmentId=CCM?.Id) + 1 Level (Order=1, ApproverUserId). Codes QT-NP/OT/CT/XE-V2-001. Wired 4 calls after SeedSampleProposalWorkflowV2Async (NOT gated DemoSeed, gotcha #51 infra seed). Enum verified Grep. Build 0 err 0 warn. Bash tool = bash NOT PowerShell despite env hint (use `cd && cmd | grep`). Spec deterministic 100% → ACCEPT Case 1. NOT touched App/Controller/FE/test/Mig. Tag `[s42, p11-a, seed, mirror-proposal-exact]`.
|
||||||
|
- **S42 P11-A Wave 2b APP — wire ApproveV2 CQRS Travel+Vehicle (`TravelVehicleApprovalFeatures.cs` ~830 LOC + 2 controller edit):** Cookie-cutter mirror Wave 2a / ProposalFeatures Region 2. 1 new file ns `Application.Office`: 2 module × (DetailDto + LevelOpinionDto + GetById JOIN Step/Level metadata + UpdateDraft + Submit + Approve UPSERT+advance + Reject TuChoi + Return TraLai+RejectedFromStatus) + 1 shared `internal static TravelVehicleCodeGen.GenerateMaDonTuAsync` (Serializable tx, `WorkflowAppCodeSequences` Prefix-keyed, prefix `DT/CT/{year}` Travel & `DX/XE/{year}` Vehicle, format `{prefix}/{seq:D3}` — D3 no year segment per spec). KEY gotcha: WorkflowAppStatus enum DIFFERS from ProposalStatus int values (DaGuiDuyet=2 not 1, TraLai=3) → mirror by SEMANTIC enum member not literal. Owner = `RequesterUserId` (not DrafterUserId). Submit verify wf.ApplicableType==Travel9/Vehicle7 else Conflict. 2 controller +6 route each (GET{id}/PUT/submit/approve/reject/return) nested body records, CreatedAtAction. KHÔNG sửa WorkflowAppsFeatures.cs/Leave/Ot/FE/test/seed. Build 0 err. Spec deterministic ~98% em main → ACCEPT Case 1/2. Tag `[s42, p11-a, wave-2b, mirror-proposal-region2]`.
|
||||||
|
- **S42 P11-A Wave 2a APP — wire ApproveV2 CQRS Leave+Ot (`LeaveOtApprovalFeatures.cs` ~770 LOC + 2 controller edit):** Pattern 4 (UPSERT in Approve, 0 opinion endpoint) + cookie-cutter mirror ProposalFeatures Region 2. 1 new file ns `Application.Office`: 2 module × (DetailDto + LevelOpinionDto + GetById JOIN Step/Level metadata + UpdateDraft + Submit + Approve UPSERT+advance + Reject TuChoi + Return TraLai+RejectedFromStatus) + 1 shared `internal static WorkflowAppCodeGen.GenerateMaDonTuAsync` (Serializable tx + `WorkflowAppCodeSequences` Prefix-keyed, prefix DT/LR & DT/OT, format `{prefix}/{year}/{seq:D3}`). Approve: flatten allLevels OrderBy Step→Level, currentSlot=allLevels[order-1], actor==ApproverUserId OR Admin, comment empty→placeholder, advance OR terminal DaDuyet. Submit verify wf.ApplicableType==Leave5/Ot6 else Conflict + gen MaDonTu nếu null. 2 controller +6 route each (GET{id}/PUT/submit/approve/reject/return) mirror ProposalsController nested body records (`WorkflowActionBody`). KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). Build 0 err (2 warn DocxRenderer pre-existing). Spec deterministic 100% em main → ACCEPT Case 1. Travel/Vehicle (Wave 2b) + test (Wave 4) deferred. Tag `[s42, p11-a, wave-2a, mirror-proposal-region2]`.
|
||||||
|
- **S41 P11-A Wave 1 SCHEMA — wire ApproveV2+LevelOpinions 4 WorkflowApps (Mig 41 `WireWorkflowAppsApprovalV2`):** Pattern 12-bis cookie-cutter mirror Proposal Mig 38, 13× cumulative. 11 file: 5 entity (4 `{Leave,Ot,Travel,Vehicle}RequestLevelOpinion` + shared `WorkflowAppCodeSequence` Prefix-PK) + 5 EF config (auto-discover ApplyConfigurationsFromAssembly, no manual register) + edit 4 parent (nav LevelOpinions + `WorkflowAppStatus? RejectedFromStatus`) + edit enum (`TravelRequest=9`) + 2 DbSet (IAppDbContext+ApplicationDbContext, 5 each). Mig diff CLEAN: 5 CreateTable + 4 AddColumn (no drift). FK Cascade parent + Restrict Level + UNIQUE composite ({Parent}Id, ApprovalWorkflowLevelId). Applied BOTH DB (Dev + Design). Build 0 err. Wave 2 (App/Controller) + Wave 4 (test) deferred per spec. Spec deterministic 100% (em main chose schema) → ACCEPT Case 1. Tag `[s41, p11-a, 12-bis-13x, mig41]`.
|
||||||
- **S35 G-H2 BE CRUD 4 catalog (HrmConfigFeatures.cs 372 LOC + Controller 134 LOC, 16 endpoint):** Pattern 12-bis 3rd application catalog-mega. 4 sub-resource × 4 verb. KEY: HRM no HasQueryFilter → `.Where(!IsDeleted)` manual; Validator MaxLength = EF source-of-truth (Code=50 not spec 20). 130 test baseline preserve. ACCEPT clean spec 95%. Tag `[s35, be-crud, hrm, 12-bis-3x]`.
|
- **S35 G-H2 BE CRUD 4 catalog (HrmConfigFeatures.cs 372 LOC + Controller 134 LOC, 16 endpoint):** Pattern 12-bis 3rd application catalog-mega. 4 sub-resource × 4 verb. KEY: HRM no HasQueryFilter → `.Where(!IsDeleted)` manual; Validator MaxLength = EF source-of-truth (Code=50 not spec 20). 130 test baseline preserve. ACCEPT clean spec 95%. Tag `[s35, be-crud, hrm, 12-bis-3x]`.
|
||||||
- **S29 Plan B Chunk C Contract V2 mirror (Mig 33 ContractLevelOpinions):** Pattern 12-bis 1st — 8 file +4265 LOC (Designer autogen 95%, handcraft ~232 LOC). Em main spec deterministic 100% → ACCEPT. Tag `[s29, plan-b, 12-bis]`.
|
- **S29 Plan B Chunk C Contract V2 mirror (Mig 33 ContractLevelOpinions):** Pattern 12-bis 1st — 8 file +4265 LOC (Designer autogen 95%, handcraft ~232 LOC). Em main spec deterministic 100% → ACCEPT. Tag `[s29, plan-b, 12-bis]`.
|
||||||
- **Archived FE/test + older BE entries → `archive/2026-05-q4.md` + git d2f52ba (S40 curate):** S35 FE inline forms 5 satellite (→ frontend domain) · S34 test bundle +10 [Fact] 130 PASS (→ test-specialist domain) · S33 Task 5 EmployeesListPage · S32 wrap/startup. KEY absorbed in Patterns above + split pointers.
|
- **Archived FE/test + older BE entries → `archive/2026-05-q4.md` + git d2f52ba (S40 curate):** S35 FE inline forms 5 satellite (→ frontend domain) · S34 test bundle +10 [Fact] 130 PASS (→ test-specialist domain) · S33 Task 5 EmployeesListPage · S32 wrap/startup. KEY absorbed in Patterns above + split pointers.
|
||||||
|
|||||||
@ -70,6 +70,7 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
|
|||||||
|
|
||||||
## 📅 Recent activity (FIFO — older → archive/git)
|
## 📅 Recent activity (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **2026-05-30 (P11-A WorkflowApps wire pre-flight):** 4 module Leave/OT/Travel/Vehicle. Schema pin ĐÃ CÓ SẴN (Mig 39): `Office/{Module}.cs` đều có `ApprovalWorkflowId?`+`CurrentApprovalLevelOrder?`+`WorkflowAppStatus` (5-state khớp ProposalStatus). SKELETON tại `Application/Office/WorkflowAppsFeatures.cs:11-15` (chỉ Create+List, KHÔNG Approve/Reject/Return/GetById). **Proposal = mirror HOÀN HẢO** (cùng Office ns, Mig 38): `ProposalFeatures.cs:403-486` ApproveHandler = flatten `Steps.OrderBy(Order).SelectMany(Levels.OrderBy(Order))` global level index → `allLevels[CurrentApprovalLevelOrder-1]` → actor match `Level.ApproverUserId==uid||Admin` → UPSERT LevelOpinion → advance++/DaDuyet. ⚠️ `ApprovalWorkflow.cs:72` nói Level **KHÔNG OR-of-N** (1 ApproverUserId/Level) — KHÁC memory cũ "OR-of-N", verify lại. Gap: 4 bảng `{Module}LevelOpinion` mới (Mig 41+), 3 route/controller, 4 seed WF, FE Detail+Opinion (chỉ có WorkflowAppsListPage chung). ⚠️ enum `ApplicableType` THIẾU Travel (có Leave=5/OT=6/Vehicle=7/ItTicket=8); `ExtendApplicableTypeForWorkflowApps` mig empty Up/Down (enum-only). Tag `[pre-flight, p11-a, workflowapps]`.
|
||||||
- **2026-05-29 (S40 STATE GROUNDING):** 7 metric verify. ✅ Migrations=**40** (path `.../Persistence/Migrations/*.cs`, last `AddAttendances`). ✅ Gotchas=**55** (`### N.`). ✅ git clean. DbSet=77 nhưng **SQL tables=84** (em main verify `.ToTable()` ModelSnapshot — 77+7 Identity, "84 docs ĐÚNG", DbSet count sai −7). Endpoints=**211** (docs ~223). FE pages fe-admin **36**+fe-user 29=**65** (docs 53 under-count). Menu keys=**53** const (docs 85 over-count). 3 số tin cậy nhất = mig/gotcha/git. Lesson: tables phải count ToTable KHÔNG DbSet. Tag `[state-grounding, docs-drift, s40]`.
|
- **2026-05-29 (S40 STATE GROUNDING):** 7 metric verify. ✅ Migrations=**40** (path `.../Persistence/Migrations/*.cs`, last `AddAttendances`). ✅ Gotchas=**55** (`### N.`). ✅ git clean. DbSet=77 nhưng **SQL tables=84** (em main verify `.ToTable()` ModelSnapshot — 77+7 Identity, "84 docs ĐÚNG", DbSet count sai −7). Endpoints=**211** (docs ~223). FE pages fe-admin **36**+fe-user 29=**65** (docs 53 under-count). Menu keys=**53** const (docs 85 over-count). 3 số tin cậy nhất = mig/gotcha/git. Lesson: tables phải count ToTable KHÔNG DbSet. Tag `[state-grounding, docs-drift, s40]`.
|
||||||
- **2026-05-29 (S39 BVAAU 7-agent extract):** Đọc 8 file BVAAU `.claude/agents/` ~22K. Split 4→7 trục research(2)/implement(2)/quality(3). Boundary: repo interface=domain, EF config=infra, test=test-specialist. Tool: cả 7 agent 5 RAG MCP (+search_code BM25 +store_memory +list_projects). BVAAU Phase 0 codebase RỖNG → aspirational template chưa battle-test; SOLUTION_ERP giữ 6 skill + backend/frontend split (thay domain/infra cho 2-FE fit). VIPIX guide claim KHÔNG verify được (file miss). Tag `[cross-project, bvaau, port]`.
|
- **2026-05-29 (S39 BVAAU 7-agent extract):** Đọc 8 file BVAAU `.claude/agents/` ~22K. Split 4→7 trục research(2)/implement(2)/quality(3). Boundary: repo interface=domain, EF config=infra, test=test-specialist. Tool: cả 7 agent 5 RAG MCP (+search_code BM25 +store_memory +list_projects). BVAAU Phase 0 codebase RỖNG → aspirational template chưa battle-test; SOLUTION_ERP giữ 6 skill + backend/frontend split (thay domain/infra cho 2-FE fit). VIPIX guide claim KHÔNG verify được (file miss). Tag `[cross-project, bvaau, port]`.
|
||||||
- **2026-05-28 (S37 G-O3 Proposal pre-flight):** PE pattern mirror cho Mig 38. LevelOpinion UNIQUE per-LEVEL `(EntityId, LevelId)` FK Cascade+Restrict. CodeSequence 1 row/Prefix (`DX/YYYY` 3 col Prefix PK+LastSeq, tx SERIALIZABLE). ApproveV2Async 7 step (group Levels by Order=Cấp OR-of-N → match actor.Id∈ApproverUserId + Admin bypass → UPSERT LevelOpinion sync → F2 skipToFinal → advance levelOrder++ → DaDuyet). NamGroup TblRequest generic → **SOL clean-room MẠNH HƠN**. CategoryId nullable FK + free text fallback (lesson Plan C 8 FK ZERO populated). Tag `[pre-flight, g-o3, proposal]`.
|
- **2026-05-28 (S37 G-O3 Proposal pre-flight):** PE pattern mirror cho Mig 38. LevelOpinion UNIQUE per-LEVEL `(EntityId, LevelId)` FK Cascade+Restrict. CodeSequence 1 row/Prefix (`DX/YYYY` 3 col Prefix PK+LastSeq, tx SERIALIZABLE). ApproveV2Async 7 step (group Levels by Order=Cấp OR-of-N → match actor.Id∈ApproverUserId + Admin bypass → UPSERT LevelOpinion sync → F2 skipToFinal → advance levelOrder++ → DaDuyet). NamGroup TblRequest generic → **SOL clean-room MẠNH HƠN**. CategoryId nullable FK + free text fallback (lesson Plan C 8 FK ZERO populated). Tag `[pre-flight, g-o3, proposal]`.
|
||||||
|
|||||||
@ -15,7 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
|
|||||||
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
|
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
|
||||||
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
|
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
|
||||||
|
|
||||||
## 📊 Baseline 130 PASS (58 Domain + 72 Infra)
|
## 📊 Baseline 141 PASS (58 Domain + 83 Infra) ← S42 +11 WorkflowApp ApproveV2
|
||||||
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal`
|
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal`
|
||||||
|
|
||||||
## ⏱️ Timing rules (docs/rules.md §7)
|
## ⏱️ Timing rules (docs/rules.md §7)
|
||||||
@ -51,6 +51,7 @@ Test theo CODE (single source truth), document mismatch header comment + report.
|
|||||||
|
|
||||||
- **2026-05-29 (S39 agent split setup):** NEW dedicated agent. Seeded test patterns (10 reflection authz + 11 infra helper + 12 InternalsVisibleTo + #48 SQLite tie-break + spec drift S34). Inherited coverage gap backlog 4 priority items từ S36 Reviewer audit (130 PASS baseline). First spawn pending em main S39+ test bundle task (recommend Gap 1 Holiday composite UNIQUE first).
|
- **2026-05-29 (S39 agent split setup):** NEW dedicated agent. Seeded test patterns (10 reflection authz + 11 infra helper + 12 InternalsVisibleTo + #48 SQLite tie-break + spec drift S34). Inherited coverage gap backlog 4 priority items từ S36 Reviewer audit (130 PASS baseline). First spawn pending em main S39+ test bundle task (recommend Gap 1 Holiday composite UNIQUE first).
|
||||||
- **2026-05-29 (S40 baseline audit smoke):** CONFIRMED 130 PASS (Domain 58 + Infra 72), 0 fail/skip, ~15s. Runner count authoritative; raw `[Fact]/[Theory]` attr = 48+70 (Theory→InlineData expand). Infra spread 15 files. Gap re-verified vs prod: EmployeesController+HrmConfigsController EXIST, authz regression chỉ ApprovalWorkflowsV2Controller (gotcha #44 gap real). Proposal = Domain entity + EF config only, CHƯA có ApproveV2Async service (S37 skeleton, defer đúng). Agent load OK. AUDIT-only, no write.
|
- **2026-05-29 (S40 baseline audit smoke):** CONFIRMED 130 PASS (Domain 58 + Infra 72), 0 fail/skip, ~15s. Runner count authoritative; raw `[Fact]/[Theory]` attr = 48+70 (Theory→InlineData expand). Infra spread 15 files. Gap re-verified vs prod: EmployeesController+HrmConfigsController EXIST, authz regression chỉ ApprovalWorkflowsV2Controller (gotcha #44 gap real). Proposal = Domain entity + EF config only, CHƯA có ApproveV2Async service (S37 skeleton, defer đúng). Agent load OK. AUDIT-only, no write.
|
||||||
|
- **2026-05-30 (S42 P11-A Wave4):** +11 test `tests/.../Application/WorkflowAppApproveV2Tests.cs` → **141 PASS** (Infra 72→83). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, Reject→TuChoi, Return→TraLai+RejectedFromStatus) + OtRequest smoke (submit→approve single-level→DaDuyet). **No prod bug** — LeaveOt ApproveV2 wire correct, all PASS first run. **NEW Pattern:** WorkflowApps handlers = CQRS MediatR (KHÔNG service) → instantiate handler trực tiếp `new ApproveLeaveRequestHandler(db, AsUser(u), clock).Handle(cmd,ct)`, chỉ 3 dep (IApplicationDbContext + TestCurrentUser + FixedDateTime) — nhẹ hơn 6-dep Contract service. MaDonTu format "DT/LR/2026/001". Gap #4 (Workflow Apps) PARTIAL done — Travel/Vehicle mirror pending. ⚠️ Lesson: CWD drift (fe-user) → ghi MEMORY nhầm path, em main relocate. Verify CWD root trước Write memory.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
|
|||||||
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
||||||
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
|
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
|
||||||
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
||||||
|
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
|
||||||
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
||||||
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
||||||
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
||||||
@ -96,6 +97,7 @@ function App() {
|
|||||||
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
||||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
|
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
|
||||||
|
<Route path="/workflow-apps/:kind/:id" element={<WorkflowAppDetailPage />} />
|
||||||
<Route path="/it-tickets" element={<ItTicketsPage />} />
|
<Route path="/it-tickets" element={<ItTicketsPage />} />
|
||||||
<Route path="/attendance" element={<MyAttendancePage />} />
|
<Route path="/attendance" element={<MyAttendancePage />} />
|
||||||
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
|
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
|
||||||
|
|||||||
423
fe-admin/src/pages/office/WorkflowAppDetailPage.tsx
Normal file
423
fe-admin/src/pages/office/WorkflowAppDetailPage.tsx
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
// Generic Workflow App Detail page — Phase 11 P11-A Wave 3a (S42 2026-05-30).
|
||||||
|
// Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module
|
||||||
|
// leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline +
|
||||||
|
// Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc).
|
||||||
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, Plane, RotateCcw, Send,
|
||||||
|
} 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 { Label } from '@/components/ui/Label'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import {
|
||||||
|
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS,
|
||||||
|
WorkflowAppStatus, type WorkflowAppDetail,
|
||||||
|
} from '@/types/workflowApps'
|
||||||
|
|
||||||
|
type Kind = 'leave' | 'ot' | 'travel' | 'vehicle'
|
||||||
|
type ActionKind = 'approve' | 'reject' | 'return'
|
||||||
|
|
||||||
|
interface WorkflowOption { id: string; code: string; name: string }
|
||||||
|
|
||||||
|
function formatDate(iso?: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso?: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVnd(n: number | null | undefined): string {
|
||||||
|
if (n === null || n === undefined) return '—'
|
||||||
|
return n.toLocaleString('vi-VN') + ' đ'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
|
||||||
|
approve: { text: 'Duyệt', tone: 'bg-emerald-600 hover:bg-emerald-700' },
|
||||||
|
reject: { text: 'Từ chối', tone: 'bg-red-600 hover:bg-red-700' },
|
||||||
|
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_CONFIG: Record<Kind, {
|
||||||
|
title: string
|
||||||
|
endpoint: string
|
||||||
|
applicableType: number
|
||||||
|
icon: any
|
||||||
|
detailFields: Array<{ label: string; render: (x: WorkflowAppDetail) => React.ReactNode }>
|
||||||
|
}> = {
|
||||||
|
leave: {
|
||||||
|
title: 'Đơn xin nghỉ phép',
|
||||||
|
endpoint: '/leave-requests',
|
||||||
|
applicableType: 5,
|
||||||
|
icon: CalendarOff,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người xin', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Từ ngày', render: (x) => formatDate(x.startDate) },
|
||||||
|
{ label: 'Đến ngày', render: (x) => formatDate(x.endDate) },
|
||||||
|
{ label: 'Số ngày', render: (x) => x.numDays ?? '—' },
|
||||||
|
{ label: 'Lý do', render: (x) => x.reason || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ot: {
|
||||||
|
title: 'Đơn đăng ký OT',
|
||||||
|
endpoint: '/ot-requests',
|
||||||
|
applicableType: 6,
|
||||||
|
icon: Clock,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người xin', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Ngày OT', render: (x) => formatDate(x.otDate) },
|
||||||
|
{ label: 'Giờ bắt đầu', render: (x) => x.startTime ?? '—' },
|
||||||
|
{ label: 'Giờ kết thúc', render: (x) => x.endTime ?? '—' },
|
||||||
|
{ label: 'Số giờ', render: (x) => x.hours ?? '—' },
|
||||||
|
{ label: 'Lý do', render: (x) => x.reason || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
travel: {
|
||||||
|
title: 'Đơn đi công tác',
|
||||||
|
endpoint: '/travel-requests',
|
||||||
|
applicableType: 9,
|
||||||
|
icon: Plane,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người xin', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Địa điểm', render: (x) => x.destination || '—' },
|
||||||
|
{ label: 'Từ ngày', render: (x) => formatDate(x.startDate) },
|
||||||
|
{ label: 'Đến ngày', render: (x) => formatDate(x.endDate) },
|
||||||
|
{ label: 'Số ngày', render: (x) => x.numDays ?? '—' },
|
||||||
|
{ label: 'Dự toán chi phí', render: (x) => formatVnd(x.estimatedCost) },
|
||||||
|
{ label: 'Mục đích', render: (x) => x.purpose || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
title: 'Đặt xe công',
|
||||||
|
endpoint: '/vehicle-bookings',
|
||||||
|
applicableType: 7,
|
||||||
|
icon: Car,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người đặt', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Biển số', render: (x) => <span className="font-mono">{x.vehicleLicense ?? '—'}</span> },
|
||||||
|
{ label: 'Tên xe', render: (x) => x.vehicleName || '—' },
|
||||||
|
{ label: 'Bắt đầu', render: (x) => formatDateTime(x.startAt) },
|
||||||
|
{ label: 'Kết thúc', render: (x) => formatDateTime(x.endAt) },
|
||||||
|
{ label: 'Địa điểm đến', render: (x) => x.destination || '—' },
|
||||||
|
{ label: 'Tài xế', render: (x) => x.driverName || '—' },
|
||||||
|
{ label: 'Mục đích', render: (x) => x.purpose || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowAppDetailPage() {
|
||||||
|
const { kind = 'leave', id } = useParams<{ kind: Kind; id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const config = KIND_CONFIG[kind as Kind]
|
||||||
|
|
||||||
|
const [actionDialog, setActionDialog] = useState<ActionKind | null>(null)
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const [pickedWorkflowId, setPickedWorkflowId] = useState<string>('')
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: [config?.endpoint, id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<WorkflowAppDetail>(`${config.endpoint}/${id}`)).data,
|
||||||
|
enabled: !!config && !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const d = detail.data
|
||||||
|
const status = d?.status
|
||||||
|
const isDraft = status === WorkflowAppStatus.Nhap || status === WorkflowAppStatus.TraLai
|
||||||
|
const isInWorkflow = status === WorkflowAppStatus.DaGuiDuyet
|
||||||
|
const hasWorkflow = !!d?.approvalWorkflowId
|
||||||
|
|
||||||
|
// Workflow picker — chỉ fetch khi draft chưa pin workflow.
|
||||||
|
const workflows = useQuery({
|
||||||
|
queryKey: ['approval-workflows-v2', { applicableType: config?.applicableType, isUserSelectable: true }],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<WorkflowOption[]>('/approval-workflows-v2', {
|
||||||
|
params: { applicableType: config.applicableType, isUserSelectable: true },
|
||||||
|
})).data,
|
||||||
|
enabled: !!config && isDraft && !hasWorkflow,
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: [config.endpoint, id] })
|
||||||
|
qc.invalidateQueries({ queryKey: [config.endpoint] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinWorkflow = useMutation({
|
||||||
|
mutationFn: async (workflowId: string) => {
|
||||||
|
await api.put(`${config.endpoint}/${id}`, { approvalWorkflowId: workflowId })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã chọn quy trình duyệt')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post(`${config.endpoint}/${id}/submit`, {})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã gửi duyệt')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: async (k: ActionKind) => {
|
||||||
|
await api.post(`${config.endpoint}/${id}/${k}`, { comment: comment.trim() || null })
|
||||||
|
},
|
||||||
|
onSuccess: (_, k) => {
|
||||||
|
toast.success(`Đã ${ACTION_LABEL[k].text.toLowerCase()}`)
|
||||||
|
setActionDialog(null)
|
||||||
|
setComment('')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <div className="text-red-600">Module không tồn tại: {kind}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader title="Đang tải..." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isError || !d) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Lỗi"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
Không tải được dữ liệu đơn từ.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={d.maDonTu ?? '(Chưa có mã)'}
|
||||||
|
description={config.title}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Danh sách
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status row + action buttons */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium',
|
||||||
|
WORKFLOW_APP_STATUS_BADGE[d.status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{WORKFLOW_APP_STATUS_LABELS[d.status]}
|
||||||
|
</span>
|
||||||
|
{d.currentApprovalLevelOrder != null && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Cấp hiện tại: <span className="font-semibold">{d.currentApprovalLevelOrder}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{d.workflowCode && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Quy trình: <span className="font-mono">{d.workflowCode}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{isDraft && !hasWorkflow && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border bg-background px-2 text-sm"
|
||||||
|
value={pickedWorkflowId}
|
||||||
|
onChange={(e) => setPickedWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Chọn quy trình duyệt —</option>
|
||||||
|
{(workflows.data ?? []).map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.code} - {w.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!pickedWorkflowId || pinWorkflow.isPending}
|
||||||
|
onClick={() => pickedWorkflowId && pinWorkflow.mutate(pickedWorkflowId)}
|
||||||
|
>
|
||||||
|
Lưu quy trình
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDraft && (
|
||||||
|
<Button
|
||||||
|
onClick={() => submit.mutate()}
|
||||||
|
disabled={submit.isPending || !hasWorkflow}
|
||||||
|
title={!hasWorkflow ? 'Cần chọn quy trình duyệt trước khi gửi' : undefined}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{status === WorkflowAppStatus.TraLai ? 'Gửi duyệt lại' : 'Gửi duyệt'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isInWorkflow && (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setActionDialog('approve')} className={ACTION_LABEL.approve.tone}>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Duyệt
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setActionDialog('return')} className={ACTION_LABEL.return.tone}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Trả lại
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setActionDialog('reject')} className={ACTION_LABEL.reject.tone}>
|
||||||
|
<Ban className="mr-2 h-4 w-4" />
|
||||||
|
Từ chối
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDraft && !hasWorkflow && (
|
||||||
|
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
||||||
|
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 1: Thông tin */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<h3 className="flex items-center gap-2 font-semibold text-base">
|
||||||
|
<Icon className="h-4 w-4 opacity-70" />
|
||||||
|
1. Thông tin
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||||
|
{config.detailFields.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<Label className="text-muted-foreground">{f.label}</Label>
|
||||||
|
<div className="mt-1 font-medium whitespace-pre-wrap">{f.render(d)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Ngày tạo</Label>
|
||||||
|
<div className="mt-1 text-xs">{formatDateTime(d.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Quy trình duyệt */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Quy trình</Label>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
{d.workflowCode ? (
|
||||||
|
<>
|
||||||
|
<span className="font-mono">{d.workflowCode}</span> - {d.workflowName}
|
||||||
|
</>
|
||||||
|
) : '— Chưa chọn —'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Cấp hiện tại</Label>
|
||||||
|
<div className="mt-1 font-medium">{d.currentApprovalLevelOrder ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
|
||||||
|
{d.levelOpinions.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Chưa có ý kiến.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...d.levelOpinions]
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.stepOrder ?? 0) - (b.stepOrder ?? 0) ||
|
||||||
|
(a.levelOrder ?? 0) - (b.levelOrder ?? 0))
|
||||||
|
.map((o) => (
|
||||||
|
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder}
|
||||||
|
</span>
|
||||||
|
<span>{formatDateTime(o.signedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">{o.signedByFullName}</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action confirm dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!actionDialog}
|
||||||
|
onClose={() => {
|
||||||
|
setActionDialog(null)
|
||||||
|
setComment('')
|
||||||
|
}}
|
||||||
|
title={actionDialog ? `${ACTION_LABEL[actionDialog].text} đơn từ` : ''}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="action-comment">Ý kiến (tuỳ chọn)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="action-comment"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Để trống nếu không có ý kiến..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">{comment.length}/2000</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setActionDialog(null)}>
|
||||||
|
Huỷ
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => actionDialog && action.mutate(actionDialog)}
|
||||||
|
disabled={action.isPending}
|
||||||
|
className={actionDialog ? ACTION_LABEL[actionDialog].tone : ''}
|
||||||
|
>
|
||||||
|
{action.isPending ? 'Đang xử lý...' : 'Xác nhận'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
|
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
|
||||||
// SKELETON Phase 1: read-only list. Create form + workflow actions DEFER Phase 11.
|
// Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline).
|
||||||
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
|
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
|
||||||
// File MIRROR SHA256 identical fe-user counterpart.
|
// File MIRROR SHA256 identical fe-user counterpart.
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react'
|
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -80,6 +80,7 @@ const ICON_MAP: Record<Kind, any> = {
|
|||||||
|
|
||||||
export function WorkflowAppsListPage() {
|
export function WorkflowAppsListPage() {
|
||||||
const { kind = 'leave' } = useParams<{ kind: Kind }>()
|
const { kind = 'leave' } = useParams<{ kind: Kind }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const config = KIND_CONFIG[kind as Kind]
|
const config = KIND_CONFIG[kind as Kind]
|
||||||
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
|
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
|
||||||
|
|
||||||
@ -99,11 +100,6 @@ export function WorkflowAppsListPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader title={config.title} description={config.description} />
|
<PageHeader title={config.title} description={config.description} />
|
||||||
|
|
||||||
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
|
||||||
⚠️ <strong>Skeleton Phase 1 (S38):</strong> Read-only list. Form tạo + workflow Approve/Reject defer Phase 11 polish.
|
|
||||||
Em chủ trì kích hoạt full ApproveV2 wire khi anh main yêu cầu.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b bg-muted/50">
|
<thead className="border-b bg-muted/50">
|
||||||
@ -131,7 +127,11 @@ export function WorkflowAppsListPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{items.map((item: any) => (
|
{items.map((item: any) => (
|
||||||
<tr key={item.id} className="border-b">
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b cursor-pointer hover:bg-muted/40"
|
||||||
|
onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)}
|
||||||
|
>
|
||||||
{config.columns.map((c) => (
|
{config.columns.map((c) => (
|
||||||
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
|
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -43,6 +43,59 @@ export const IT_TICKET_STATUS_LABELS: Record<number, string> = {
|
|||||||
1: 'Mới', 2: 'Đang xử lý', 3: 'Đã giải quyết', 4: 'Đã đóng', 5: 'Mở lại',
|
1: 'Mới', 2: 'Đang xử lý', 3: 'Đã giải quyết', 4: 'Đã đóng', 5: 'Mở lại',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 11 P11-A Wave 3a (S42 2026-05-30) — Detail + LevelOpinion shape cho 4 module
|
||||||
|
// (leave/ot/travel/vehicle) mirror BE LeaveOt/TravelVehicleApprovalFeatures DetailDto.
|
||||||
|
export interface WorkflowAppLevelOpinion {
|
||||||
|
id: string
|
||||||
|
approvalWorkflowLevelId: string
|
||||||
|
stepOrder: number | null
|
||||||
|
stepName: string | null
|
||||||
|
levelOrder: number | null
|
||||||
|
approverUserId: string | null
|
||||||
|
comment: string | null
|
||||||
|
signedAt: string
|
||||||
|
signedByUserId: string
|
||||||
|
signedByFullName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superset detail — module-specific fields optional, rendered theo KIND_CONFIG.detailFields.
|
||||||
|
export interface WorkflowAppDetail {
|
||||||
|
id: string
|
||||||
|
maDonTu: string | null
|
||||||
|
requesterUserId: string
|
||||||
|
requesterFullName: string
|
||||||
|
status: number
|
||||||
|
approvalWorkflowId: string | null
|
||||||
|
workflowCode: string | null
|
||||||
|
workflowName: string | null
|
||||||
|
currentApprovalLevelOrder: number | null
|
||||||
|
rejectedFromStatus: number | null
|
||||||
|
createdAt: string
|
||||||
|
levelOpinions: WorkflowAppLevelOpinion[]
|
||||||
|
// leave
|
||||||
|
leaveTypeId?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
numDays?: number
|
||||||
|
reason?: string
|
||||||
|
// ot
|
||||||
|
otDate?: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
hours?: number
|
||||||
|
otPolicyId?: string | null
|
||||||
|
// travel
|
||||||
|
destination?: string
|
||||||
|
purpose?: string
|
||||||
|
estimatedCost?: number | null
|
||||||
|
// vehicle
|
||||||
|
vehicleLicense?: string
|
||||||
|
vehicleName?: string | null
|
||||||
|
startAt?: string
|
||||||
|
endAt?: string
|
||||||
|
driverName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PagedResult<T> {
|
export interface PagedResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
|
|||||||
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
||||||
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
|
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
|
||||||
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
||||||
|
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
|
||||||
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
||||||
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
||||||
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
||||||
@ -79,6 +80,7 @@ function App() {
|
|||||||
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
||||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
|
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
|
||||||
|
<Route path="/workflow-apps/:kind/:id" element={<WorkflowAppDetailPage />} />
|
||||||
<Route path="/it-tickets" element={<ItTicketsPage />} />
|
<Route path="/it-tickets" element={<ItTicketsPage />} />
|
||||||
<Route path="/attendance" element={<MyAttendancePage />} />
|
<Route path="/attendance" element={<MyAttendancePage />} />
|
||||||
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
|
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
|
||||||
|
|||||||
423
fe-user/src/pages/office/WorkflowAppDetailPage.tsx
Normal file
423
fe-user/src/pages/office/WorkflowAppDetailPage.tsx
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
// Generic Workflow App Detail page — Phase 11 P11-A Wave 3a (S42 2026-05-30).
|
||||||
|
// Declarative KIND_CONFIG Record<Kind> mirror WorkflowAppsListPage — 4 module
|
||||||
|
// leave / ot / travel / vehicle. Workflow status + Ý kiến cấp duyệt timeline +
|
||||||
|
// Submit/Approve/Reject/Return actions (mirror ProposalDetailPage cấu trúc).
|
||||||
|
// File MIRROR SHA256 identical với fe-user counterpart.
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Ban, CalendarOff, Car, CheckCircle2, Clock, Plane, RotateCcw, Send,
|
||||||
|
} 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 { Label } from '@/components/ui/Label'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import {
|
||||||
|
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS,
|
||||||
|
WorkflowAppStatus, type WorkflowAppDetail,
|
||||||
|
} from '@/types/workflowApps'
|
||||||
|
|
||||||
|
type Kind = 'leave' | 'ot' | 'travel' | 'vehicle'
|
||||||
|
type ActionKind = 'approve' | 'reject' | 'return'
|
||||||
|
|
||||||
|
interface WorkflowOption { id: string; code: string; name: string }
|
||||||
|
|
||||||
|
function formatDate(iso?: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso?: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVnd(n: number | null | undefined): string {
|
||||||
|
if (n === null || n === undefined) return '—'
|
||||||
|
return n.toLocaleString('vi-VN') + ' đ'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<ActionKind, { text: string; tone: string }> = {
|
||||||
|
approve: { text: 'Duyệt', tone: 'bg-emerald-600 hover:bg-emerald-700' },
|
||||||
|
reject: { text: 'Từ chối', tone: 'bg-red-600 hover:bg-red-700' },
|
||||||
|
return: { text: 'Trả lại', tone: 'bg-orange-600 hover:bg-orange-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_CONFIG: Record<Kind, {
|
||||||
|
title: string
|
||||||
|
endpoint: string
|
||||||
|
applicableType: number
|
||||||
|
icon: any
|
||||||
|
detailFields: Array<{ label: string; render: (x: WorkflowAppDetail) => React.ReactNode }>
|
||||||
|
}> = {
|
||||||
|
leave: {
|
||||||
|
title: 'Đơn xin nghỉ phép',
|
||||||
|
endpoint: '/leave-requests',
|
||||||
|
applicableType: 5,
|
||||||
|
icon: CalendarOff,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người xin', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Từ ngày', render: (x) => formatDate(x.startDate) },
|
||||||
|
{ label: 'Đến ngày', render: (x) => formatDate(x.endDate) },
|
||||||
|
{ label: 'Số ngày', render: (x) => x.numDays ?? '—' },
|
||||||
|
{ label: 'Lý do', render: (x) => x.reason || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ot: {
|
||||||
|
title: 'Đơn đăng ký OT',
|
||||||
|
endpoint: '/ot-requests',
|
||||||
|
applicableType: 6,
|
||||||
|
icon: Clock,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người xin', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Ngày OT', render: (x) => formatDate(x.otDate) },
|
||||||
|
{ label: 'Giờ bắt đầu', render: (x) => x.startTime ?? '—' },
|
||||||
|
{ label: 'Giờ kết thúc', render: (x) => x.endTime ?? '—' },
|
||||||
|
{ label: 'Số giờ', render: (x) => x.hours ?? '—' },
|
||||||
|
{ label: 'Lý do', render: (x) => x.reason || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
travel: {
|
||||||
|
title: 'Đơn đi công tác',
|
||||||
|
endpoint: '/travel-requests',
|
||||||
|
applicableType: 9,
|
||||||
|
icon: Plane,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người xin', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Địa điểm', render: (x) => x.destination || '—' },
|
||||||
|
{ label: 'Từ ngày', render: (x) => formatDate(x.startDate) },
|
||||||
|
{ label: 'Đến ngày', render: (x) => formatDate(x.endDate) },
|
||||||
|
{ label: 'Số ngày', render: (x) => x.numDays ?? '—' },
|
||||||
|
{ label: 'Dự toán chi phí', render: (x) => formatVnd(x.estimatedCost) },
|
||||||
|
{ label: 'Mục đích', render: (x) => x.purpose || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
title: 'Đặt xe công',
|
||||||
|
endpoint: '/vehicle-bookings',
|
||||||
|
applicableType: 7,
|
||||||
|
icon: Car,
|
||||||
|
detailFields: [
|
||||||
|
{ label: 'Người đặt', render: (x) => x.requesterFullName },
|
||||||
|
{ label: 'Biển số', render: (x) => <span className="font-mono">{x.vehicleLicense ?? '—'}</span> },
|
||||||
|
{ label: 'Tên xe', render: (x) => x.vehicleName || '—' },
|
||||||
|
{ label: 'Bắt đầu', render: (x) => formatDateTime(x.startAt) },
|
||||||
|
{ label: 'Kết thúc', render: (x) => formatDateTime(x.endAt) },
|
||||||
|
{ label: 'Địa điểm đến', render: (x) => x.destination || '—' },
|
||||||
|
{ label: 'Tài xế', render: (x) => x.driverName || '—' },
|
||||||
|
{ label: 'Mục đích', render: (x) => x.purpose || '—' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowAppDetailPage() {
|
||||||
|
const { kind = 'leave', id } = useParams<{ kind: Kind; id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const config = KIND_CONFIG[kind as Kind]
|
||||||
|
|
||||||
|
const [actionDialog, setActionDialog] = useState<ActionKind | null>(null)
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const [pickedWorkflowId, setPickedWorkflowId] = useState<string>('')
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: [config?.endpoint, id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<WorkflowAppDetail>(`${config.endpoint}/${id}`)).data,
|
||||||
|
enabled: !!config && !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const d = detail.data
|
||||||
|
const status = d?.status
|
||||||
|
const isDraft = status === WorkflowAppStatus.Nhap || status === WorkflowAppStatus.TraLai
|
||||||
|
const isInWorkflow = status === WorkflowAppStatus.DaGuiDuyet
|
||||||
|
const hasWorkflow = !!d?.approvalWorkflowId
|
||||||
|
|
||||||
|
// Workflow picker — chỉ fetch khi draft chưa pin workflow.
|
||||||
|
const workflows = useQuery({
|
||||||
|
queryKey: ['approval-workflows-v2', { applicableType: config?.applicableType, isUserSelectable: true }],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<WorkflowOption[]>('/approval-workflows-v2', {
|
||||||
|
params: { applicableType: config.applicableType, isUserSelectable: true },
|
||||||
|
})).data,
|
||||||
|
enabled: !!config && isDraft && !hasWorkflow,
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: [config.endpoint, id] })
|
||||||
|
qc.invalidateQueries({ queryKey: [config.endpoint] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinWorkflow = useMutation({
|
||||||
|
mutationFn: async (workflowId: string) => {
|
||||||
|
await api.put(`${config.endpoint}/${id}`, { approvalWorkflowId: workflowId })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã chọn quy trình duyệt')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.post(`${config.endpoint}/${id}/submit`, {})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã gửi duyệt')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: async (k: ActionKind) => {
|
||||||
|
await api.post(`${config.endpoint}/${id}/${k}`, { comment: comment.trim() || null })
|
||||||
|
},
|
||||||
|
onSuccess: (_, k) => {
|
||||||
|
toast.success(`Đã ${ACTION_LABEL[k].text.toLowerCase()}`)
|
||||||
|
setActionDialog(null)
|
||||||
|
setComment('')
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <div className="text-red-600">Module không tồn tại: {kind}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader title="Đang tải..." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isError || !d) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Lỗi"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="rounded-lg border bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
Không tải được dữ liệu đơn từ.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={d.maDonTu ?? '(Chưa có mã)'}
|
||||||
|
description={config.title}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/workflow-apps/${kind}`)}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Danh sách
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status row + action buttons */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium',
|
||||||
|
WORKFLOW_APP_STATUS_BADGE[d.status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{WORKFLOW_APP_STATUS_LABELS[d.status]}
|
||||||
|
</span>
|
||||||
|
{d.currentApprovalLevelOrder != null && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Cấp hiện tại: <span className="font-semibold">{d.currentApprovalLevelOrder}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{d.workflowCode && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Quy trình: <span className="font-mono">{d.workflowCode}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{isDraft && !hasWorkflow && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border bg-background px-2 text-sm"
|
||||||
|
value={pickedWorkflowId}
|
||||||
|
onChange={(e) => setPickedWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Chọn quy trình duyệt —</option>
|
||||||
|
{(workflows.data ?? []).map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.code} - {w.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!pickedWorkflowId || pinWorkflow.isPending}
|
||||||
|
onClick={() => pickedWorkflowId && pinWorkflow.mutate(pickedWorkflowId)}
|
||||||
|
>
|
||||||
|
Lưu quy trình
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDraft && (
|
||||||
|
<Button
|
||||||
|
onClick={() => submit.mutate()}
|
||||||
|
disabled={submit.isPending || !hasWorkflow}
|
||||||
|
title={!hasWorkflow ? 'Cần chọn quy trình duyệt trước khi gửi' : undefined}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{status === WorkflowAppStatus.TraLai ? 'Gửi duyệt lại' : 'Gửi duyệt'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isInWorkflow && (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setActionDialog('approve')} className={ACTION_LABEL.approve.tone}>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Duyệt
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setActionDialog('return')} className={ACTION_LABEL.return.tone}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Trả lại
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setActionDialog('reject')} className={ACTION_LABEL.reject.tone}>
|
||||||
|
<Ban className="mr-2 h-4 w-4" />
|
||||||
|
Từ chối
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDraft && !hasWorkflow && (
|
||||||
|
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
||||||
|
Đơn chưa gắn quy trình duyệt. Vui lòng chọn quy trình rồi bấm <strong>Lưu quy trình</strong> trước khi gửi duyệt.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 1: Thông tin */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<h3 className="flex items-center gap-2 font-semibold text-base">
|
||||||
|
<Icon className="h-4 w-4 opacity-70" />
|
||||||
|
1. Thông tin
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||||
|
{config.detailFields.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<Label className="text-muted-foreground">{f.label}</Label>
|
||||||
|
<div className="mt-1 font-medium whitespace-pre-wrap">{f.render(d)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Ngày tạo</Label>
|
||||||
|
<div className="mt-1 text-xs">{formatDateTime(d.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Quy trình duyệt */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<h3 className="font-semibold text-base">2. Quy trình duyệt</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Quy trình</Label>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
{d.workflowCode ? (
|
||||||
|
<>
|
||||||
|
<span className="font-mono">{d.workflowCode}</span> - {d.workflowName}
|
||||||
|
</>
|
||||||
|
) : '— Chưa chọn —'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground">Cấp hiện tại</Label>
|
||||||
|
<div className="mt-1 font-medium">{d.currentApprovalLevelOrder ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: Ý kiến cấp duyệt V2 dynamic */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||||
|
<h3 className="font-semibold text-base">3. Ý kiến cấp duyệt</h3>
|
||||||
|
{d.levelOpinions.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Chưa có ý kiến.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...d.levelOpinions]
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.stepOrder ?? 0) - (b.stepOrder ?? 0) ||
|
||||||
|
(a.levelOrder ?? 0) - (b.levelOrder ?? 0))
|
||||||
|
.map((o) => (
|
||||||
|
<div key={o.id} className="rounded border-l-4 border-emerald-400 bg-emerald-50/40 p-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Bước {o.stepOrder} {o.stepName} · Cấp {o.levelOrder}
|
||||||
|
</span>
|
||||||
|
<span>{formatDateTime(o.signedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-medium">{o.signedByFullName}</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap text-sm">{o.comment ?? '(duyệt — không ý kiến)'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action confirm dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!actionDialog}
|
||||||
|
onClose={() => {
|
||||||
|
setActionDialog(null)
|
||||||
|
setComment('')
|
||||||
|
}}
|
||||||
|
title={actionDialog ? `${ACTION_LABEL[actionDialog].text} đơn từ` : ''}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="action-comment">Ý kiến (tuỳ chọn)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="action-comment"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Để trống nếu không có ý kiến..."
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">{comment.length}/2000</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setActionDialog(null)}>
|
||||||
|
Huỷ
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => actionDialog && action.mutate(actionDialog)}
|
||||||
|
disabled={action.isPending}
|
||||||
|
className={actionDialog ? ACTION_LABEL[actionDialog].tone : ''}
|
||||||
|
>
|
||||||
|
{action.isPending ? 'Đang xử lý...' : 'Xác nhận'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
|
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
|
||||||
// SKELETON Phase 1: read-only list. Create form + workflow actions DEFER Phase 11.
|
// Wave 3a (S42 2026-05-30): row click → Detail page (workflow actions + opinion timeline).
|
||||||
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
|
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
|
||||||
// File MIRROR SHA256 identical fe-user counterpart.
|
// File MIRROR SHA256 identical fe-user counterpart.
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react'
|
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -80,6 +80,7 @@ const ICON_MAP: Record<Kind, any> = {
|
|||||||
|
|
||||||
export function WorkflowAppsListPage() {
|
export function WorkflowAppsListPage() {
|
||||||
const { kind = 'leave' } = useParams<{ kind: Kind }>()
|
const { kind = 'leave' } = useParams<{ kind: Kind }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const config = KIND_CONFIG[kind as Kind]
|
const config = KIND_CONFIG[kind as Kind]
|
||||||
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
|
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
|
||||||
|
|
||||||
@ -99,11 +100,6 @@ export function WorkflowAppsListPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader title={config.title} description={config.description} />
|
<PageHeader title={config.title} description={config.description} />
|
||||||
|
|
||||||
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
|
||||||
⚠️ <strong>Skeleton Phase 1 (S38):</strong> Read-only list. Form tạo + workflow Approve/Reject defer Phase 11 polish.
|
|
||||||
Em chủ trì kích hoạt full ApproveV2 wire khi anh main yêu cầu.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="rounded-lg border bg-card">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b bg-muted/50">
|
<thead className="border-b bg-muted/50">
|
||||||
@ -131,7 +127,11 @@ export function WorkflowAppsListPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{items.map((item: any) => (
|
{items.map((item: any) => (
|
||||||
<tr key={item.id} className="border-b">
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b cursor-pointer hover:bg-muted/40"
|
||||||
|
onClick={() => navigate(`/workflow-apps/${kind}/${item.id}`)}
|
||||||
|
>
|
||||||
{config.columns.map((c) => (
|
{config.columns.map((c) => (
|
||||||
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
|
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -43,6 +43,59 @@ export const IT_TICKET_STATUS_LABELS: Record<number, string> = {
|
|||||||
1: 'Mới', 2: 'Đang xử lý', 3: 'Đã giải quyết', 4: 'Đã đóng', 5: 'Mở lại',
|
1: 'Mới', 2: 'Đang xử lý', 3: 'Đã giải quyết', 4: 'Đã đóng', 5: 'Mở lại',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 11 P11-A Wave 3a (S42 2026-05-30) — Detail + LevelOpinion shape cho 4 module
|
||||||
|
// (leave/ot/travel/vehicle) mirror BE LeaveOt/TravelVehicleApprovalFeatures DetailDto.
|
||||||
|
export interface WorkflowAppLevelOpinion {
|
||||||
|
id: string
|
||||||
|
approvalWorkflowLevelId: string
|
||||||
|
stepOrder: number | null
|
||||||
|
stepName: string | null
|
||||||
|
levelOrder: number | null
|
||||||
|
approverUserId: string | null
|
||||||
|
comment: string | null
|
||||||
|
signedAt: string
|
||||||
|
signedByUserId: string
|
||||||
|
signedByFullName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superset detail — module-specific fields optional, rendered theo KIND_CONFIG.detailFields.
|
||||||
|
export interface WorkflowAppDetail {
|
||||||
|
id: string
|
||||||
|
maDonTu: string | null
|
||||||
|
requesterUserId: string
|
||||||
|
requesterFullName: string
|
||||||
|
status: number
|
||||||
|
approvalWorkflowId: string | null
|
||||||
|
workflowCode: string | null
|
||||||
|
workflowName: string | null
|
||||||
|
currentApprovalLevelOrder: number | null
|
||||||
|
rejectedFromStatus: number | null
|
||||||
|
createdAt: string
|
||||||
|
levelOpinions: WorkflowAppLevelOpinion[]
|
||||||
|
// leave
|
||||||
|
leaveTypeId?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
numDays?: number
|
||||||
|
reason?: string
|
||||||
|
// ot
|
||||||
|
otDate?: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
hours?: number
|
||||||
|
otPolicyId?: string | null
|
||||||
|
// travel
|
||||||
|
destination?: string
|
||||||
|
purpose?: string
|
||||||
|
estimatedCost?: number | null
|
||||||
|
// vehicle
|
||||||
|
vehicleLicense?: string
|
||||||
|
vehicleName?: string | null
|
||||||
|
startAt?: string
|
||||||
|
endAt?: string
|
||||||
|
driverName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PagedResult<T> {
|
export interface PagedResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@ -14,10 +14,63 @@ public class LeaveRequestsController(IMediator mediator) : ControllerBase
|
|||||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
=> Ok(await mediator.Send(new GetLeaveRequestsQuery(status, requesterUserId, page, pageSize)));
|
=> Ok(await mediator.Send(new GetLeaveRequestsQuery(status, requesterUserId, page, pageSize)));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var dto = await mediator.Send(new GetLeaveRequestByIdQuery(id));
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestCommand cmd)
|
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestCommand cmd)
|
||||||
{
|
{
|
||||||
var id = await mediator.Send(cmd);
|
var id = await mediator.Send(cmd);
|
||||||
return Created(string.Empty, new { id });
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateLeaveRequestDraftBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateLeaveRequestDraftCommand(id, body.LeaveTypeId, body.StartDate,
|
||||||
|
body.EndDate, body.NumDays, body.Reason, body.ApprovalWorkflowId));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/submit")]
|
||||||
|
public async Task<IActionResult> Submit(Guid id)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SubmitLeaveRequestCommand(id));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
public async Task<IActionResult> Approve(Guid id, [FromBody] WorkflowActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ApproveLeaveRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
public async Task<IActionResult> Reject(Guid id, [FromBody] WorkflowActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new RejectLeaveRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/return")]
|
||||||
|
public async Task<IActionResult> Return(Guid id, [FromBody] WorkflowActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ReturnLeaveRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateLeaveRequestDraftBody(
|
||||||
|
Guid LeaveTypeId,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
decimal NumDays,
|
||||||
|
string Reason,
|
||||||
|
Guid? ApprovalWorkflowId);
|
||||||
|
|
||||||
|
public record WorkflowActionBody(string? Comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,64 @@ public class OtRequestsController(IMediator mediator) : ControllerBase
|
|||||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
=> Ok(await mediator.Send(new GetOtRequestsQuery(status, requesterUserId, page, pageSize)));
|
=> Ok(await mediator.Send(new GetOtRequestsQuery(status, requesterUserId, page, pageSize)));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var dto = await mediator.Send(new GetOtRequestByIdQuery(id));
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateOtRequestCommand cmd)
|
public async Task<IActionResult> Create([FromBody] CreateOtRequestCommand cmd)
|
||||||
{
|
{
|
||||||
var id = await mediator.Send(cmd);
|
var id = await mediator.Send(cmd);
|
||||||
return Created(string.Empty, new { id });
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOtRequestDraftBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateOtRequestDraftCommand(id, body.OtDate, body.StartTime,
|
||||||
|
body.EndTime, body.Hours, body.Reason, body.OtPolicyId, body.ApprovalWorkflowId));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/submit")]
|
||||||
|
public async Task<IActionResult> Submit(Guid id)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SubmitOtRequestCommand(id));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
public async Task<IActionResult> Approve(Guid id, [FromBody] WorkflowActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ApproveOtRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
public async Task<IActionResult> Reject(Guid id, [FromBody] WorkflowActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new RejectOtRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/return")]
|
||||||
|
public async Task<IActionResult> Return(Guid id, [FromBody] WorkflowActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ReturnOtRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateOtRequestDraftBody(
|
||||||
|
DateTime OtDate,
|
||||||
|
TimeSpan StartTime,
|
||||||
|
TimeSpan EndTime,
|
||||||
|
decimal Hours,
|
||||||
|
string Reason,
|
||||||
|
Guid? OtPolicyId,
|
||||||
|
Guid? ApprovalWorkflowId);
|
||||||
|
|
||||||
|
public record WorkflowActionBody(string? Comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,64 @@ public class TravelRequestsController(IMediator mediator) : ControllerBase
|
|||||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
=> Ok(await mediator.Send(new GetTravelRequestsQuery(status, requesterUserId, page, pageSize)));
|
=> Ok(await mediator.Send(new GetTravelRequestsQuery(status, requesterUserId, page, pageSize)));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var dto = await mediator.Send(new GetTravelRequestByIdQuery(id));
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateTravelRequestCommand cmd)
|
public async Task<IActionResult> Create([FromBody] CreateTravelRequestCommand cmd)
|
||||||
{
|
{
|
||||||
var id = await mediator.Send(cmd);
|
var id = await mediator.Send(cmd);
|
||||||
return Created(string.Empty, new { id });
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateTravelRequestBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateTravelRequestDraftCommand(id, body.Destination, body.StartDate,
|
||||||
|
body.EndDate, body.NumDays, body.Purpose, body.EstimatedCost, body.ApprovalWorkflowId));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/submit")]
|
||||||
|
public async Task<IActionResult> Submit(Guid id)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SubmitTravelRequestCommand(id));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ApproveTravelRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new RejectTravelRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/return")]
|
||||||
|
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ReturnTravelRequestCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateTravelRequestBody(
|
||||||
|
string Destination,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
int NumDays,
|
||||||
|
string Purpose,
|
||||||
|
decimal? EstimatedCost,
|
||||||
|
Guid? ApprovalWorkflowId);
|
||||||
|
|
||||||
|
public record ApprovalActionBody(string? Comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,65 @@ public class VehicleBookingsController(IMediator mediator) : ControllerBase
|
|||||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||||
=> Ok(await mediator.Send(new GetVehicleBookingsQuery(status, requesterUserId, page, pageSize)));
|
=> Ok(await mediator.Send(new GetVehicleBookingsQuery(status, requesterUserId, page, pageSize)));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var dto = await mediator.Send(new GetVehicleBookingByIdQuery(id));
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateVehicleBookingCommand cmd)
|
public async Task<IActionResult> Create([FromBody] CreateVehicleBookingCommand cmd)
|
||||||
{
|
{
|
||||||
var id = await mediator.Send(cmd);
|
var id = await mediator.Send(cmd);
|
||||||
return Created(string.Empty, new { id });
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVehicleBookingBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdateVehicleBookingDraftCommand(id, body.VehicleLicense, body.VehicleName,
|
||||||
|
body.StartAt, body.EndAt, body.Destination, body.Purpose, body.DriverName, body.ApprovalWorkflowId));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/submit")]
|
||||||
|
public async Task<IActionResult> Submit(Guid id)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SubmitVehicleBookingCommand(id));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ApproveVehicleBookingCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new RejectVehicleBookingCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/return")]
|
||||||
|
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
|
||||||
|
{
|
||||||
|
await mediator.Send(new ReturnVehicleBookingCommand(id, body.Comment));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateVehicleBookingBody(
|
||||||
|
string VehicleLicense,
|
||||||
|
string? VehicleName,
|
||||||
|
DateTime StartAt,
|
||||||
|
DateTime EndAt,
|
||||||
|
string Destination,
|
||||||
|
string Purpose,
|
||||||
|
string? DriverName,
|
||||||
|
Guid? ApprovalWorkflowId);
|
||||||
|
|
||||||
|
public record ApprovalActionBody(string? Comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -128,6 +128,14 @@ public interface IApplicationDbContext
|
|||||||
DbSet<VehicleBooking> VehicleBookings { get; }
|
DbSet<VehicleBooking> VehicleBookings { get; }
|
||||||
DbSet<ItTicket> ItTickets { get; }
|
DbSet<ItTicket> ItTickets { get; }
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — Wire ApproveV2 + LevelOpinions cho 4 WorkflowApps
|
||||||
|
// module (cookie-cutter mirror Proposal Mig 38). + shared atomic codegen MaDonTu.
|
||||||
|
DbSet<LeaveRequestLevelOpinion> LeaveRequestLevelOpinions { get; }
|
||||||
|
DbSet<OtRequestLevelOpinion> OtRequestLevelOpinions { get; }
|
||||||
|
DbSet<TravelRequestLevelOpinion> TravelRequestLevelOpinions { get; }
|
||||||
|
DbSet<VehicleBookingLevelOpinion> VehicleBookingLevelOpinions { get; }
|
||||||
|
DbSet<WorkflowAppCodeSequence> WorkflowAppCodeSequences { get; }
|
||||||
|
|
||||||
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
||||||
DbSet<Attendance> Attendances { get; }
|
DbSet<Attendance> Attendances { get; }
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,749 @@
|
|||||||
|
using System.Data;
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A Wave 2a (S41/S42 2026-05-30) — wire ApproveV2 CQRS cho LeaveRequest + OtRequest.
|
||||||
|
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema sẵn (Mig 41).
|
||||||
|
// ApplicableType: LeaveRequest=5, OtRequest=6.
|
||||||
|
//
|
||||||
|
// Per module 7 handler: GetById detail · UpdateDraft · Submit · Approve(UPSERT+advance) ·
|
||||||
|
// Reject(TuChoi) · Return(TraLai). 1 static helper GenerateMaDonTuAsync dùng chung.
|
||||||
|
//
|
||||||
|
// CONSTRAINT: KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). KHÔNG đụng
|
||||||
|
// Travel/Vehicle (Wave 2b song song). MaDonTu gen lần Submit đầu (null→gen).
|
||||||
|
|
||||||
|
// ===== Shared CodeGen helper (dùng chung Leave + Ot trong file này) =====
|
||||||
|
|
||||||
|
internal static class WorkflowAppCodeGen
|
||||||
|
{
|
||||||
|
// Mirror ProposalFeatures.GenerateMaDeXuatAsync — Serializable tx + Prefix-keyed sequence.
|
||||||
|
// Format: "{prefix}/{seq:D3}" — prefix vd "DT/LR/2026" → "DT/LR/2026/001".
|
||||||
|
internal static async Task<string> GenerateMaDonTuAsync(
|
||||||
|
IApplicationDbContext db, string prefix, int year, IDateTime clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fullPrefix = $"{prefix}/{year}";
|
||||||
|
var dbContext = (DbContext)db;
|
||||||
|
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||||
|
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == fullPrefix, ct);
|
||||||
|
if (seq is null)
|
||||||
|
{
|
||||||
|
seq = new WorkflowAppCodeSequence { Prefix = fullPrefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||||
|
db.WorkflowAppCodeSequences.Add(seq);
|
||||||
|
}
|
||||||
|
seq.LastSeq++;
|
||||||
|
seq.UpdatedAt = clock.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return $"{fullPrefix}/{seq.LastSeq:D3}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MODULE A: LeaveRequest (ApplicableType=5, prefix "DT/LR")
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public record LeaveRequestLevelOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApprovalWorkflowLevelId,
|
||||||
|
int? StepOrder,
|
||||||
|
string? StepName,
|
||||||
|
int? LevelOrder,
|
||||||
|
Guid? ApproverUserId,
|
||||||
|
string? Comment,
|
||||||
|
DateTime SignedAt,
|
||||||
|
Guid SignedByUserId,
|
||||||
|
string SignedByFullName);
|
||||||
|
|
||||||
|
public record LeaveRequestDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaDonTu,
|
||||||
|
Guid RequesterUserId,
|
||||||
|
string RequesterFullName,
|
||||||
|
Guid LeaveTypeId,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
decimal NumDays,
|
||||||
|
string Reason,
|
||||||
|
int Status,
|
||||||
|
Guid? ApprovalWorkflowId,
|
||||||
|
string? WorkflowCode,
|
||||||
|
string? WorkflowName,
|
||||||
|
int? CurrentApprovalLevelOrder,
|
||||||
|
int? RejectedFromStatus,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<LeaveRequestLevelOpinionDto> LevelOpinions);
|
||||||
|
|
||||||
|
public record GetLeaveRequestByIdQuery(Guid Id) : IRequest<LeaveRequestDetailDto?>;
|
||||||
|
|
||||||
|
public class GetLeaveRequestByIdHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<GetLeaveRequestByIdQuery, LeaveRequestDetailDto?>
|
||||||
|
{
|
||||||
|
public async Task<LeaveRequestDetailDto?> Handle(GetLeaveRequestByIdQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.LeaveRequests.AsNoTracking()
|
||||||
|
.Include(x => x.LevelOpinions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) return null;
|
||||||
|
|
||||||
|
string? wfCode = null;
|
||||||
|
string? wfName = null;
|
||||||
|
if (p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => new { w.Code, w.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
wfCode = wf?.Code;
|
||||||
|
wfName = wf?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||||
|
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||||
|
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.Order,
|
||||||
|
s.Name,
|
||||||
|
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||||
|
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||||
|
.ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToDictionary(x => x.Level.Id);
|
||||||
|
|
||||||
|
var opinions = p.LevelOpinions
|
||||||
|
.Select(o =>
|
||||||
|
{
|
||||||
|
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||||
|
return new LeaveRequestLevelOpinionDto(
|
||||||
|
o.Id,
|
||||||
|
o.ApprovalWorkflowLevelId,
|
||||||
|
lvl?.Step.Order,
|
||||||
|
lvl?.Step.Name,
|
||||||
|
lvl?.Level.Order,
|
||||||
|
lvl?.Level.ApproverUserId,
|
||||||
|
o.Comment,
|
||||||
|
o.SignedAt,
|
||||||
|
o.SignedByUserId,
|
||||||
|
o.SignedByFullName);
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new LeaveRequestDetailDto(
|
||||||
|
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId,
|
||||||
|
p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status,
|
||||||
|
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||||
|
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||||
|
p.CreatedAt, opinions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateLeaveRequestDraftCommand(
|
||||||
|
Guid Id,
|
||||||
|
Guid LeaveTypeId,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
decimal NumDays,
|
||||||
|
string Reason,
|
||||||
|
Guid? ApprovalWorkflowId) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateLeaveRequestDraftValidator : AbstractValidator<UpdateLeaveRequestDraftCommand>
|
||||||
|
{
|
||||||
|
public UpdateLeaveRequestDraftValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||||
|
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||||
|
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateLeaveRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<UpdateLeaveRequestDraftCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateLeaveRequestDraftCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == cu.UserId;
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
|
||||||
|
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||||
|
{
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.LeaveTypeId = req.LeaveTypeId;
|
||||||
|
p.StartDate = req.StartDate;
|
||||||
|
p.EndDate = req.EndDate;
|
||||||
|
p.NumDays = req.NumDays;
|
||||||
|
p.Reason = req.Reason.Trim();
|
||||||
|
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SubmitLeaveRequestCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class SubmitLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<SubmitLeaveRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SubmitLeaveRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == cu.UserId;
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue)
|
||||||
|
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||||
|
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||||
|
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/LR", clock.Now.Year, clock, ct);
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = 1;
|
||||||
|
p.RejectedFromStatus = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApproveLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<ApproveLeaveRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ApproveLeaveRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (cu.UserId is null) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||||
|
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||||
|
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
|
||||||
|
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToList();
|
||||||
|
if (allLevels.Count == 0)
|
||||||
|
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||||
|
|
||||||
|
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentSlot is null)
|
||||||
|
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||||
|
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
|
||||||
|
var existing = await db.LeaveRequestLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.LeaveRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||||
|
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||||
|
? "(duyệt — không ý kiến)"
|
||||||
|
: req.Comment.Trim();
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.LeaveRequestLevelOpinions.Add(new LeaveRequestLevelOpinion
|
||||||
|
{
|
||||||
|
LeaveRequestId = p.Id,
|
||||||
|
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||||
|
Comment = commentFinal,
|
||||||
|
SignedAt = clock.UtcNow,
|
||||||
|
SignedByUserId = cu.UserId.Value,
|
||||||
|
SignedByFullName = cu.FullName ?? "(unknown)",
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
CreatedBy = cu.UserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Comment = commentFinal;
|
||||||
|
existing.SignedAt = clock.UtcNow;
|
||||||
|
existing.SignedByUserId = cu.UserId.Value;
|
||||||
|
existing.SignedByFullName = cu.FullName ?? "(unknown)";
|
||||||
|
existing.UpdatedAt = clock.UtcNow;
|
||||||
|
existing.UpdatedBy = cu.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||||
|
{
|
||||||
|
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Status = WorkflowAppStatus.DaDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
}
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RejectLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class RejectLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<RejectLeaveRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(RejectLeaveRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TuChoi;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReturnLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ReturnLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<ReturnLeaveRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ReturnLeaveRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TraLai;
|
||||||
|
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MODULE B: OtRequest (ApplicableType=6, prefix "DT/OT")
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public record OtRequestLevelOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApprovalWorkflowLevelId,
|
||||||
|
int? StepOrder,
|
||||||
|
string? StepName,
|
||||||
|
int? LevelOrder,
|
||||||
|
Guid? ApproverUserId,
|
||||||
|
string? Comment,
|
||||||
|
DateTime SignedAt,
|
||||||
|
Guid SignedByUserId,
|
||||||
|
string SignedByFullName);
|
||||||
|
|
||||||
|
public record OtRequestDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaDonTu,
|
||||||
|
Guid RequesterUserId,
|
||||||
|
string RequesterFullName,
|
||||||
|
DateTime OtDate,
|
||||||
|
TimeSpan StartTime,
|
||||||
|
TimeSpan EndTime,
|
||||||
|
decimal Hours,
|
||||||
|
string Reason,
|
||||||
|
Guid? OtPolicyId,
|
||||||
|
int Status,
|
||||||
|
Guid? ApprovalWorkflowId,
|
||||||
|
string? WorkflowCode,
|
||||||
|
string? WorkflowName,
|
||||||
|
int? CurrentApprovalLevelOrder,
|
||||||
|
int? RejectedFromStatus,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<OtRequestLevelOpinionDto> LevelOpinions);
|
||||||
|
|
||||||
|
public record GetOtRequestByIdQuery(Guid Id) : IRequest<OtRequestDetailDto?>;
|
||||||
|
|
||||||
|
public class GetOtRequestByIdHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<GetOtRequestByIdQuery, OtRequestDetailDto?>
|
||||||
|
{
|
||||||
|
public async Task<OtRequestDetailDto?> Handle(GetOtRequestByIdQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.OtRequests.AsNoTracking()
|
||||||
|
.Include(x => x.LevelOpinions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) return null;
|
||||||
|
|
||||||
|
string? wfCode = null;
|
||||||
|
string? wfName = null;
|
||||||
|
if (p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => new { w.Code, w.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
wfCode = wf?.Code;
|
||||||
|
wfName = wf?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||||
|
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||||
|
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.Order,
|
||||||
|
s.Name,
|
||||||
|
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||||
|
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||||
|
.ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToDictionary(x => x.Level.Id);
|
||||||
|
|
||||||
|
var opinions = p.LevelOpinions
|
||||||
|
.Select(o =>
|
||||||
|
{
|
||||||
|
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||||
|
return new OtRequestLevelOpinionDto(
|
||||||
|
o.Id,
|
||||||
|
o.ApprovalWorkflowLevelId,
|
||||||
|
lvl?.Step.Order,
|
||||||
|
lvl?.Step.Name,
|
||||||
|
lvl?.Level.Order,
|
||||||
|
lvl?.Level.ApproverUserId,
|
||||||
|
o.Comment,
|
||||||
|
o.SignedAt,
|
||||||
|
o.SignedByUserId,
|
||||||
|
o.SignedByFullName);
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new OtRequestDetailDto(
|
||||||
|
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||||
|
p.OtDate, p.StartTime, p.EndTime, p.Hours, p.Reason, p.OtPolicyId, (int)p.Status,
|
||||||
|
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||||
|
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||||
|
p.CreatedAt, opinions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateOtRequestDraftCommand(
|
||||||
|
Guid Id,
|
||||||
|
DateTime OtDate,
|
||||||
|
TimeSpan StartTime,
|
||||||
|
TimeSpan EndTime,
|
||||||
|
decimal Hours,
|
||||||
|
string Reason,
|
||||||
|
Guid? OtPolicyId,
|
||||||
|
Guid? ApprovalWorkflowId) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateOtRequestDraftValidator : AbstractValidator<UpdateOtRequestDraftCommand>
|
||||||
|
{
|
||||||
|
public UpdateOtRequestDraftValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||||
|
RuleFor(x => x.Hours).GreaterThan(0);
|
||||||
|
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateOtRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<UpdateOtRequestDraftCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateOtRequestDraftCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == cu.UserId;
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
|
||||||
|
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||||
|
{
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.OtDate = req.OtDate;
|
||||||
|
p.StartTime = req.StartTime;
|
||||||
|
p.EndTime = req.EndTime;
|
||||||
|
p.Hours = req.Hours;
|
||||||
|
p.Reason = req.Reason.Trim();
|
||||||
|
p.OtPolicyId = req.OtPolicyId;
|
||||||
|
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SubmitOtRequestCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class SubmitOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<SubmitOtRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SubmitOtRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == cu.UserId;
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue)
|
||||||
|
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||||
|
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||||
|
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/OT", clock.Now.Year, clock, ct);
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = 1;
|
||||||
|
p.RejectedFromStatus = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApproveOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ApproveOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<ApproveOtRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ApproveOtRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (cu.UserId is null) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||||
|
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||||
|
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
|
||||||
|
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToList();
|
||||||
|
if (allLevels.Count == 0)
|
||||||
|
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||||
|
|
||||||
|
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentSlot is null)
|
||||||
|
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||||
|
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
|
||||||
|
var existing = await db.OtRequestLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.OtRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||||
|
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||||
|
? "(duyệt — không ý kiến)"
|
||||||
|
: req.Comment.Trim();
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.OtRequestLevelOpinions.Add(new OtRequestLevelOpinion
|
||||||
|
{
|
||||||
|
OtRequestId = p.Id,
|
||||||
|
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||||
|
Comment = commentFinal,
|
||||||
|
SignedAt = clock.UtcNow,
|
||||||
|
SignedByUserId = cu.UserId.Value,
|
||||||
|
SignedByFullName = cu.FullName ?? "(unknown)",
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
CreatedBy = cu.UserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Comment = commentFinal;
|
||||||
|
existing.SignedAt = clock.UtcNow;
|
||||||
|
existing.SignedByUserId = cu.UserId.Value;
|
||||||
|
existing.SignedByFullName = cu.FullName ?? "(unknown)";
|
||||||
|
existing.UpdatedAt = clock.UtcNow;
|
||||||
|
existing.UpdatedBy = cu.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||||
|
{
|
||||||
|
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Status = WorkflowAppStatus.DaDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
}
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RejectOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class RejectOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<RejectOtRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(RejectOtRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TuChoi;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReturnOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ReturnOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||||
|
: IRequestHandler<ReturnOtRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ReturnOtRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = cu.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TraLai;
|
||||||
|
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,773 @@
|
|||||||
|
using System.Data;
|
||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A Wave 2b (S41 2026-05-30) — Wire ApproveV2 CQRS cho TravelRequest + VehicleBooking.
|
||||||
|
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema Wave 1 (Mig 41) đã sẵn:
|
||||||
|
// TravelRequestLevelOpinions + VehicleBookingLevelOpinions + WorkflowAppCodeSequences
|
||||||
|
// parent +RejectedFromStatus + nav LevelOpinions.
|
||||||
|
//
|
||||||
|
// ApplicableType: TravelRequest=9 (Mig 41 mới) · VehicleBooking=7.
|
||||||
|
// WorkflowAppStatus {Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5}.
|
||||||
|
//
|
||||||
|
// Endpoint per module (qua {Travel,Vehicle}*Controller):
|
||||||
|
// GET /{id} — detail Include LevelOpinions + Workflow metadata
|
||||||
|
// PUT /{id} — update draft (Nhap or TraLai only)
|
||||||
|
// POST /{id}/submit — gen MaDonTu atomic + Status=DaGuiDuyet
|
||||||
|
// POST /{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
|
||||||
|
// POST /{id}/reject — Status=TuChoi terminal (no opinion sync)
|
||||||
|
// POST /{id}/return — Status=TraLai + RejectedFromStatus=DaGuiDuyet (no opinion sync)
|
||||||
|
//
|
||||||
|
// Note: GetList + Create giữ nguyên trong WorkflowAppsFeatures.cs (KHÔNG sửa file đó).
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// REGION 1: TravelRequest — ApproveV2 wire
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public record TravelRequestLevelOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApprovalWorkflowLevelId,
|
||||||
|
int? StepOrder,
|
||||||
|
string? StepName,
|
||||||
|
int? LevelOrder,
|
||||||
|
Guid? ApproverUserId,
|
||||||
|
string? Comment,
|
||||||
|
DateTime SignedAt,
|
||||||
|
Guid SignedByUserId,
|
||||||
|
string SignedByFullName);
|
||||||
|
|
||||||
|
public record TravelRequestDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaDonTu,
|
||||||
|
Guid RequesterUserId,
|
||||||
|
string RequesterFullName,
|
||||||
|
string Destination,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
int NumDays,
|
||||||
|
string Purpose,
|
||||||
|
decimal? EstimatedCost,
|
||||||
|
int Status,
|
||||||
|
Guid? ApprovalWorkflowId,
|
||||||
|
string? WorkflowCode,
|
||||||
|
string? WorkflowName,
|
||||||
|
int? CurrentApprovalLevelOrder,
|
||||||
|
int? RejectedFromStatus,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<TravelRequestLevelOpinionDto> LevelOpinions);
|
||||||
|
|
||||||
|
public record GetTravelRequestByIdQuery(Guid Id) : IRequest<TravelRequestDetailDto?>;
|
||||||
|
|
||||||
|
public class GetTravelRequestByIdHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<GetTravelRequestByIdQuery, TravelRequestDetailDto?>
|
||||||
|
{
|
||||||
|
public async Task<TravelRequestDetailDto?> Handle(GetTravelRequestByIdQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.TravelRequests.AsNoTracking()
|
||||||
|
.Include(x => x.LevelOpinions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) return null;
|
||||||
|
|
||||||
|
string? wfCode = null;
|
||||||
|
string? wfName = null;
|
||||||
|
if (p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => new { w.Code, w.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
wfCode = wf?.Code;
|
||||||
|
wfName = wf?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||||
|
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||||
|
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.Order,
|
||||||
|
s.Name,
|
||||||
|
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||||
|
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||||
|
.ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToDictionary(x => x.Level.Id);
|
||||||
|
|
||||||
|
var opinions = p.LevelOpinions
|
||||||
|
.Select(o =>
|
||||||
|
{
|
||||||
|
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||||
|
return new TravelRequestLevelOpinionDto(
|
||||||
|
o.Id,
|
||||||
|
o.ApprovalWorkflowLevelId,
|
||||||
|
lvl?.Step.Order,
|
||||||
|
lvl?.Step.Name,
|
||||||
|
lvl?.Level.Order,
|
||||||
|
lvl?.Level.ApproverUserId,
|
||||||
|
o.Comment,
|
||||||
|
o.SignedAt,
|
||||||
|
o.SignedByUserId,
|
||||||
|
o.SignedByFullName);
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new TravelRequestDetailDto(
|
||||||
|
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||||
|
p.Destination, p.StartDate, p.EndDate, p.NumDays, p.Purpose, p.EstimatedCost,
|
||||||
|
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||||
|
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||||
|
p.CreatedAt, opinions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateTravelRequestDraftCommand(
|
||||||
|
Guid Id,
|
||||||
|
string Destination,
|
||||||
|
DateTime StartDate,
|
||||||
|
DateTime EndDate,
|
||||||
|
int NumDays,
|
||||||
|
string Purpose,
|
||||||
|
decimal? EstimatedCost,
|
||||||
|
Guid? ApprovalWorkflowId) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateTravelRequestDraftValidator : AbstractValidator<UpdateTravelRequestDraftCommand>
|
||||||
|
{
|
||||||
|
public UpdateTravelRequestDraftValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||||
|
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||||
|
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||||
|
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateTravelRequestDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<UpdateTravelRequestDraftCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateTravelRequestDraftCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn công tác.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
|
||||||
|
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||||
|
{
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Destination = req.Destination.Trim();
|
||||||
|
p.StartDate = req.StartDate;
|
||||||
|
p.EndDate = req.EndDate;
|
||||||
|
p.NumDays = req.NumDays;
|
||||||
|
p.Purpose = req.Purpose.Trim();
|
||||||
|
p.EstimatedCost = req.EstimatedCost;
|
||||||
|
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SubmitTravelRequestCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class SubmitTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<SubmitTravelRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SubmitTravelRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue)
|
||||||
|
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||||
|
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||||
|
{
|
||||||
|
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
|
||||||
|
db, $"DT/CT/{clock.Now.Year}", clock, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = 1;
|
||||||
|
p.RejectedFromStatus = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApproveTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ApproveTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<ApproveTravelRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ApproveTravelRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||||
|
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||||
|
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
|
||||||
|
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToList();
|
||||||
|
if (allLevels.Count == 0)
|
||||||
|
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||||
|
|
||||||
|
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentSlot is null)
|
||||||
|
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
|
||||||
|
var existing = await db.TravelRequestLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.TravelRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||||
|
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||||
|
? "(duyệt — không ý kiến)"
|
||||||
|
: req.Comment.Trim();
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.TravelRequestLevelOpinions.Add(new TravelRequestLevelOpinion
|
||||||
|
{
|
||||||
|
TravelRequestId = p.Id,
|
||||||
|
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||||
|
Comment = commentFinal,
|
||||||
|
SignedAt = clock.UtcNow,
|
||||||
|
SignedByUserId = currentUser.UserId.Value,
|
||||||
|
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
CreatedBy = currentUser.UserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Comment = commentFinal;
|
||||||
|
existing.SignedAt = clock.UtcNow;
|
||||||
|
existing.SignedByUserId = currentUser.UserId.Value;
|
||||||
|
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||||
|
existing.UpdatedAt = clock.UtcNow;
|
||||||
|
existing.UpdatedBy = currentUser.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||||
|
{
|
||||||
|
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Status = WorkflowAppStatus.DaDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
}
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RejectTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class RejectTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<RejectTravelRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(RejectTravelRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TuChoi;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReturnTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ReturnTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<ReturnTravelRequestCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ReturnTravelRequestCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TraLai;
|
||||||
|
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// REGION 2: VehicleBooking — ApproveV2 wire
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public record VehicleBookingLevelOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApprovalWorkflowLevelId,
|
||||||
|
int? StepOrder,
|
||||||
|
string? StepName,
|
||||||
|
int? LevelOrder,
|
||||||
|
Guid? ApproverUserId,
|
||||||
|
string? Comment,
|
||||||
|
DateTime SignedAt,
|
||||||
|
Guid SignedByUserId,
|
||||||
|
string SignedByFullName);
|
||||||
|
|
||||||
|
public record VehicleBookingDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaDonTu,
|
||||||
|
Guid RequesterUserId,
|
||||||
|
string RequesterFullName,
|
||||||
|
string VehicleLicense,
|
||||||
|
string? VehicleName,
|
||||||
|
DateTime StartAt,
|
||||||
|
DateTime EndAt,
|
||||||
|
string Destination,
|
||||||
|
string Purpose,
|
||||||
|
string? DriverName,
|
||||||
|
int Status,
|
||||||
|
Guid? ApprovalWorkflowId,
|
||||||
|
string? WorkflowCode,
|
||||||
|
string? WorkflowName,
|
||||||
|
int? CurrentApprovalLevelOrder,
|
||||||
|
int? RejectedFromStatus,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
List<VehicleBookingLevelOpinionDto> LevelOpinions);
|
||||||
|
|
||||||
|
public record GetVehicleBookingByIdQuery(Guid Id) : IRequest<VehicleBookingDetailDto?>;
|
||||||
|
|
||||||
|
public class GetVehicleBookingByIdHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<GetVehicleBookingByIdQuery, VehicleBookingDetailDto?>
|
||||||
|
{
|
||||||
|
public async Task<VehicleBookingDetailDto?> Handle(GetVehicleBookingByIdQuery req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.VehicleBookings.AsNoTracking()
|
||||||
|
.Include(x => x.LevelOpinions)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) return null;
|
||||||
|
|
||||||
|
string? wfCode = null;
|
||||||
|
string? wfName = null;
|
||||||
|
if (p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => new { w.Code, w.Name })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
wfCode = wf?.Code;
|
||||||
|
wfName = wf?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||||
|
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||||
|
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.Order,
|
||||||
|
s.Name,
|
||||||
|
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||||
|
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||||
|
.ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToDictionary(x => x.Level.Id);
|
||||||
|
|
||||||
|
var opinions = p.LevelOpinions
|
||||||
|
.Select(o =>
|
||||||
|
{
|
||||||
|
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||||
|
return new VehicleBookingLevelOpinionDto(
|
||||||
|
o.Id,
|
||||||
|
o.ApprovalWorkflowLevelId,
|
||||||
|
lvl?.Step.Order,
|
||||||
|
lvl?.Step.Name,
|
||||||
|
lvl?.Level.Order,
|
||||||
|
lvl?.Level.ApproverUserId,
|
||||||
|
o.Comment,
|
||||||
|
o.SignedAt,
|
||||||
|
o.SignedByUserId,
|
||||||
|
o.SignedByFullName);
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new VehicleBookingDetailDto(
|
||||||
|
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||||
|
p.VehicleLicense, p.VehicleName, p.StartAt, p.EndAt, p.Destination, p.Purpose, p.DriverName,
|
||||||
|
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||||
|
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||||
|
p.CreatedAt, opinions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateVehicleBookingDraftCommand(
|
||||||
|
Guid Id,
|
||||||
|
string VehicleLicense,
|
||||||
|
string? VehicleName,
|
||||||
|
DateTime StartAt,
|
||||||
|
DateTime EndAt,
|
||||||
|
string Destination,
|
||||||
|
string Purpose,
|
||||||
|
string? DriverName,
|
||||||
|
Guid? ApprovalWorkflowId) : IRequest;
|
||||||
|
|
||||||
|
public class UpdateVehicleBookingDraftValidator : AbstractValidator<UpdateVehicleBookingDraftCommand>
|
||||||
|
{
|
||||||
|
public UpdateVehicleBookingDraftValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
|
||||||
|
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||||
|
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||||
|
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateVehicleBookingDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<UpdateVehicleBookingDraftCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdateVehicleBookingDraftCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn đặt xe.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
|
||||||
|
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||||
|
{
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.VehicleLicense = req.VehicleLicense.Trim();
|
||||||
|
p.VehicleName = req.VehicleName?.Trim();
|
||||||
|
p.StartAt = req.StartAt;
|
||||||
|
p.EndAt = req.EndAt;
|
||||||
|
p.Destination = req.Destination.Trim();
|
||||||
|
p.Purpose = req.Purpose.Trim();
|
||||||
|
p.DriverName = req.DriverName?.Trim();
|
||||||
|
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SubmitVehicleBookingCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class SubmitVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<SubmitVehicleBookingCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SubmitVehicleBookingCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||||
|
|
||||||
|
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isOwner && !isAdmin)
|
||||||
|
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||||
|
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||||
|
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue)
|
||||||
|
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||||
|
|
||||||
|
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||||
|
.Select(w => (int?)w.ApplicableType)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (wfType is null)
|
||||||
|
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
|
||||||
|
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||||
|
{
|
||||||
|
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
|
||||||
|
db, $"DX/XE/{clock.Now.Year}", clock, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = 1;
|
||||||
|
p.RejectedFromStatus = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApproveVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ApproveVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<ApproveVehicleBookingCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ApproveVehicleBookingCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||||
|
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||||
|
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||||
|
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||||
|
|
||||||
|
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||||
|
.ToList();
|
||||||
|
if (allLevels.Count == 0)
|
||||||
|
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||||
|
|
||||||
|
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentSlot is null)
|
||||||
|
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
|
||||||
|
var existing = await db.VehicleBookingLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.VehicleBookingId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||||
|
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||||
|
? "(duyệt — không ý kiến)"
|
||||||
|
: req.Comment.Trim();
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.VehicleBookingLevelOpinions.Add(new VehicleBookingLevelOpinion
|
||||||
|
{
|
||||||
|
VehicleBookingId = p.Id,
|
||||||
|
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||||
|
Comment = commentFinal,
|
||||||
|
SignedAt = clock.UtcNow,
|
||||||
|
SignedByUserId = currentUser.UserId.Value,
|
||||||
|
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||||
|
CreatedAt = clock.UtcNow,
|
||||||
|
CreatedBy = currentUser.UserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.Comment = commentFinal;
|
||||||
|
existing.SignedAt = clock.UtcNow;
|
||||||
|
existing.SignedByUserId = currentUser.UserId.Value;
|
||||||
|
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||||
|
existing.UpdatedAt = clock.UtcNow;
|
||||||
|
existing.UpdatedBy = currentUser.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||||
|
{
|
||||||
|
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.Status = WorkflowAppStatus.DaDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
}
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RejectVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class RejectVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<RejectVehicleBookingCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(RejectVehicleBookingCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TuChoi;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReturnVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class ReturnVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||||
|
: IRequestHandler<ReturnVehicleBookingCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(ReturnVehicleBookingCommand req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||||
|
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||||
|
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||||
|
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||||
|
{
|
||||||
|
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||||
|
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||||
|
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.ToList() ?? new();
|
||||||
|
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||||
|
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||||
|
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Status = WorkflowAppStatus.TraLai;
|
||||||
|
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = currentUser.UserId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Shared CodeGen helper — Prefix-keyed WorkflowAppCodeSequences (SERIALIZABLE tx).
|
||||||
|
// Mirror SubmitProposalHandler.GenerateMaDeXuatAsync. Format: {prefix}/{seq:D3}.
|
||||||
|
// Travel prefix = "DT/CT/{year}"
|
||||||
|
// Vehicle prefix = "DX/XE/{year}"
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
internal static class TravelVehicleCodeGen
|
||||||
|
{
|
||||||
|
internal static async Task<string> GenerateMaDonTuAsync(
|
||||||
|
IApplicationDbContext db, string prefix, IDateTime clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dbContext = (DbContext)db;
|
||||||
|
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||||
|
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
|
||||||
|
if (seq is null)
|
||||||
|
{
|
||||||
|
seq = new WorkflowAppCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||||
|
db.WorkflowAppCodeSequences.Add(seq);
|
||||||
|
}
|
||||||
|
seq.LastSeq++;
|
||||||
|
seq.UpdatedAt = clock.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return $"{prefix}/{seq.LastSeq:D3}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -55,6 +55,7 @@ public enum ApprovalWorkflowApplicableType
|
|||||||
OtRequest = 6, // G-O4 — Đơn OT
|
OtRequest = 6, // G-O4 — Đơn OT
|
||||||
VehicleBooking = 7, // G-O5 — Đặt xe công
|
VehicleBooking = 7, // G-O5 — Đặt xe công
|
||||||
ItTicket = 8, // G-O6 — Ticket CNTT
|
ItTicket = 8, // G-O6 — Ticket CNTT
|
||||||
|
TravelRequest = 9, // G-O4 — Đơn công tác (Travel) — Phase 11 P11-A
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.
|
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.
|
||||||
|
|||||||
@ -19,4 +19,7 @@ public class LeaveRequest : AuditableEntity
|
|||||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||||
public Guid? ApprovalWorkflowId { get; set; } // pin ApplicableType=5
|
public Guid? ApprovalWorkflowId { get; set; } // pin ApplicableType=5
|
||||||
public int? CurrentApprovalLevelOrder { get; set; }
|
public int? CurrentApprovalLevelOrder { get; set; }
|
||||||
|
|
||||||
|
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
|
||||||
|
public List<LeaveRequestLevelOpinion> LevelOpinions { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho LeaveRequest.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||||
|
//
|
||||||
|
// Mỗi row = 1 (LeaveRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||||
|
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||||
|
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||||
|
//
|
||||||
|
// UNIQUE composite (LeaveRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||||
|
// FK Cascade LeaveRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||||
|
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||||
|
public class LeaveRequestLevelOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid LeaveRequestId { get; set; }
|
||||||
|
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||||
|
|
||||||
|
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||||
|
public DateTime SignedAt { get; set; }
|
||||||
|
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||||
|
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||||
|
|
||||||
|
public LeaveRequest? LeaveRequest { get; set; }
|
||||||
|
public ApprovalWorkflowLevel? Level { get; set; }
|
||||||
|
}
|
||||||
@ -19,4 +19,7 @@ public class OtRequest : AuditableEntity
|
|||||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||||
public Guid? ApprovalWorkflowId { get; set; }
|
public Guid? ApprovalWorkflowId { get; set; }
|
||||||
public int? CurrentApprovalLevelOrder { get; set; }
|
public int? CurrentApprovalLevelOrder { get; set; }
|
||||||
|
|
||||||
|
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
|
||||||
|
public List<OtRequestLevelOpinion> LevelOpinions { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho OtRequest.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||||
|
//
|
||||||
|
// Mỗi row = 1 (OtRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||||
|
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||||
|
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||||
|
//
|
||||||
|
// UNIQUE composite (OtRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||||
|
// FK Cascade OtRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||||
|
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||||
|
public class OtRequestLevelOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid OtRequestId { get; set; }
|
||||||
|
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||||
|
|
||||||
|
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||||
|
public DateTime SignedAt { get; set; }
|
||||||
|
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||||
|
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||||
|
|
||||||
|
public OtRequest? OtRequest { get; set; }
|
||||||
|
public ApprovalWorkflowLevel? Level { get; set; }
|
||||||
|
}
|
||||||
@ -18,4 +18,7 @@ public class TravelRequest : AuditableEntity
|
|||||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||||
public Guid? ApprovalWorkflowId { get; set; }
|
public Guid? ApprovalWorkflowId { get; set; }
|
||||||
public int? CurrentApprovalLevelOrder { get; set; }
|
public int? CurrentApprovalLevelOrder { get; set; }
|
||||||
|
|
||||||
|
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
|
||||||
|
public List<TravelRequestLevelOpinion> LevelOpinions { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho TravelRequest.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||||
|
//
|
||||||
|
// Mỗi row = 1 (TravelRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||||
|
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||||
|
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||||
|
//
|
||||||
|
// UNIQUE composite (TravelRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||||
|
// FK Cascade TravelRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||||
|
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||||
|
public class TravelRequestLevelOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid TravelRequestId { get; set; }
|
||||||
|
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||||
|
|
||||||
|
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||||
|
public DateTime SignedAt { get; set; }
|
||||||
|
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||||
|
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||||
|
|
||||||
|
public TravelRequest? TravelRequest { get; set; }
|
||||||
|
public ApprovalWorkflowLevel? Level { get; set; }
|
||||||
|
}
|
||||||
@ -20,4 +20,7 @@ public class VehicleBooking : AuditableEntity
|
|||||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||||
public Guid? ApprovalWorkflowId { get; set; }
|
public Guid? ApprovalWorkflowId { get; set; }
|
||||||
public int? CurrentApprovalLevelOrder { get; set; }
|
public int? CurrentApprovalLevelOrder { get; set; }
|
||||||
|
|
||||||
|
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
|
||||||
|
public List<VehicleBookingLevelOpinion> LevelOpinions { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho VehicleBooking.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||||
|
//
|
||||||
|
// Mỗi row = 1 (VehicleBooking × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||||
|
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||||
|
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||||
|
//
|
||||||
|
// UNIQUE composite (VehicleBookingId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||||
|
// FK Cascade VehicleBooking (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||||
|
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||||
|
public class VehicleBookingLevelOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid VehicleBookingId { get; set; }
|
||||||
|
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||||
|
|
||||||
|
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||||
|
public DateTime SignedAt { get; set; }
|
||||||
|
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||||
|
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||||
|
|
||||||
|
public VehicleBooking? VehicleBooking { get; set; }
|
||||||
|
public ApprovalWorkflowLevel? Level { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
namespace SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — Sequence generator dùng chung cho mã đơn từ (MaDonTu)
|
||||||
|
// của 4 WorkflowApps module (Leave / OT / Travel / VehicleBooking).
|
||||||
|
// Mirror ProposalCodeSequence pattern (Prefix string PK + LastSeq atomic).
|
||||||
|
//
|
||||||
|
// Prefix-keyed per module per năm, vd:
|
||||||
|
// "DT/LR/2026" (Leave) → "DT/LR/2026/001" → "DT/LR/2026/002" → ...
|
||||||
|
// "DT/OT/2026" (OT)
|
||||||
|
// "DT/CT/2026" (Travel — Công tác)
|
||||||
|
// "DX/XE/2026" (VehicleBooking — Đặt xe)
|
||||||
|
// LastSeq reset đầu năm tự nhiên (key Prefix mới). Update atomic qua
|
||||||
|
// SERIALIZABLE transaction trong CodeGen service.
|
||||||
|
public class WorkflowAppCodeSequence
|
||||||
|
{
|
||||||
|
public string Prefix { get; set; } = string.Empty; // PK — "DT/LR/2026"
|
||||||
|
public int LastSeq { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@ -115,6 +115,13 @@ public class ApplicationDbContext
|
|||||||
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
|
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
|
||||||
public DbSet<ItTicket> ItTickets => Set<ItTicket>();
|
public DbSet<ItTicket> ItTickets => Set<ItTicket>();
|
||||||
|
|
||||||
|
// Phase 11 P11-A (Mig 41) — LevelOpinions 4 module + shared CodeSequence.
|
||||||
|
public DbSet<LeaveRequestLevelOpinion> LeaveRequestLevelOpinions => Set<LeaveRequestLevelOpinion>();
|
||||||
|
public DbSet<OtRequestLevelOpinion> OtRequestLevelOpinions => Set<OtRequestLevelOpinion>();
|
||||||
|
public DbSet<TravelRequestLevelOpinion> TravelRequestLevelOpinions => Set<TravelRequestLevelOpinion>();
|
||||||
|
public DbSet<VehicleBookingLevelOpinion> VehicleBookingLevelOpinions => Set<VehicleBookingLevelOpinion>();
|
||||||
|
public DbSet<WorkflowAppCodeSequence> WorkflowAppCodeSequences => Set<WorkflowAppCodeSequence>();
|
||||||
|
|
||||||
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
||||||
public DbSet<Attendance> Attendances => Set<Attendance>();
|
public DbSet<Attendance> Attendances => Set<Attendance>();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho LeaveRequest.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||||
|
public class LeaveRequestLevelOpinionConfiguration : IEntityTypeConfiguration<LeaveRequestLevelOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<LeaveRequestLevelOpinion> e)
|
||||||
|
{
|
||||||
|
e.ToTable("LeaveRequestLevelOpinions");
|
||||||
|
|
||||||
|
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.LeaveRequest)
|
||||||
|
.WithMany(p => p.LevelOpinions)
|
||||||
|
.HasForeignKey(x => x.LeaveRequestId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Level)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.LeaveRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho OtRequest.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||||
|
public class OtRequestLevelOpinionConfiguration : IEntityTypeConfiguration<OtRequestLevelOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<OtRequestLevelOpinion> e)
|
||||||
|
{
|
||||||
|
e.ToTable("OtRequestLevelOpinions");
|
||||||
|
|
||||||
|
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.OtRequest)
|
||||||
|
.WithMany(p => p.LevelOpinions)
|
||||||
|
.HasForeignKey(x => x.OtRequestId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Level)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.OtRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho TravelRequest.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||||
|
public class TravelRequestLevelOpinionConfiguration : IEntityTypeConfiguration<TravelRequestLevelOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TravelRequestLevelOpinion> e)
|
||||||
|
{
|
||||||
|
e.ToTable("TravelRequestLevelOpinions");
|
||||||
|
|
||||||
|
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.TravelRequest)
|
||||||
|
.WithMany(p => p.LevelOpinions)
|
||||||
|
.HasForeignKey(x => x.TravelRequestId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Level)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.TravelRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho VehicleBooking.
|
||||||
|
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||||
|
public class VehicleBookingLevelOpinionConfiguration : IEntityTypeConfiguration<VehicleBookingLevelOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<VehicleBookingLevelOpinion> e)
|
||||||
|
{
|
||||||
|
e.ToTable("VehicleBookingLevelOpinions");
|
||||||
|
|
||||||
|
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.VehicleBooking)
|
||||||
|
.WithMany(p => p.LevelOpinions)
|
||||||
|
.HasForeignKey(x => x.VehicleBookingId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Level)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.VehicleBookingId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// EF Mig 41 P11-A (Phase 11) — Sequence generator dùng chung cho mã đơn từ 4
|
||||||
|
// WorkflowApps module (Leave/OT/Travel/VehicleBooking). Mirror
|
||||||
|
// ProposalCodeSequenceConfiguration pattern. PK = Prefix (string), LastSeq atomic.
|
||||||
|
public class WorkflowAppCodeSequenceConfiguration : IEntityTypeConfiguration<WorkflowAppCodeSequence>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<WorkflowAppCodeSequence> e)
|
||||||
|
{
|
||||||
|
e.ToTable("WorkflowAppCodeSequences");
|
||||||
|
|
||||||
|
e.HasKey(x => x.Prefix);
|
||||||
|
e.Property(x => x.Prefix).HasMaxLength(20); // "DT/LR/2026" max ~10 chars OK
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -137,6 +137,13 @@ public static class DbInitializer
|
|||||||
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
|
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
|
||||||
await SeedSampleProposalWorkflowV2Async(db, userManager, logger);
|
await SeedSampleProposalWorkflowV2Async(db, userManager, logger);
|
||||||
|
|
||||||
|
// Phase 11 P11-A (S42) — Sample workflow V2 cho 4 WorkflowApps module.
|
||||||
|
// INFRASTRUCTURE seed NOT gated DemoSeed (gotcha #51) — mirror Proposal.
|
||||||
|
await SeedSampleLeaveRequestWorkflowV2Async(db, userManager, logger);
|
||||||
|
await SeedSampleOtRequestWorkflowV2Async(db, userManager, logger);
|
||||||
|
await SeedSampleTravelRequestWorkflowV2Async(db, userManager, logger);
|
||||||
|
await SeedSampleVehicleBookingWorkflowV2Async(db, userManager, logger);
|
||||||
|
|
||||||
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,6 +301,202 @@ public static class DbInitializer
|
|||||||
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Proposal: QT-DX-V2-001 v01");
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Proposal: QT-DX-V2-001 v01");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 11 P11-A (S42) — Sample workflow V2 cho 4 WorkflowApps module để UAT
|
||||||
|
// test approval ngay (Leave/OT/Travel/Vehicle). Cookie-cutter mirror
|
||||||
|
// SeedSampleProposalWorkflowV2Async CHÍNH XÁC. INFRASTRUCTURE seed NOT gated
|
||||||
|
// DemoSeed (gotcha #51). Idempotent — skip nếu đã có ANY workflow cùng type.
|
||||||
|
private static async Task SeedSampleLeaveRequestWorkflowV2Async(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var hasAny = await db.ApprovalWorkflows
|
||||||
|
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.LeaveRequest);
|
||||||
|
if (hasAny) return;
|
||||||
|
|
||||||
|
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
|
||||||
|
if (approver is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("SeedSampleLeaveRequestWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-NP-V2-001",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.LeaveRequest,
|
||||||
|
Name = "Quy trình duyệt đơn nghỉ phép (mẫu)",
|
||||||
|
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp duyệt",
|
||||||
|
DepartmentId = ccmDept?.Id,
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp 1",
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
step.Levels.Add(level);
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for LeaveRequest: QT-NP-V2-001 v01");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedSampleOtRequestWorkflowV2Async(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var hasAny = await db.ApprovalWorkflows
|
||||||
|
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.OtRequest);
|
||||||
|
if (hasAny) return;
|
||||||
|
|
||||||
|
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
|
||||||
|
if (approver is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("SeedSampleOtRequestWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-OT-V2-001",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.OtRequest,
|
||||||
|
Name = "Quy trình duyệt đơn OT (mẫu)",
|
||||||
|
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp duyệt",
|
||||||
|
DepartmentId = ccmDept?.Id,
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp 1",
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
step.Levels.Add(level);
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for OtRequest: QT-OT-V2-001 v01");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedSampleTravelRequestWorkflowV2Async(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var hasAny = await db.ApprovalWorkflows
|
||||||
|
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.TravelRequest);
|
||||||
|
if (hasAny) return;
|
||||||
|
|
||||||
|
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
|
||||||
|
if (approver is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("SeedSampleTravelRequestWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-CT-V2-001",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.TravelRequest,
|
||||||
|
Name = "Quy trình duyệt đơn công tác (mẫu)",
|
||||||
|
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp duyệt",
|
||||||
|
DepartmentId = ccmDept?.Id,
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp 1",
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
step.Levels.Add(level);
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for TravelRequest: QT-CT-V2-001 v01");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedSampleVehicleBookingWorkflowV2Async(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var hasAny = await db.ApprovalWorkflows
|
||||||
|
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.VehicleBooking);
|
||||||
|
if (hasAny) return;
|
||||||
|
|
||||||
|
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
|
||||||
|
if (approver is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("SeedSampleVehicleBookingWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-XE-V2-001",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.VehicleBooking,
|
||||||
|
Name = "Quy trình duyệt đặt xe (mẫu)",
|
||||||
|
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp duyệt",
|
||||||
|
DepartmentId = ccmDept?.Id,
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp 1",
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
step.Levels.Add(level);
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for VehicleBooking: QT-XE-V2-001 v01");
|
||||||
|
}
|
||||||
|
|
||||||
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
||||||
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
||||||
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,275 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class WireWorkflowAppsApprovalV2 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "VehicleBookings",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "TravelRequests",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "OtRequests",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "LeaveRequests",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "LeaveRequestLevelOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
LeaveRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_LeaveRequestLevelOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_LeaveRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||||
|
column: x => x.ApprovalWorkflowLevelId,
|
||||||
|
principalTable: "ApprovalWorkflowLevels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_LeaveRequestLevelOpinions_LeaveRequests_LeaveRequestId",
|
||||||
|
column: x => x.LeaveRequestId,
|
||||||
|
principalTable: "LeaveRequests",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OtRequestLevelOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
OtRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OtRequestLevelOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OtRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||||
|
column: x => x.ApprovalWorkflowLevelId,
|
||||||
|
principalTable: "ApprovalWorkflowLevels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OtRequestLevelOpinions_OtRequests_OtRequestId",
|
||||||
|
column: x => x.OtRequestId,
|
||||||
|
principalTable: "OtRequests",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TravelRequestLevelOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
TravelRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TravelRequestLevelOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TravelRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||||
|
column: x => x.ApprovalWorkflowLevelId,
|
||||||
|
principalTable: "ApprovalWorkflowLevels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TravelRequestLevelOpinions_TravelRequests_TravelRequestId",
|
||||||
|
column: x => x.TravelRequestId,
|
||||||
|
principalTable: "TravelRequests",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VehicleBookingLevelOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
VehicleBookingId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VehicleBookingLevelOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VehicleBookingLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||||
|
column: x => x.ApprovalWorkflowLevelId,
|
||||||
|
principalTable: "ApprovalWorkflowLevels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VehicleBookingLevelOpinions_VehicleBookings_VehicleBookingId",
|
||||||
|
column: x => x.VehicleBookingId,
|
||||||
|
principalTable: "VehicleBookings",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WorkflowAppCodeSequences",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Prefix = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
|
LastSeq = table.Column<int>(type: "int", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WorkflowAppCodeSequences", x => x.Prefix);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_LeaveRequestLevelOpinions_ApprovalWorkflowLevelId",
|
||||||
|
table: "LeaveRequestLevelOpinions",
|
||||||
|
column: "ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_LeaveRequestLevelOpinions_LeaveRequestId_ApprovalWorkflowLevelId",
|
||||||
|
table: "LeaveRequestLevelOpinions",
|
||||||
|
columns: new[] { "LeaveRequestId", "ApprovalWorkflowLevelId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OtRequestLevelOpinions_ApprovalWorkflowLevelId",
|
||||||
|
table: "OtRequestLevelOpinions",
|
||||||
|
column: "ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OtRequestLevelOpinions_OtRequestId_ApprovalWorkflowLevelId",
|
||||||
|
table: "OtRequestLevelOpinions",
|
||||||
|
columns: new[] { "OtRequestId", "ApprovalWorkflowLevelId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TravelRequestLevelOpinions_ApprovalWorkflowLevelId",
|
||||||
|
table: "TravelRequestLevelOpinions",
|
||||||
|
column: "ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TravelRequestLevelOpinions_TravelRequestId_ApprovalWorkflowLevelId",
|
||||||
|
table: "TravelRequestLevelOpinions",
|
||||||
|
columns: new[] { "TravelRequestId", "ApprovalWorkflowLevelId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VehicleBookingLevelOpinions_ApprovalWorkflowLevelId",
|
||||||
|
table: "VehicleBookingLevelOpinions",
|
||||||
|
column: "ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VehicleBookingLevelOpinions_VehicleBookingId_ApprovalWorkflowLevelId",
|
||||||
|
table: "VehicleBookingLevelOpinions",
|
||||||
|
columns: new[] { "VehicleBookingId", "ApprovalWorkflowLevelId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "LeaveRequestLevelOpinions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OtRequestLevelOpinions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TravelRequestLevelOpinions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VehicleBookingLevelOpinions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WorkflowAppCodeSequences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "VehicleBookings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "TravelRequests");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "OtRequests");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RejectedFromStatus",
|
||||||
|
table: "LeaveRequests");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3676,6 +3676,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("nvarchar(1000)");
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedFromStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("RequesterFullName")
|
b.Property<string>("RequesterFullName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@ -3709,6 +3712,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("LeaveRequests", (string)null);
|
b.ToTable("LeaveRequests", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApprovalWorkflowLevelId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid>("LeaveRequestId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SignedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SignedByFullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SignedByUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
b.HasIndex("LeaveRequestId", "ApprovalWorkflowLevelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("LeaveRequestLevelOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -3932,6 +3993,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("nvarchar(1000)");
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedFromStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("RequesterFullName")
|
b.Property<string>("RequesterFullName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@ -3965,6 +4029,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("OtRequests", (string)null);
|
b.ToTable("OtRequests", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApprovalWorkflowLevelId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<Guid>("OtRequestId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SignedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SignedByFullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SignedByUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
b.HasIndex("OtRequestId", "ApprovalWorkflowLevelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("OtRequestLevelOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -4236,6 +4358,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("nvarchar(1000)");
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedFromStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("RequesterFullName")
|
b.Property<string>("RequesterFullName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@ -4269,6 +4394,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("TravelRequests", (string)null);
|
b.ToTable("TravelRequests", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApprovalWorkflowLevelId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SignedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SignedByFullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SignedByUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("TravelRequestId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
b.HasIndex("TravelRequestId", "ApprovalWorkflowLevelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TravelRequestLevelOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -4317,6 +4500,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("nvarchar(1000)");
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedFromStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("RequesterFullName")
|
b.Property<string>("RequesterFullName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@ -4361,6 +4547,81 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("VehicleBookings", (string)null);
|
b.ToTable("VehicleBookings", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("ApprovalWorkflowLevelId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeletedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SignedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SignedByFullName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SignedByUserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UpdatedBy")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("VehicleBookingId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
b.HasIndex("VehicleBookingId", "ApprovalWorkflowLevelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("VehicleBookingLevelOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.WorkflowAppCodeSequence", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Prefix")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<int>("LastSeq")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Prefix");
|
||||||
|
|
||||||
|
b.ToTable("WorkflowAppCodeSequences", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -5603,6 +5864,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowLevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Office.LeaveRequest", "LeaveRequest")
|
||||||
|
.WithMany("LevelOpinions")
|
||||||
|
.HasForeignKey("LeaveRequestId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("LeaveRequest");
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room")
|
b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room")
|
||||||
@ -5625,6 +5905,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Booking");
|
b.Navigation("Booking");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowLevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Office.OtRequest", "OtRequest")
|
||||||
|
.WithMany("LevelOpinions")
|
||||||
|
.HasForeignKey("OtRequestId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
|
||||||
|
b.Navigation("OtRequest");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
|
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
|
||||||
@ -5655,6 +5954,44 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Proposal");
|
b.Navigation("Proposal");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowLevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Office.TravelRequest", "TravelRequest")
|
||||||
|
.WithMany("LevelOpinions")
|
||||||
|
.HasForeignKey("TravelRequestId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
|
||||||
|
b.Navigation("TravelRequest");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowLevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Office.VehicleBooking", "VehicleBooking")
|
||||||
|
.WithMany("LevelOpinions")
|
||||||
|
.HasForeignKey("VehicleBookingId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
|
||||||
|
b.Navigation("VehicleBooking");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
|
||||||
@ -5889,11 +6226,21 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Permissions");
|
b.Navigation("Permissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Attendees");
|
b.Navigation("Attendees");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Attachments");
|
b.Navigation("Attachments");
|
||||||
@ -5901,6 +6248,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("LevelOpinions");
|
b.Navigation("LevelOpinions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Approvals");
|
b.Navigation("Approvals");
|
||||||
|
|||||||
@ -0,0 +1,443 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Office;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Office;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// Phase 11 P11-A Wave 4 (S42 2026-05-30) — test-after ApproveV2 wire WorkflowApps.
|
||||||
|
// Critical-algo state machine + UPSERT invariant cho LeaveOtApprovalFeatures.cs
|
||||||
|
// (LeaveRequest đầy đủ 8 case + OtRequest smoke 1 case).
|
||||||
|
//
|
||||||
|
// Handlers là CQRS MediatR — inject IApplicationDbContext + ICurrentUser + IDateTime
|
||||||
|
// trực tiếp (không qua service). Khác ContractWorkflowServiceApproveV2Tests (service
|
||||||
|
// wire 6 dep) — đây nhẹ hơn, instantiate handler + gọi Handle().
|
||||||
|
//
|
||||||
|
// Mirror LeaveOtApprovalFeatures.cs:250 ApproveLeaveRequestHandler. Status state:
|
||||||
|
// Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5.
|
||||||
|
// LevelOpinion UNIQUE composite (LeaveRequestId, ApprovalWorkflowLevelId).
|
||||||
|
public class WorkflowAppApproveV2Tests
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedNow = new(2026, 5, 30, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static (IdentityFixture fix, TestApplicationDbContext db, FixedDateTime clock) NewCtx()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var clock = new FixedDateTime(FixedNow);
|
||||||
|
return (fix, db, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestCurrentUser AsUser(User u, params string[] roles)
|
||||||
|
=> new() { UserId = u.Id, FullName = u.FullName, Roles = roles ?? Array.Empty<string>() };
|
||||||
|
|
||||||
|
// Seed 1 Bước × N Cấp LeaveRequest workflow. Trả về list Levels theo Order asc.
|
||||||
|
private static async Task<(ApprovalWorkflow wf, List<ApprovalWorkflowLevel> levels)> SeedLeaveWorkflowAsync(
|
||||||
|
TestApplicationDbContext db, params Guid[] approverUserIds)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-LR-001",
|
||||||
|
Version = 1,
|
||||||
|
Name = "Quy trình nghỉ phép test",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.LeaveRequest,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước 1",
|
||||||
|
};
|
||||||
|
var levels = new List<ApprovalWorkflowLevel>();
|
||||||
|
for (var i = 0; i < approverUserIds.Length; i++)
|
||||||
|
{
|
||||||
|
levels.Add(new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = i + 1,
|
||||||
|
ApproverUserId = approverUserIds[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.AddRange(levels);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (wf, levels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LeaveRequest BuildLeave(
|
||||||
|
Guid requesterId,
|
||||||
|
Guid? workflowId,
|
||||||
|
WorkflowAppStatus status,
|
||||||
|
int? currentLevel)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
RequesterUserId = requesterId,
|
||||||
|
RequesterFullName = "Người tạo",
|
||||||
|
LeaveTypeId = Guid.NewGuid(),
|
||||||
|
StartDate = FixedNow.Date,
|
||||||
|
EndDate = FixedNow.Date.AddDays(2),
|
||||||
|
NumDays = 3,
|
||||||
|
Reason = "Nghỉ việc riêng",
|
||||||
|
Status = status,
|
||||||
|
ApprovalWorkflowId = workflowId,
|
||||||
|
CurrentApprovalLevelOrder = currentLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Case 1: Submit happy path ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_FromNhap_AdvancesToDaGuiDuyet_GeneratesMaDonTu()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c1@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-c1@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.Nhap, currentLevel: null);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new SubmitLeaveRequestHandler(db, AsUser(requester), clock);
|
||||||
|
await handler.Handle(new SubmitLeaveRequestCommand(leave.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
leave.RejectedFromStatus.Should().BeNull();
|
||||||
|
leave.MaDonTu.Should().NotBeNullOrEmpty();
|
||||||
|
leave.MaDonTu.Should().Be("DT/LR/2026/001", "prefix DT/LR + năm clock + seq D3");
|
||||||
|
|
||||||
|
var seq = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "DT/LR/2026");
|
||||||
|
seq.LastSeq.Should().Be(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 2: Submit guards ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_WhenStatusNotNhapOrTraLai_ThrowsConflict()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c2a@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-c2a@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new SubmitLeaveRequestHandler(db, AsUser(requester), clock);
|
||||||
|
var act = async () => await handler.Handle(new SubmitLeaveRequestCommand(leave.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Nháp hoặc Trả lại*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_WhenApprovalWorkflowIdNull_ThrowsConflict()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c2b@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, workflowId: null, WorkflowAppStatus.Nhap, currentLevel: null);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new SubmitLeaveRequestHandler(db, AsUser(requester), clock);
|
||||||
|
var act = async () => await handler.Handle(new SubmitLeaveRequestCommand(leave.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Chưa chọn quy trình duyệt*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 3: Approve advance (2-level, cấp 1) ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_FirstLevel_TwoLevel_AdvancesAndCreatesOneOpinion()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c3@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c3@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var ap2 = await fix.CreateUserAsync("ap2-c3@test.local", "Approver 2", null, Array.Empty<string>());
|
||||||
|
var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new ApproveLeaveRequestHandler(db, AsUser(ap1), clock);
|
||||||
|
await handler.Handle(new ApproveLeaveRequestCommand(leave.Id, "đồng ý"), CancellationToken.None);
|
||||||
|
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "còn Cấp 2 chưa terminal");
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().Be(2);
|
||||||
|
|
||||||
|
var opinions = await db.LeaveRequestLevelOpinions.Where(o => o.LeaveRequestId == leave.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1);
|
||||||
|
opinions[0].ApprovalWorkflowLevelId.Should().Be(levels[0].Id, "slot Cấp 1");
|
||||||
|
opinions[0].Comment.Should().Be("đồng ý");
|
||||||
|
opinions[0].SignedByUserId.Should().Be(ap1.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 4: Approve terminal (cấp cuối) ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_LastLevel_TransitionsToDaDuyet_ClearsCurrentLevel()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c4@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c4@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var ap2 = await fix.CreateUserAsync("ap2-c4@test.local", "Approver 2", null, Array.Empty<string>());
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
|
||||||
|
|
||||||
|
// pin tại Cấp cuối (2)
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 2);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new ApproveLeaveRequestHandler(db, AsUser(ap2), clock);
|
||||||
|
await handler.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt cuối"), CancellationToken.None);
|
||||||
|
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet, "Cấp cuối → terminal");
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
|
||||||
|
var opinions = await db.LeaveRequestLevelOpinions.Where(o => o.LeaveRequestId == leave.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1, "1 row cho slot Cấp 2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 5: Approve UPSERT invariant (re-sign same level) ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_SameLevelTwice_AdminReSign_DoesNotDuplicateRow_UpdatesLatestWrite()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c5@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c5@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var ap2 = await fix.CreateUserAsync("ap2-c5@test.local", "Approver 2", null, Array.Empty<string>());
|
||||||
|
var admin = await fix.CreateUserAsync("admin-c5@test.local", "Quản trị", null, new[] { "Admin" });
|
||||||
|
var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id, ap2.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// 1st approve Cấp 1 bởi ap1 → tạo row, advance lên Cấp 2
|
||||||
|
await new ApproveLeaveRequestHandler(db, AsUser(ap1), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ý kiến gốc"), CancellationToken.None);
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().Be(2);
|
||||||
|
|
||||||
|
// Admin override re-sign Cấp 1: pin pointer trở lại Cấp 1 + admin duyệt lại
|
||||||
|
leave.CurrentApprovalLevelOrder = 1;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
await new ApproveLeaveRequestHandler(db, AsUser(admin, "Admin"), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, "admin ký lại"), CancellationToken.None);
|
||||||
|
|
||||||
|
var opinions = await db.LeaveRequestLevelOpinions
|
||||||
|
.Where(o => o.LeaveRequestId == leave.Id && o.ApprovalWorkflowLevelId == levels[0].Id)
|
||||||
|
.ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1, "UNIQUE composite — KHÔNG tạo row thứ 2");
|
||||||
|
opinions[0].Comment.Should().Be("admin ký lại", "latest-write-wins");
|
||||||
|
opinions[0].SignedByUserId.Should().Be(admin.Id, "signer cập nhật người ký mới nhất");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 6: Approve forbidden (outsider non-admin) ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_OutsiderNonAdmin_ThrowsForbidden_StateUnchanged()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c6@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c6@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var outsider = await fix.CreateUserAsync("out-c6@test.local", "Outsider", null, Array.Empty<string>());
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new ApproveLeaveRequestHandler(db, AsUser(outsider), clock);
|
||||||
|
var act = async () => await handler.Handle(new ApproveLeaveRequestCommand(leave.Id, "thử duyệt"), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>().WithMessage("*Không phải người duyệt*");
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "guard chặn trước mutate");
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
(await db.LeaveRequestLevelOpinions.CountAsync(o => o.LeaveRequestId == leave.Id))
|
||||||
|
.Should().Be(0, "không tạo opinion khi bị chặn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 7: Approve empty comment → placeholder ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_EmptyComment_StoresPlaceholder()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c7@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c7@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var (wf, levels) = await SeedLeaveWorkflowAsync(db, ap1.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await new ApproveLeaveRequestHandler(db, AsUser(ap1), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, " "), CancellationToken.None);
|
||||||
|
|
||||||
|
var opinion = await db.LeaveRequestLevelOpinions
|
||||||
|
.FirstAsync(o => o.LeaveRequestId == leave.Id && o.ApprovalWorkflowLevelId == levels[0].Id);
|
||||||
|
opinion.Comment.Should().Be("(duyệt — không ý kiến)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 8a: Reject → TuChoi ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_FromDaGuiDuyet_TransitionsToTuChoi_ClearsCurrentLevel()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c8a@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c8a@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await new RejectLeaveRequestHandler(db, AsUser(ap1), clock)
|
||||||
|
.Handle(new RejectLeaveRequestCommand(leave.Id, "không duyệt"), CancellationToken.None);
|
||||||
|
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.TuChoi);
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 8b: Return → TraLai + RejectedFromStatus ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Return_FromDaGuiDuyet_TransitionsToTraLai_SetsRejectedFromStatus()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-c8b@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var ap1 = await fix.CreateUserAsync("ap1-c8b@test.local", "Approver 1", null, Array.Empty<string>());
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, ap1.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await new ReturnLeaveRequestHandler(db, AsUser(ap1), clock)
|
||||||
|
.Handle(new ReturnLeaveRequestCommand(leave.Id, "sửa lại"), CancellationToken.None);
|
||||||
|
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.TraLai);
|
||||||
|
leave.RejectedFromStatus.Should().Be(WorkflowAppStatus.DaGuiDuyet);
|
||||||
|
leave.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Smoke OtRequest: mirror cookie-cutter — terminal happy path ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OtRequest_Submit_Then_Approve_SingleLevel_ReachesDaDuyet()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-ot@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-ot@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-OT-001",
|
||||||
|
Version = 1,
|
||||||
|
Name = "Quy trình OT test",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.OtRequest,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước 1",
|
||||||
|
};
|
||||||
|
var lvl = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.Add(lvl);
|
||||||
|
|
||||||
|
var ot = new OtRequest
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
RequesterUserId = requester.Id,
|
||||||
|
RequesterFullName = "Người tạo",
|
||||||
|
OtDate = FixedNow.Date,
|
||||||
|
StartTime = TimeSpan.FromHours(18),
|
||||||
|
EndTime = TimeSpan.FromHours(21),
|
||||||
|
Hours = 3,
|
||||||
|
Reason = "Tăng ca giao hàng",
|
||||||
|
Status = WorkflowAppStatus.Nhap,
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
};
|
||||||
|
db.OtRequests.Add(ot);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await new SubmitOtRequestHandler(db, AsUser(requester), clock)
|
||||||
|
.Handle(new SubmitOtRequestCommand(ot.Id), CancellationToken.None);
|
||||||
|
ot.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
|
||||||
|
ot.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
ot.MaDonTu.Should().Be("DT/OT/2026/001");
|
||||||
|
|
||||||
|
await new ApproveOtRequestHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveOtRequestCommand(ot.Id, null), CancellationToken.None);
|
||||||
|
ot.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
||||||
|
ot.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
|
||||||
|
var opinions = await db.OtRequestLevelOpinions.Where(o => o.OtRequestId == ot.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1);
|
||||||
|
opinions[0].Comment.Should().Be("(duyệt — không ý kiến)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user