[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

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