[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

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:
pqhuy1987
2026-05-30 09:44:00 +07:00
parent ad1dea9349
commit e7b66cd52b
39 changed files with 10604 additions and 22 deletions

View File

@ -66,7 +66,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
## 🧠 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).
- **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).
- **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)
- **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]`.
- **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.

View File

@ -70,6 +70,7 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
## 📅 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 (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]`.

View File

@ -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: 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`
## ⏱️ 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 (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.
---

View File

@ -36,6 +36,7 @@ import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
@ -96,6 +97,7 @@ function App() {
<Route path="/proposals/new" element={<ProposalCreatePage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
<Route path="/workflow-apps/:kind/:id" element={<WorkflowAppDetailPage />} />
<Route path="/it-tickets" element={<ItTicketsPage />} />
<Route path="/attendance" element={<MyAttendancePage />} />
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />

View 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 ý 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>
)
}

View File

@ -1,9 +1,9 @@
// 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.
// File MIRROR SHA256 identical fe-user counterpart.
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 { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api'
@ -80,6 +80,7 @@ const ICON_MAP: Record<Kind, any> = {
export function WorkflowAppsListPage() {
const { kind = 'leave' } = useParams<{ kind: Kind }>()
const navigate = useNavigate()
const config = KIND_CONFIG[kind as Kind]
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
@ -99,11 +100,6 @@ export function WorkflowAppsListPage() {
<div className="space-y-4">
<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">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
@ -131,7 +127,11 @@ export function WorkflowAppsListPage() {
</tr>
)}
{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) => (
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
))}

View File

@ -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',
}
// 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> {
items: T[]
total: number

View File

@ -29,6 +29,7 @@ import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
@ -79,6 +80,7 @@ function App() {
<Route path="/proposals/new" element={<ProposalCreatePage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
<Route path="/workflow-apps/:kind/:id" element={<WorkflowAppDetailPage />} />
<Route path="/it-tickets" element={<ItTicketsPage />} />
<Route path="/attendance" element={<MyAttendancePage />} />
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />

View 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 ý 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>
)
}

View File

@ -1,9 +1,9 @@
// 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.
// File MIRROR SHA256 identical fe-user counterpart.
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 { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api'
@ -80,6 +80,7 @@ const ICON_MAP: Record<Kind, any> = {
export function WorkflowAppsListPage() {
const { kind = 'leave' } = useParams<{ kind: Kind }>()
const navigate = useNavigate()
const config = KIND_CONFIG[kind as Kind]
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
@ -99,11 +100,6 @@ export function WorkflowAppsListPage() {
<div className="space-y-4">
<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">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
@ -131,7 +127,11 @@ export function WorkflowAppsListPage() {
</tr>
)}
{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) => (
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
))}

View File

@ -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',
}
// 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> {
items: T[]
total: number

View File

@ -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)
=> 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]
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestCommand 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);
}

View File

@ -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)
=> 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]
public async Task<IActionResult> Create([FromBody] CreateOtRequestCommand 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);
}

View File

@ -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)
=> 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]
public async Task<IActionResult> Create([FromBody] CreateTravelRequestCommand 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);
}

View File

@ -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)
=> 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]
public async Task<IActionResult> Create([FromBody] CreateVehicleBookingCommand 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);
}

View File

@ -128,6 +128,14 @@ public interface IApplicationDbContext
DbSet<VehicleBooking> VehicleBookings { 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.
DbSet<Attendance> Attendances { get; }

View File

@ -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);
}
}

View File

@ -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}";
}
}

View File

@ -55,6 +55,7 @@ public enum ApprovalWorkflowApplicableType
OtRequest = 6, // G-O4 — Đơn OT
VehicleBooking = 7, // G-O5 — Đặt xe công
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.

View File

@ -19,4 +19,7 @@ public class LeaveRequest : AuditableEntity
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; } // pin ApplicableType=5
public int? CurrentApprovalLevelOrder { get; set; }
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
public List<LeaveRequestLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -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; }
}

View File

@ -19,4 +19,7 @@ public class OtRequest : AuditableEntity
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
public List<OtRequestLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -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; }
}

View File

@ -18,4 +18,7 @@ public class TravelRequest : AuditableEntity
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
public List<TravelRequestLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -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; }
}

View File

@ -20,4 +20,7 @@ public class VehicleBooking : AuditableEntity
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
public WorkflowAppStatus? RejectedFromStatus { get; set; } // smart return tracking (mirror Proposal)
public List<VehicleBookingLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -115,6 +115,13 @@ public class ApplicationDbContext
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
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.
public DbSet<Attendance> Attendances => Set<Attendance>();

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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
}
}

View File

@ -137,6 +137,13 @@ public static class DbInitializer
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
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);
}
@ -294,6 +301,202 @@ public static class DbInitializer
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:
// 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)

View File

@ -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");
}
}
}

View File

@ -3676,6 +3676,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -3709,6 +3712,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.Property<Guid>("Id")
@ -3932,6 +3993,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -3965,6 +4029,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.Property<Guid>("Id")
@ -4236,6 +4358,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -4269,6 +4394,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.Property<Guid>("Id")
@ -4317,6 +4500,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -4361,6 +4547,81 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.Property<Guid>("Id")
@ -5603,6 +5864,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.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 =>
{
b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room")
@ -5625,6 +5905,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
@ -5655,6 +5954,44 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
@ -5889,11 +6226,21 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Permissions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b =>
{
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.Navigation("Attendees");
});
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b =>
{
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
{
b.Navigation("Attachments");
@ -5901,6 +6248,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
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 =>
{
b.Navigation("Approvals");

View File

@ -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)");
}
}
}