[CLAUDE] Workflow: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Phase 11 P11-A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s
Wire full approval workflow V2 cho Leave/OT/Travel/Vehicle — cookie-cutter
mirror Proposal (Mig 38). Trước đây skeleton Phase 1 (Create+List), giờ
ApproveV2 advance-level + UPSERT LevelOpinion + atomic codegen.
Schema (Mig 41 WireWorkflowAppsApprovalV2 — 84→89 tables, pure additive):
- 4 bảng {Leave,Ot,Travel,Vehicle}LevelOpinions (UNIQUE composite + Cascade
parent + Restrict Level — mirror ProposalLevelOpinion)
- 1 bảng WorkflowAppCodeSequences (shared atomic MaDonTu, Prefix-keyed)
- 4 cột RejectedFromStatus (smart return tracking)
- enum ApprovalWorkflowApplicableType.TravelRequest = 9
Application (LeaveOt + TravelVehicle ApprovalFeatures.cs — 30 handler):
- GetById detail (Include LevelOpinions + JOIN Step/Level) · UpdateDraft
- Submit (gen MaDonTu + DaGuiDuyet + level=1, verify ApplicableType per module)
- Approve (verify actor==ApproverUserId OR Admin, UPSERT opinion latest-write-wins,
advance level OR terminal DaDuyet, empty comment → placeholder)
- Reject (→TuChoi) · Return (→TraLai + RejectedFromStatus)
Api: 4 controller +6 route mỗi cái (GET/{id}, PUT/{id}, submit/approve/reject/return)
Infra: DbInitializer seed 4 workflow V2 mẫu (QT-NP/OT/CT/XE-V2-001) → UAT test ngay
FE: WorkflowAppDetailPage.tsx declarative 4-kind (fe-admin+fe-user SHA256 identical)
— workflow status + opinion timeline + action buttons; gỡ banner skeleton + row nav
Tests: +11 WorkflowAppApproveV2Tests (130→141 PASS) — state machine + UPSERT
invariant + guards + codegen + forbidden + placeholder (Leave full + Ot smoke)
Verify: build 0 error · 141 test PASS · FE build ×2 · reviewer checklist
(ApplicableType per-module + cross-module DbSet + [Authorize] — no copy-paste bug)
Known-minor (unreachable): Reject/Return actor-check skip nếu CurrentApprovalLevelOrder
null — nhưng DaGuiDuyet luôn có set (defer hardening).
ItTicket KHÔNG đụng (kanban, no workflow V2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -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; }
|
||||
|
||||
|
||||
@ -0,0 +1,749 @@
|
||||
using System.Data;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 11 P11-A Wave 2a (S41/S42 2026-05-30) — wire ApproveV2 CQRS cho LeaveRequest + OtRequest.
|
||||
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema sẵn (Mig 41).
|
||||
// ApplicableType: LeaveRequest=5, OtRequest=6.
|
||||
//
|
||||
// Per module 7 handler: GetById detail · UpdateDraft · Submit · Approve(UPSERT+advance) ·
|
||||
// Reject(TuChoi) · Return(TraLai). 1 static helper GenerateMaDonTuAsync dùng chung.
|
||||
//
|
||||
// CONSTRAINT: KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). KHÔNG đụng
|
||||
// Travel/Vehicle (Wave 2b song song). MaDonTu gen lần Submit đầu (null→gen).
|
||||
|
||||
// ===== Shared CodeGen helper (dùng chung Leave + Ot trong file này) =====
|
||||
|
||||
internal static class WorkflowAppCodeGen
|
||||
{
|
||||
// Mirror ProposalFeatures.GenerateMaDeXuatAsync — Serializable tx + Prefix-keyed sequence.
|
||||
// Format: "{prefix}/{seq:D3}" — prefix vd "DT/LR/2026" → "DT/LR/2026/001".
|
||||
internal static async Task<string> GenerateMaDonTuAsync(
|
||||
IApplicationDbContext db, string prefix, int year, IDateTime clock, CancellationToken ct)
|
||||
{
|
||||
var fullPrefix = $"{prefix}/{year}";
|
||||
var dbContext = (DbContext)db;
|
||||
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == fullPrefix, ct);
|
||||
if (seq is null)
|
||||
{
|
||||
seq = new WorkflowAppCodeSequence { Prefix = fullPrefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||
db.WorkflowAppCodeSequences.Add(seq);
|
||||
}
|
||||
seq.LastSeq++;
|
||||
seq.UpdatedAt = clock.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return $"{fullPrefix}/{seq.LastSeq:D3}";
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MODULE A: LeaveRequest (ApplicableType=5, prefix "DT/LR")
|
||||
// =========================================================================
|
||||
|
||||
public record LeaveRequestLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record LeaveRequestDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
Guid LeaveTypeId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
decimal NumDays,
|
||||
string Reason,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<LeaveRequestLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetLeaveRequestByIdQuery(Guid Id) : IRequest<LeaveRequestDetailDto?>;
|
||||
|
||||
public class GetLeaveRequestByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetLeaveRequestByIdQuery, LeaveRequestDetailDto?>
|
||||
{
|
||||
public async Task<LeaveRequestDetailDto?> Handle(GetLeaveRequestByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new LeaveRequestLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new LeaveRequestDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId,
|
||||
p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status,
|
||||
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateLeaveRequestDraftCommand(
|
||||
Guid Id,
|
||||
Guid LeaveTypeId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
decimal NumDays,
|
||||
string Reason,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateLeaveRequestDraftValidator : AbstractValidator<UpdateLeaveRequestDraftCommand>
|
||||
{
|
||||
public UpdateLeaveRequestDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateLeaveRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<UpdateLeaveRequestDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateLeaveRequestDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
|
||||
}
|
||||
|
||||
p.LeaveTypeId = req.LeaveTypeId;
|
||||
p.StartDate = req.StartDate;
|
||||
p.EndDate = req.EndDate;
|
||||
p.NumDays = req.NumDays;
|
||||
p.Reason = req.Reason.Trim();
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitLeaveRequestCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<SubmitLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(SubmitLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/LR", clock.Now.Year, clock, ct);
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ApproveLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(ApproveLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.LeaveRequestLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.LeaveRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.LeaveRequestLevelOpinions.Add(new LeaveRequestLevelOpinion
|
||||
{
|
||||
LeaveRequestId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = cu.UserId.Value,
|
||||
SignedByFullName = cu.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = cu.UserId.Value;
|
||||
existing.SignedByFullName = cu.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = cu.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<RejectLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(RejectLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ReturnLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(ReturnLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MODULE B: OtRequest (ApplicableType=6, prefix "DT/OT")
|
||||
// =========================================================================
|
||||
|
||||
public record OtRequestLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record OtRequestDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
DateTime OtDate,
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
decimal Hours,
|
||||
string Reason,
|
||||
Guid? OtPolicyId,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<OtRequestLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetOtRequestByIdQuery(Guid Id) : IRequest<OtRequestDetailDto?>;
|
||||
|
||||
public class GetOtRequestByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetOtRequestByIdQuery, OtRequestDetailDto?>
|
||||
{
|
||||
public async Task<OtRequestDetailDto?> Handle(GetOtRequestByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new OtRequestLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new OtRequestDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||
p.OtDate, p.StartTime, p.EndTime, p.Hours, p.Reason, p.OtPolicyId, (int)p.Status,
|
||||
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateOtRequestDraftCommand(
|
||||
Guid Id,
|
||||
DateTime OtDate,
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
decimal Hours,
|
||||
string Reason,
|
||||
Guid? OtPolicyId,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateOtRequestDraftValidator : AbstractValidator<UpdateOtRequestDraftCommand>
|
||||
{
|
||||
public UpdateOtRequestDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.Hours).GreaterThan(0);
|
||||
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateOtRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<UpdateOtRequestDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateOtRequestDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
|
||||
}
|
||||
|
||||
p.OtDate = req.OtDate;
|
||||
p.StartTime = req.StartTime;
|
||||
p.EndTime = req.EndTime;
|
||||
p.Hours = req.Hours;
|
||||
p.Reason = req.Reason.Trim();
|
||||
p.OtPolicyId = req.OtPolicyId;
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitOtRequestCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<SubmitOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(SubmitOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/OT", clock.Now.Year, clock, ct);
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ApproveOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(ApproveOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.OtRequestLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.OtRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.OtRequestLevelOpinions.Add(new OtRequestLevelOpinion
|
||||
{
|
||||
OtRequestId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = cu.UserId.Value,
|
||||
SignedByFullName = cu.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = cu.UserId.Value;
|
||||
existing.SignedByFullName = cu.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = cu.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<RejectOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(RejectOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ReturnOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(ReturnOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,773 @@
|
||||
using System.Data;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 11 P11-A Wave 2b (S41 2026-05-30) — Wire ApproveV2 CQRS cho TravelRequest + VehicleBooking.
|
||||
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema Wave 1 (Mig 41) đã sẵn:
|
||||
// TravelRequestLevelOpinions + VehicleBookingLevelOpinions + WorkflowAppCodeSequences
|
||||
// parent +RejectedFromStatus + nav LevelOpinions.
|
||||
//
|
||||
// ApplicableType: TravelRequest=9 (Mig 41 mới) · VehicleBooking=7.
|
||||
// WorkflowAppStatus {Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5}.
|
||||
//
|
||||
// Endpoint per module (qua {Travel,Vehicle}*Controller):
|
||||
// GET /{id} — detail Include LevelOpinions + Workflow metadata
|
||||
// PUT /{id} — update draft (Nhap or TraLai only)
|
||||
// POST /{id}/submit — gen MaDonTu atomic + Status=DaGuiDuyet
|
||||
// POST /{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
|
||||
// POST /{id}/reject — Status=TuChoi terminal (no opinion sync)
|
||||
// POST /{id}/return — Status=TraLai + RejectedFromStatus=DaGuiDuyet (no opinion sync)
|
||||
//
|
||||
// Note: GetList + Create giữ nguyên trong WorkflowAppsFeatures.cs (KHÔNG sửa file đó).
|
||||
|
||||
// =========================================================================
|
||||
// REGION 1: TravelRequest — ApproveV2 wire
|
||||
// =========================================================================
|
||||
|
||||
public record TravelRequestLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record TravelRequestDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
string Destination,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int NumDays,
|
||||
string Purpose,
|
||||
decimal? EstimatedCost,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<TravelRequestLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetTravelRequestByIdQuery(Guid Id) : IRequest<TravelRequestDetailDto?>;
|
||||
|
||||
public class GetTravelRequestByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetTravelRequestByIdQuery, TravelRequestDetailDto?>
|
||||
{
|
||||
public async Task<TravelRequestDetailDto?> Handle(GetTravelRequestByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new TravelRequestLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new TravelRequestDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||
p.Destination, p.StartDate, p.EndDate, p.NumDays, p.Purpose, p.EstimatedCost,
|
||||
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateTravelRequestDraftCommand(
|
||||
Guid Id,
|
||||
string Destination,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int NumDays,
|
||||
string Purpose,
|
||||
decimal? EstimatedCost,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateTravelRequestDraftValidator : AbstractValidator<UpdateTravelRequestDraftCommand>
|
||||
{
|
||||
public UpdateTravelRequestDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateTravelRequestDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<UpdateTravelRequestDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateTravelRequestDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn công tác.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
|
||||
}
|
||||
|
||||
p.Destination = req.Destination.Trim();
|
||||
p.StartDate = req.StartDate;
|
||||
p.EndDate = req.EndDate;
|
||||
p.NumDays = req.NumDays;
|
||||
p.Purpose = req.Purpose.Trim();
|
||||
p.EstimatedCost = req.EstimatedCost;
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitTravelRequestCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<SubmitTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(SubmitTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
{
|
||||
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
|
||||
db, $"DT/CT/{clock.Now.Year}", clock, ct);
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ApproveTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(ApproveTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.TravelRequestLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.TravelRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.TravelRequestLevelOpinions.Add(new TravelRequestLevelOpinion
|
||||
{
|
||||
TravelRequestId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = currentUser.UserId.Value,
|
||||
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = currentUser.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = currentUser.UserId.Value;
|
||||
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = currentUser.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<RejectTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(RejectTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ReturnTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(ReturnTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 2: VehicleBooking — ApproveV2 wire
|
||||
// =========================================================================
|
||||
|
||||
public record VehicleBookingLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record VehicleBookingDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
string VehicleLicense,
|
||||
string? VehicleName,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Destination,
|
||||
string Purpose,
|
||||
string? DriverName,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<VehicleBookingLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetVehicleBookingByIdQuery(Guid Id) : IRequest<VehicleBookingDetailDto?>;
|
||||
|
||||
public class GetVehicleBookingByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetVehicleBookingByIdQuery, VehicleBookingDetailDto?>
|
||||
{
|
||||
public async Task<VehicleBookingDetailDto?> Handle(GetVehicleBookingByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new VehicleBookingLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new VehicleBookingDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||
p.VehicleLicense, p.VehicleName, p.StartAt, p.EndAt, p.Destination, p.Purpose, p.DriverName,
|
||||
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateVehicleBookingDraftCommand(
|
||||
Guid Id,
|
||||
string VehicleLicense,
|
||||
string? VehicleName,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Destination,
|
||||
string Purpose,
|
||||
string? DriverName,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateVehicleBookingDraftValidator : AbstractValidator<UpdateVehicleBookingDraftCommand>
|
||||
{
|
||||
public UpdateVehicleBookingDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateVehicleBookingDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<UpdateVehicleBookingDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateVehicleBookingDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn đặt xe.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
|
||||
}
|
||||
|
||||
p.VehicleLicense = req.VehicleLicense.Trim();
|
||||
p.VehicleName = req.VehicleName?.Trim();
|
||||
p.StartAt = req.StartAt;
|
||||
p.EndAt = req.EndAt;
|
||||
p.Destination = req.Destination.Trim();
|
||||
p.Purpose = req.Purpose.Trim();
|
||||
p.DriverName = req.DriverName?.Trim();
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitVehicleBookingCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<SubmitVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(SubmitVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
{
|
||||
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
|
||||
db, $"DX/XE/{clock.Now.Year}", clock, ct);
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ApproveVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(ApproveVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.VehicleBookingLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.VehicleBookingId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.VehicleBookingLevelOpinions.Add(new VehicleBookingLevelOpinion
|
||||
{
|
||||
VehicleBookingId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = currentUser.UserId.Value,
|
||||
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = currentUser.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = currentUser.UserId.Value;
|
||||
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = currentUser.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<RejectVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(RejectVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ReturnVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(ReturnVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shared CodeGen helper — Prefix-keyed WorkflowAppCodeSequences (SERIALIZABLE tx).
|
||||
// Mirror SubmitProposalHandler.GenerateMaDeXuatAsync. Format: {prefix}/{seq:D3}.
|
||||
// Travel prefix = "DT/CT/{year}"
|
||||
// Vehicle prefix = "DX/XE/{year}"
|
||||
// =========================================================================
|
||||
|
||||
internal static class TravelVehicleCodeGen
|
||||
{
|
||||
internal static async Task<string> GenerateMaDonTuAsync(
|
||||
IApplicationDbContext db, string prefix, IDateTime clock, CancellationToken ct)
|
||||
{
|
||||
var dbContext = (DbContext)db;
|
||||
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
|
||||
if (seq is null)
|
||||
{
|
||||
seq = new WorkflowAppCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||
db.WorkflowAppCodeSequences.Add(seq);
|
||||
}
|
||||
seq.LastSeq++;
|
||||
seq.UpdatedAt = clock.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return $"{prefix}/{seq.LastSeq:D3}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user