[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

@ -14,10 +14,63 @@ public class LeaveRequestsController(IMediator mediator) : ControllerBase
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetLeaveRequestsQuery(status, requesterUserId, page, pageSize)));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var dto = await mediator.Send(new GetLeaveRequestByIdQuery(id));
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateLeaveRequestDraftBody body)
{
await mediator.Send(new UpdateLeaveRequestDraftCommand(id, body.LeaveTypeId, body.StartDate,
body.EndDate, body.NumDays, body.Reason, body.ApprovalWorkflowId));
return NoContent();
}
[HttpPost("{id:guid}/submit")]
public async Task<IActionResult> Submit(Guid id)
{
await mediator.Send(new SubmitLeaveRequestCommand(id));
return NoContent();
}
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromBody] WorkflowActionBody body)
{
await mediator.Send(new ApproveLeaveRequestCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, [FromBody] WorkflowActionBody body)
{
await mediator.Send(new RejectLeaveRequestCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/return")]
public async Task<IActionResult> Return(Guid id, [FromBody] WorkflowActionBody body)
{
await mediator.Send(new ReturnLeaveRequestCommand(id, body.Comment));
return NoContent();
}
public record UpdateLeaveRequestDraftBody(
Guid LeaveTypeId,
DateTime StartDate,
DateTime EndDate,
decimal NumDays,
string Reason,
Guid? ApprovalWorkflowId);
public record WorkflowActionBody(string? Comment);
}

View File

@ -14,10 +14,64 @@ public class OtRequestsController(IMediator mediator) : ControllerBase
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetOtRequestsQuery(status, requesterUserId, page, pageSize)));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var dto = await mediator.Send(new GetOtRequestByIdQuery(id));
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOtRequestCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOtRequestDraftBody body)
{
await mediator.Send(new UpdateOtRequestDraftCommand(id, body.OtDate, body.StartTime,
body.EndTime, body.Hours, body.Reason, body.OtPolicyId, body.ApprovalWorkflowId));
return NoContent();
}
[HttpPost("{id:guid}/submit")]
public async Task<IActionResult> Submit(Guid id)
{
await mediator.Send(new SubmitOtRequestCommand(id));
return NoContent();
}
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromBody] WorkflowActionBody body)
{
await mediator.Send(new ApproveOtRequestCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, [FromBody] WorkflowActionBody body)
{
await mediator.Send(new RejectOtRequestCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/return")]
public async Task<IActionResult> Return(Guid id, [FromBody] WorkflowActionBody body)
{
await mediator.Send(new ReturnOtRequestCommand(id, body.Comment));
return NoContent();
}
public record UpdateOtRequestDraftBody(
DateTime OtDate,
TimeSpan StartTime,
TimeSpan EndTime,
decimal Hours,
string Reason,
Guid? OtPolicyId,
Guid? ApprovalWorkflowId);
public record WorkflowActionBody(string? Comment);
}

View File

@ -14,10 +14,64 @@ public class TravelRequestsController(IMediator mediator) : ControllerBase
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetTravelRequestsQuery(status, requesterUserId, page, pageSize)));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var dto = await mediator.Send(new GetTravelRequestByIdQuery(id));
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTravelRequestCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateTravelRequestBody body)
{
await mediator.Send(new UpdateTravelRequestDraftCommand(id, body.Destination, body.StartDate,
body.EndDate, body.NumDays, body.Purpose, body.EstimatedCost, body.ApprovalWorkflowId));
return NoContent();
}
[HttpPost("{id:guid}/submit")]
public async Task<IActionResult> Submit(Guid id)
{
await mediator.Send(new SubmitTravelRequestCommand(id));
return NoContent();
}
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ApproveTravelRequestCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new RejectTravelRequestCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/return")]
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ReturnTravelRequestCommand(id, body.Comment));
return NoContent();
}
public record UpdateTravelRequestBody(
string Destination,
DateTime StartDate,
DateTime EndDate,
int NumDays,
string Purpose,
decimal? EstimatedCost,
Guid? ApprovalWorkflowId);
public record ApprovalActionBody(string? Comment);
}

View File

@ -14,10 +14,65 @@ public class VehicleBookingsController(IMediator mediator) : ControllerBase
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetVehicleBookingsQuery(status, requesterUserId, page, pageSize)));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var dto = await mediator.Send(new GetVehicleBookingByIdQuery(id));
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateVehicleBookingCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVehicleBookingBody body)
{
await mediator.Send(new UpdateVehicleBookingDraftCommand(id, body.VehicleLicense, body.VehicleName,
body.StartAt, body.EndAt, body.Destination, body.Purpose, body.DriverName, body.ApprovalWorkflowId));
return NoContent();
}
[HttpPost("{id:guid}/submit")]
public async Task<IActionResult> Submit(Guid id)
{
await mediator.Send(new SubmitVehicleBookingCommand(id));
return NoContent();
}
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ApproveVehicleBookingCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/reject")]
public async Task<IActionResult> Reject(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new RejectVehicleBookingCommand(id, body.Comment));
return NoContent();
}
[HttpPost("{id:guid}/return")]
public async Task<IActionResult> Return(Guid id, [FromBody] ApprovalActionBody body)
{
await mediator.Send(new ReturnVehicleBookingCommand(id, body.Comment));
return NoContent();
}
public record UpdateVehicleBookingBody(
string VehicleLicense,
string? VehicleName,
DateTime StartAt,
DateTime EndAt,
string Destination,
string Purpose,
string? DriverName,
Guid? ApprovalWorkflowId);
public record ApprovalActionBody(string? Comment);
}

View File

@ -128,6 +128,14 @@ public interface IApplicationDbContext
DbSet<VehicleBooking> VehicleBookings { get; }
DbSet<ItTicket> ItTickets { get; }
// Phase 11 P11-A (Mig 41) — Wire ApproveV2 + LevelOpinions cho 4 WorkflowApps
// module (cookie-cutter mirror Proposal Mig 38). + shared atomic codegen MaDonTu.
DbSet<LeaveRequestLevelOpinion> LeaveRequestLevelOpinions { get; }
DbSet<OtRequestLevelOpinion> OtRequestLevelOpinions { get; }
DbSet<TravelRequestLevelOpinion> TravelRequestLevelOpinions { get; }
DbSet<VehicleBookingLevelOpinion> VehicleBookingLevelOpinions { get; }
DbSet<WorkflowAppCodeSequence> WorkflowAppCodeSequences { get; }
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
DbSet<Attendance> Attendances { get; }

View File

@ -0,0 +1,749 @@
using System.Data;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
// Phase 11 P11-A Wave 2a (S41/S42 2026-05-30) — wire ApproveV2 CQRS cho LeaveRequest + OtRequest.
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema sẵn (Mig 41).
// ApplicableType: LeaveRequest=5, OtRequest=6.
//
// Per module 7 handler: GetById detail · UpdateDraft · Submit · Approve(UPSERT+advance) ·
// Reject(TuChoi) · Return(TraLai). 1 static helper GenerateMaDonTuAsync dùng chung.
//
// CONSTRAINT: KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). KHÔNG đụng
// Travel/Vehicle (Wave 2b song song). MaDonTu gen lần Submit đầu (null→gen).
// ===== Shared CodeGen helper (dùng chung Leave + Ot trong file này) =====
internal static class WorkflowAppCodeGen
{
// Mirror ProposalFeatures.GenerateMaDeXuatAsync — Serializable tx + Prefix-keyed sequence.
// Format: "{prefix}/{seq:D3}" — prefix vd "DT/LR/2026" → "DT/LR/2026/001".
internal static async Task<string> GenerateMaDonTuAsync(
IApplicationDbContext db, string prefix, int year, IDateTime clock, CancellationToken ct)
{
var fullPrefix = $"{prefix}/{year}";
var dbContext = (DbContext)db;
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == fullPrefix, ct);
if (seq is null)
{
seq = new WorkflowAppCodeSequence { Prefix = fullPrefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
db.WorkflowAppCodeSequences.Add(seq);
}
seq.LastSeq++;
seq.UpdatedAt = clock.UtcNow;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{fullPrefix}/{seq.LastSeq:D3}";
}
}
// =========================================================================
// MODULE A: LeaveRequest (ApplicableType=5, prefix "DT/LR")
// =========================================================================
public record LeaveRequestLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
Guid? ApproverUserId,
string? Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
public record LeaveRequestDetailDto(
Guid Id,
string? MaDonTu,
Guid RequesterUserId,
string RequesterFullName,
Guid LeaveTypeId,
DateTime StartDate,
DateTime EndDate,
decimal NumDays,
string Reason,
int Status,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
string? WorkflowName,
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
DateTime CreatedAt,
List<LeaveRequestLevelOpinionDto> LevelOpinions);
public record GetLeaveRequestByIdQuery(Guid Id) : IRequest<LeaveRequestDetailDto?>;
public class GetLeaveRequestByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetLeaveRequestByIdQuery, LeaveRequestDetailDto?>
{
public async Task<LeaveRequestDetailDto?> Handle(GetLeaveRequestByIdQuery req, CancellationToken ct)
{
var p = await db.LeaveRequests.AsNoTracking()
.Include(x => x.LevelOpinions)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) return null;
string? wfCode = null;
string? wfName = null;
if (p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => new { w.Code, w.Name })
.FirstOrDefaultAsync(ct);
wfCode = wf?.Code;
wfName = wf?.Name;
}
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
.Select(s => new
{
s.Id,
s.Order,
s.Name,
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
.ToList(),
})
.ToListAsync(ct);
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
.ToDictionary(x => x.Level.Id);
var opinions = p.LevelOpinions
.Select(o =>
{
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
return new LeaveRequestLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
lvl?.Step.Order,
lvl?.Step.Name,
lvl?.Level.Order,
lvl?.Level.ApproverUserId,
o.Comment,
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList();
return new LeaveRequestDetailDto(
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId,
p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status,
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, opinions);
}
}
public record UpdateLeaveRequestDraftCommand(
Guid Id,
Guid LeaveTypeId,
DateTime StartDate,
DateTime EndDate,
decimal NumDays,
string Reason,
Guid? ApprovalWorkflowId) : IRequest;
public class UpdateLeaveRequestDraftValidator : AbstractValidator<UpdateLeaveRequestDraftCommand>
{
public UpdateLeaveRequestDraftValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
RuleFor(x => x.NumDays).GreaterThan(0);
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
}
}
public class UpdateLeaveRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<UpdateLeaveRequestDraftCommand>
{
public async Task Handle(UpdateLeaveRequestDraftCommand req, CancellationToken ct)
{
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
var isOwner = p.RequesterUserId == cu.UserId;
var isAdmin = cu.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
{
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
}
p.LeaveTypeId = req.LeaveTypeId;
p.StartDate = req.StartDate;
p.EndDate = req.EndDate;
p.NumDays = req.NumDays;
p.Reason = req.Reason.Trim();
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record SubmitLeaveRequestCommand(Guid Id) : IRequest;
public class SubmitLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<SubmitLeaveRequestCommand>
{
public async Task Handle(SubmitLeaveRequestCommand req, CancellationToken ct)
{
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
var isOwner = p.RequesterUserId == cu.UserId;
var isAdmin = cu.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
if (!p.ApprovalWorkflowId.HasValue)
throw new ConflictException("Chưa chọn quy trình duyệt.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
if (string.IsNullOrEmpty(p.MaDonTu))
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/LR", clock.Now.Year, clock, ct);
p.Status = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = 1;
p.RejectedFromStatus = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ApproveLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<ApproveLeaveRequestCommand>
{
public async Task Handle(ApproveLeaveRequestCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
var allLevels = wf.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
.ToList();
if (allLevels.Count == 0)
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentSlot is null)
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
var isAdmin = cu.Roles.Contains("Admin");
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
var existing = await db.LeaveRequestLevelOpinions
.FirstOrDefaultAsync(o => o.LeaveRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
? "(duyệt — không ý kiến)"
: req.Comment.Trim();
if (existing is null)
{
db.LeaveRequestLevelOpinions.Add(new LeaveRequestLevelOpinion
{
LeaveRequestId = p.Id,
ApprovalWorkflowLevelId = currentSlot.Level.Id,
Comment = commentFinal,
SignedAt = clock.UtcNow,
SignedByUserId = cu.UserId.Value,
SignedByFullName = cu.FullName ?? "(unknown)",
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
});
}
else
{
existing.Comment = commentFinal;
existing.SignedAt = clock.UtcNow;
existing.SignedByUserId = cu.UserId.Value;
existing.SignedByFullName = cu.FullName ?? "(unknown)";
existing.UpdatedAt = clock.UtcNow;
existing.UpdatedBy = cu.UserId;
}
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
{
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
}
else
{
p.Status = WorkflowAppStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record RejectLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
public class RejectLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<RejectLeaveRequestCommand>
{
public async Task Handle(RejectLeaveRequestCommand req, CancellationToken ct)
{
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
var isAdmin = cu.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != cu.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TuChoi;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ReturnLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
public class ReturnLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<ReturnLeaveRequestCommand>
{
public async Task Handle(ReturnLeaveRequestCommand req, CancellationToken ct)
{
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
var isAdmin = cu.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != cu.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TraLai;
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
// =========================================================================
// MODULE B: OtRequest (ApplicableType=6, prefix "DT/OT")
// =========================================================================
public record OtRequestLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
Guid? ApproverUserId,
string? Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
public record OtRequestDetailDto(
Guid Id,
string? MaDonTu,
Guid RequesterUserId,
string RequesterFullName,
DateTime OtDate,
TimeSpan StartTime,
TimeSpan EndTime,
decimal Hours,
string Reason,
Guid? OtPolicyId,
int Status,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
string? WorkflowName,
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
DateTime CreatedAt,
List<OtRequestLevelOpinionDto> LevelOpinions);
public record GetOtRequestByIdQuery(Guid Id) : IRequest<OtRequestDetailDto?>;
public class GetOtRequestByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetOtRequestByIdQuery, OtRequestDetailDto?>
{
public async Task<OtRequestDetailDto?> Handle(GetOtRequestByIdQuery req, CancellationToken ct)
{
var p = await db.OtRequests.AsNoTracking()
.Include(x => x.LevelOpinions)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) return null;
string? wfCode = null;
string? wfName = null;
if (p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => new { w.Code, w.Name })
.FirstOrDefaultAsync(ct);
wfCode = wf?.Code;
wfName = wf?.Name;
}
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
.Select(s => new
{
s.Id,
s.Order,
s.Name,
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
.ToList(),
})
.ToListAsync(ct);
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
.ToDictionary(x => x.Level.Id);
var opinions = p.LevelOpinions
.Select(o =>
{
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
return new OtRequestLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
lvl?.Step.Order,
lvl?.Step.Name,
lvl?.Level.Order,
lvl?.Level.ApproverUserId,
o.Comment,
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList();
return new OtRequestDetailDto(
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
p.OtDate, p.StartTime, p.EndTime, p.Hours, p.Reason, p.OtPolicyId, (int)p.Status,
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, opinions);
}
}
public record UpdateOtRequestDraftCommand(
Guid Id,
DateTime OtDate,
TimeSpan StartTime,
TimeSpan EndTime,
decimal Hours,
string Reason,
Guid? OtPolicyId,
Guid? ApprovalWorkflowId) : IRequest;
public class UpdateOtRequestDraftValidator : AbstractValidator<UpdateOtRequestDraftCommand>
{
public UpdateOtRequestDraftValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
RuleFor(x => x.Hours).GreaterThan(0);
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
}
}
public class UpdateOtRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<UpdateOtRequestDraftCommand>
{
public async Task Handle(UpdateOtRequestDraftCommand req, CancellationToken ct)
{
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("OtRequest", req.Id);
var isOwner = p.RequesterUserId == cu.UserId;
var isAdmin = cu.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
{
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
}
p.OtDate = req.OtDate;
p.StartTime = req.StartTime;
p.EndTime = req.EndTime;
p.Hours = req.Hours;
p.Reason = req.Reason.Trim();
p.OtPolicyId = req.OtPolicyId;
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record SubmitOtRequestCommand(Guid Id) : IRequest;
public class SubmitOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<SubmitOtRequestCommand>
{
public async Task Handle(SubmitOtRequestCommand req, CancellationToken ct)
{
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("OtRequest", req.Id);
var isOwner = p.RequesterUserId == cu.UserId;
var isAdmin = cu.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
if (!p.ApprovalWorkflowId.HasValue)
throw new ConflictException("Chưa chọn quy trình duyệt.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
if (string.IsNullOrEmpty(p.MaDonTu))
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/OT", clock.Now.Year, clock, ct);
p.Status = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = 1;
p.RejectedFromStatus = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ApproveOtRequestCommand(Guid Id, string? Comment) : IRequest;
public class ApproveOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<ApproveOtRequestCommand>
{
public async Task Handle(ApproveOtRequestCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("OtRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
var allLevels = wf.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
.ToList();
if (allLevels.Count == 0)
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentSlot is null)
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
var isAdmin = cu.Roles.Contains("Admin");
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
var existing = await db.OtRequestLevelOpinions
.FirstOrDefaultAsync(o => o.OtRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
? "(duyệt — không ý kiến)"
: req.Comment.Trim();
if (existing is null)
{
db.OtRequestLevelOpinions.Add(new OtRequestLevelOpinion
{
OtRequestId = p.Id,
ApprovalWorkflowLevelId = currentSlot.Level.Id,
Comment = commentFinal,
SignedAt = clock.UtcNow,
SignedByUserId = cu.UserId.Value,
SignedByFullName = cu.FullName ?? "(unknown)",
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
});
}
else
{
existing.Comment = commentFinal;
existing.SignedAt = clock.UtcNow;
existing.SignedByUserId = cu.UserId.Value;
existing.SignedByFullName = cu.FullName ?? "(unknown)";
existing.UpdatedAt = clock.UtcNow;
existing.UpdatedBy = cu.UserId;
}
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
{
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
}
else
{
p.Status = WorkflowAppStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record RejectOtRequestCommand(Guid Id, string? Comment) : IRequest;
public class RejectOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<RejectOtRequestCommand>
{
public async Task Handle(RejectOtRequestCommand req, CancellationToken ct)
{
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("OtRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
var isAdmin = cu.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != cu.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TuChoi;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ReturnOtRequestCommand(Guid Id, string? Comment) : IRequest;
public class ReturnOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<ReturnOtRequestCommand>
{
public async Task Handle(ReturnOtRequestCommand req, CancellationToken ct)
{
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("OtRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
var isAdmin = cu.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != cu.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TraLai;
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,773 @@
using System.Data;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
// Phase 11 P11-A Wave 2b (S41 2026-05-30) — Wire ApproveV2 CQRS cho TravelRequest + VehicleBooking.
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema Wave 1 (Mig 41) đã sẵn:
// TravelRequestLevelOpinions + VehicleBookingLevelOpinions + WorkflowAppCodeSequences
// parent +RejectedFromStatus + nav LevelOpinions.
//
// ApplicableType: TravelRequest=9 (Mig 41 mới) · VehicleBooking=7.
// WorkflowAppStatus {Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5}.
//
// Endpoint per module (qua {Travel,Vehicle}*Controller):
// GET /{id} — detail Include LevelOpinions + Workflow metadata
// PUT /{id} — update draft (Nhap or TraLai only)
// POST /{id}/submit — gen MaDonTu atomic + Status=DaGuiDuyet
// POST /{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
// POST /{id}/reject — Status=TuChoi terminal (no opinion sync)
// POST /{id}/return — Status=TraLai + RejectedFromStatus=DaGuiDuyet (no opinion sync)
//
// Note: GetList + Create giữ nguyên trong WorkflowAppsFeatures.cs (KHÔNG sửa file đó).
// =========================================================================
// REGION 1: TravelRequest — ApproveV2 wire
// =========================================================================
public record TravelRequestLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
Guid? ApproverUserId,
string? Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
public record TravelRequestDetailDto(
Guid Id,
string? MaDonTu,
Guid RequesterUserId,
string RequesterFullName,
string Destination,
DateTime StartDate,
DateTime EndDate,
int NumDays,
string Purpose,
decimal? EstimatedCost,
int Status,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
string? WorkflowName,
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
DateTime CreatedAt,
List<TravelRequestLevelOpinionDto> LevelOpinions);
public record GetTravelRequestByIdQuery(Guid Id) : IRequest<TravelRequestDetailDto?>;
public class GetTravelRequestByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetTravelRequestByIdQuery, TravelRequestDetailDto?>
{
public async Task<TravelRequestDetailDto?> Handle(GetTravelRequestByIdQuery req, CancellationToken ct)
{
var p = await db.TravelRequests.AsNoTracking()
.Include(x => x.LevelOpinions)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) return null;
string? wfCode = null;
string? wfName = null;
if (p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => new { w.Code, w.Name })
.FirstOrDefaultAsync(ct);
wfCode = wf?.Code;
wfName = wf?.Name;
}
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
.Select(s => new
{
s.Id,
s.Order,
s.Name,
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
.ToList(),
})
.ToListAsync(ct);
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
.ToDictionary(x => x.Level.Id);
var opinions = p.LevelOpinions
.Select(o =>
{
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
return new TravelRequestLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
lvl?.Step.Order,
lvl?.Step.Name,
lvl?.Level.Order,
lvl?.Level.ApproverUserId,
o.Comment,
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList();
return new TravelRequestDetailDto(
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
p.Destination, p.StartDate, p.EndDate, p.NumDays, p.Purpose, p.EstimatedCost,
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, opinions);
}
}
public record UpdateTravelRequestDraftCommand(
Guid Id,
string Destination,
DateTime StartDate,
DateTime EndDate,
int NumDays,
string Purpose,
decimal? EstimatedCost,
Guid? ApprovalWorkflowId) : IRequest;
public class UpdateTravelRequestDraftValidator : AbstractValidator<UpdateTravelRequestDraftCommand>
{
public UpdateTravelRequestDraftValidator()
{
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
RuleFor(x => x.NumDays).GreaterThan(0);
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
}
}
public class UpdateTravelRequestDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<UpdateTravelRequestDraftCommand>
{
public async Task Handle(UpdateTravelRequestDraftCommand req, CancellationToken ct)
{
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
var isOwner = p.RequesterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn công tác.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
{
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
}
p.Destination = req.Destination.Trim();
p.StartDate = req.StartDate;
p.EndDate = req.EndDate;
p.NumDays = req.NumDays;
p.Purpose = req.Purpose.Trim();
p.EstimatedCost = req.EstimatedCost;
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record SubmitTravelRequestCommand(Guid Id) : IRequest;
public class SubmitTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<SubmitTravelRequestCommand>
{
public async Task Handle(SubmitTravelRequestCommand req, CancellationToken ct)
{
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
var isOwner = p.RequesterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
if (!p.ApprovalWorkflowId.HasValue)
throw new ConflictException("Chưa chọn quy trình duyệt.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
if (string.IsNullOrEmpty(p.MaDonTu))
{
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
db, $"DT/CT/{clock.Now.Year}", clock, ct);
}
p.Status = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = 1;
p.RejectedFromStatus = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ApproveTravelRequestCommand(Guid Id, string? Comment) : IRequest;
public class ApproveTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ApproveTravelRequestCommand>
{
public async Task Handle(ApproveTravelRequestCommand req, CancellationToken ct)
{
if (currentUser.UserId is null) throw new UnauthorizedException();
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
var allLevels = wf.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
.ToList();
if (allLevels.Count == 0)
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentSlot is null)
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
var existing = await db.TravelRequestLevelOpinions
.FirstOrDefaultAsync(o => o.TravelRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
? "(duyệt — không ý kiến)"
: req.Comment.Trim();
if (existing is null)
{
db.TravelRequestLevelOpinions.Add(new TravelRequestLevelOpinion
{
TravelRequestId = p.Id,
ApprovalWorkflowLevelId = currentSlot.Level.Id,
Comment = commentFinal,
SignedAt = clock.UtcNow,
SignedByUserId = currentUser.UserId.Value,
SignedByFullName = currentUser.FullName ?? "(unknown)",
CreatedAt = clock.UtcNow,
CreatedBy = currentUser.UserId,
});
}
else
{
existing.Comment = commentFinal;
existing.SignedAt = clock.UtcNow;
existing.SignedByUserId = currentUser.UserId.Value;
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
existing.UpdatedAt = clock.UtcNow;
existing.UpdatedBy = currentUser.UserId;
}
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
{
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
}
else
{
p.Status = WorkflowAppStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record RejectTravelRequestCommand(Guid Id, string? Comment) : IRequest;
public class RejectTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<RejectTravelRequestCommand>
{
public async Task Handle(RejectTravelRequestCommand req, CancellationToken ct)
{
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != currentUser.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TuChoi;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ReturnTravelRequestCommand(Guid Id, string? Comment) : IRequest;
public class ReturnTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ReturnTravelRequestCommand>
{
public async Task Handle(ReturnTravelRequestCommand req, CancellationToken ct)
{
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != currentUser.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TraLai;
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
// =========================================================================
// REGION 2: VehicleBooking — ApproveV2 wire
// =========================================================================
public record VehicleBookingLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
Guid? ApproverUserId,
string? Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
public record VehicleBookingDetailDto(
Guid Id,
string? MaDonTu,
Guid RequesterUserId,
string RequesterFullName,
string VehicleLicense,
string? VehicleName,
DateTime StartAt,
DateTime EndAt,
string Destination,
string Purpose,
string? DriverName,
int Status,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
string? WorkflowName,
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
DateTime CreatedAt,
List<VehicleBookingLevelOpinionDto> LevelOpinions);
public record GetVehicleBookingByIdQuery(Guid Id) : IRequest<VehicleBookingDetailDto?>;
public class GetVehicleBookingByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetVehicleBookingByIdQuery, VehicleBookingDetailDto?>
{
public async Task<VehicleBookingDetailDto?> Handle(GetVehicleBookingByIdQuery req, CancellationToken ct)
{
var p = await db.VehicleBookings.AsNoTracking()
.Include(x => x.LevelOpinions)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) return null;
string? wfCode = null;
string? wfName = null;
if (p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => new { w.Code, w.Name })
.FirstOrDefaultAsync(ct);
wfCode = wf?.Code;
wfName = wf?.Name;
}
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
.Select(s => new
{
s.Id,
s.Order,
s.Name,
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
.ToList(),
})
.ToListAsync(ct);
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
.ToDictionary(x => x.Level.Id);
var opinions = p.LevelOpinions
.Select(o =>
{
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
return new VehicleBookingLevelOpinionDto(
o.Id,
o.ApprovalWorkflowLevelId,
lvl?.Step.Order,
lvl?.Step.Name,
lvl?.Level.Order,
lvl?.Level.ApproverUserId,
o.Comment,
o.SignedAt,
o.SignedByUserId,
o.SignedByFullName);
})
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
.ToList();
return new VehicleBookingDetailDto(
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
p.VehicleLicense, p.VehicleName, p.StartAt, p.EndAt, p.Destination, p.Purpose, p.DriverName,
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, opinions);
}
}
public record UpdateVehicleBookingDraftCommand(
Guid Id,
string VehicleLicense,
string? VehicleName,
DateTime StartAt,
DateTime EndAt,
string Destination,
string Purpose,
string? DriverName,
Guid? ApprovalWorkflowId) : IRequest;
public class UpdateVehicleBookingDraftValidator : AbstractValidator<UpdateVehicleBookingDraftCommand>
{
public UpdateVehicleBookingDraftValidator()
{
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
}
}
public class UpdateVehicleBookingDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<UpdateVehicleBookingDraftCommand>
{
public async Task Handle(UpdateVehicleBookingDraftCommand req, CancellationToken ct)
{
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
var isOwner = p.RequesterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn đặt xe.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
{
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
}
p.VehicleLicense = req.VehicleLicense.Trim();
p.VehicleName = req.VehicleName?.Trim();
p.StartAt = req.StartAt;
p.EndAt = req.EndAt;
p.Destination = req.Destination.Trim();
p.Purpose = req.Purpose.Trim();
p.DriverName = req.DriverName?.Trim();
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record SubmitVehicleBookingCommand(Guid Id) : IRequest;
public class SubmitVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<SubmitVehicleBookingCommand>
{
public async Task Handle(SubmitVehicleBookingCommand req, CancellationToken ct)
{
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
var isOwner = p.RequesterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
if (!p.ApprovalWorkflowId.HasValue)
throw new ConflictException("Chưa chọn quy trình duyệt.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
.Select(w => (int?)w.ApplicableType)
.FirstOrDefaultAsync(ct);
if (wfType is null)
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
if (string.IsNullOrEmpty(p.MaDonTu))
{
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
db, $"DX/XE/{clock.Now.Year}", clock, ct);
}
p.Status = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = 1;
p.RejectedFromStatus = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ApproveVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
public class ApproveVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ApproveVehicleBookingCommand>
{
public async Task Handle(ApproveVehicleBookingCommand req, CancellationToken ct)
{
if (currentUser.UserId is null) throw new UnauthorizedException();
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
var allLevels = wf.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
.ToList();
if (allLevels.Count == 0)
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentSlot is null)
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
var existing = await db.VehicleBookingLevelOpinions
.FirstOrDefaultAsync(o => o.VehicleBookingId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
? "(duyệt — không ý kiến)"
: req.Comment.Trim();
if (existing is null)
{
db.VehicleBookingLevelOpinions.Add(new VehicleBookingLevelOpinion
{
VehicleBookingId = p.Id,
ApprovalWorkflowLevelId = currentSlot.Level.Id,
Comment = commentFinal,
SignedAt = clock.UtcNow,
SignedByUserId = currentUser.UserId.Value,
SignedByFullName = currentUser.FullName ?? "(unknown)",
CreatedAt = clock.UtcNow,
CreatedBy = currentUser.UserId,
});
}
else
{
existing.Comment = commentFinal;
existing.SignedAt = clock.UtcNow;
existing.SignedByUserId = currentUser.UserId.Value;
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
existing.UpdatedAt = clock.UtcNow;
existing.UpdatedBy = currentUser.UserId;
}
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
{
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
}
else
{
p.Status = WorkflowAppStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record RejectVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
public class RejectVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<RejectVehicleBookingCommand>
{
public async Task Handle(RejectVehicleBookingCommand req, CancellationToken ct)
{
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != currentUser.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TuChoi;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ReturnVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
public class ReturnVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ReturnVehicleBookingCommand>
{
public async Task Handle(ReturnVehicleBookingCommand req, CancellationToken ct)
{
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
{
var wf = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
var allLevels = wf?.Steps.OrderBy(s => s.Order)
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
.ToList() ?? new();
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
if (currentLevel?.ApproverUserId != currentUser.UserId)
throw new ForbiddenException("Không phải người duyệt của cấp này.");
}
p.Status = WorkflowAppStatus.TraLai;
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
// =========================================================================
// Shared CodeGen helper — Prefix-keyed WorkflowAppCodeSequences (SERIALIZABLE tx).
// Mirror SubmitProposalHandler.GenerateMaDeXuatAsync. Format: {prefix}/{seq:D3}.
// Travel prefix = "DT/CT/{year}"
// Vehicle prefix = "DX/XE/{year}"
// =========================================================================
internal static class TravelVehicleCodeGen
{
internal static async Task<string> GenerateMaDonTuAsync(
IApplicationDbContext db, string prefix, IDateTime clock, CancellationToken ct)
{
var dbContext = (DbContext)db;
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new WorkflowAppCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
db.WorkflowAppCodeSequences.Add(seq);
}
seq.LastSeq++;
seq.UpdatedAt = clock.UtcNow;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D3}";
}
}

View File

@ -55,6 +55,7 @@ public enum ApprovalWorkflowApplicableType
OtRequest = 6, // G-O4 — Đơn OT
VehicleBooking = 7, // G-O5 — Đặt xe công
ItTicket = 8, // G-O6 — Ticket CNTT
TravelRequest = 9, // G-O4 — Đơn công tác (Travel) — Phase 11 P11-A
}
// Bước = Phòng. 1 quy trình có nhiều bước theo Order.

View File

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

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho LeaveRequest.
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
//
// Mỗi row = 1 (LeaveRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
// Reject (TraLai/TuChoi) KHÔNG sync.
//
// UNIQUE composite (LeaveRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
// FK Cascade LeaveRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
public class LeaveRequestLevelOpinion : AuditableEntity
{
public Guid LeaveRequestId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // max 2000 hoặc placeholder
public DateTime SignedAt { get; set; }
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
public LeaveRequest? LeaveRequest { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

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

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho OtRequest.
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
//
// Mỗi row = 1 (OtRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
// Reject (TraLai/TuChoi) KHÔNG sync.
//
// UNIQUE composite (OtRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
// FK Cascade OtRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
public class OtRequestLevelOpinion : AuditableEntity
{
public Guid OtRequestId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // max 2000 hoặc placeholder
public DateTime SignedAt { get; set; }
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
public OtRequest? OtRequest { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

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

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho TravelRequest.
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
//
// Mỗi row = 1 (TravelRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
// Reject (TraLai/TuChoi) KHÔNG sync.
//
// UNIQUE composite (TravelRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
// FK Cascade TravelRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
public class TravelRequestLevelOpinion : AuditableEntity
{
public Guid TravelRequestId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // max 2000 hoặc placeholder
public DateTime SignedAt { get; set; }
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
public TravelRequest? TravelRequest { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

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

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho VehicleBooking.
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
//
// Mỗi row = 1 (VehicleBooking × ApprovalWorkflowLevel). Service ApproveV2Async sau
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
// Reject (TraLai/TuChoi) KHÔNG sync.
//
// UNIQUE composite (VehicleBookingId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
// FK Cascade VehicleBooking (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
public class VehicleBookingLevelOpinion : AuditableEntity
{
public Guid VehicleBookingId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // max 2000 hoặc placeholder
public DateTime SignedAt { get; set; }
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
public VehicleBooking? VehicleBooking { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -0,0 +1,19 @@
namespace SolutionErp.Domain.Office;
// Phase 11 P11-A (Mig 41) — Sequence generator dùng chung cho mã đơn từ (MaDonTu)
// của 4 WorkflowApps module (Leave / OT / Travel / VehicleBooking).
// Mirror ProposalCodeSequence pattern (Prefix string PK + LastSeq atomic).
//
// Prefix-keyed per module per năm, vd:
// "DT/LR/2026" (Leave) → "DT/LR/2026/001" → "DT/LR/2026/002" → ...
// "DT/OT/2026" (OT)
// "DT/CT/2026" (Travel — Công tác)
// "DX/XE/2026" (VehicleBooking — Đặt xe)
// LastSeq reset đầu năm tự nhiên (key Prefix mới). Update atomic qua
// SERIALIZABLE transaction trong CodeGen service.
public class WorkflowAppCodeSequence
{
public string Prefix { get; set; } = string.Empty; // PK — "DT/LR/2026"
public int LastSeq { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@ -115,6 +115,13 @@ public class ApplicationDbContext
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
public DbSet<ItTicket> ItTickets => Set<ItTicket>();
// Phase 11 P11-A (Mig 41) — LevelOpinions 4 module + shared CodeSequence.
public DbSet<LeaveRequestLevelOpinion> LeaveRequestLevelOpinions => Set<LeaveRequestLevelOpinion>();
public DbSet<OtRequestLevelOpinion> OtRequestLevelOpinions => Set<OtRequestLevelOpinion>();
public DbSet<TravelRequestLevelOpinion> TravelRequestLevelOpinions => Set<TravelRequestLevelOpinion>();
public DbSet<VehicleBookingLevelOpinion> VehicleBookingLevelOpinions => Set<VehicleBookingLevelOpinion>();
public DbSet<WorkflowAppCodeSequence> WorkflowAppCodeSequences => Set<WorkflowAppCodeSequence>();
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
public DbSet<Attendance> Attendances => Set<Attendance>();

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho LeaveRequest.
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
public class LeaveRequestLevelOpinionConfiguration : IEntityTypeConfiguration<LeaveRequestLevelOpinion>
{
public void Configure(EntityTypeBuilder<LeaveRequestLevelOpinion> e)
{
e.ToTable("LeaveRequestLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.LeaveRequest)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.LeaveRequestId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.LeaveRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho OtRequest.
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
public class OtRequestLevelOpinionConfiguration : IEntityTypeConfiguration<OtRequestLevelOpinion>
{
public void Configure(EntityTypeBuilder<OtRequestLevelOpinion> e)
{
e.ToTable("OtRequestLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.OtRequest)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.OtRequestId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OtRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho TravelRequest.
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
public class TravelRequestLevelOpinionConfiguration : IEntityTypeConfiguration<TravelRequestLevelOpinion>
{
public void Configure(EntityTypeBuilder<TravelRequestLevelOpinion> e)
{
e.ToTable("TravelRequestLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.TravelRequest)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.TravelRequestId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.TravelRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho VehicleBooking.
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
public class VehicleBookingLevelOpinionConfiguration : IEntityTypeConfiguration<VehicleBookingLevelOpinion>
{
public void Configure(EntityTypeBuilder<VehicleBookingLevelOpinion> e)
{
e.ToTable("VehicleBookingLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.VehicleBooking)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.VehicleBookingId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.VehicleBookingId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 41 P11-A (Phase 11) — Sequence generator dùng chung cho mã đơn từ 4
// WorkflowApps module (Leave/OT/Travel/VehicleBooking). Mirror
// ProposalCodeSequenceConfiguration pattern. PK = Prefix (string), LastSeq atomic.
public class WorkflowAppCodeSequenceConfiguration : IEntityTypeConfiguration<WorkflowAppCodeSequence>
{
public void Configure(EntityTypeBuilder<WorkflowAppCodeSequence> e)
{
e.ToTable("WorkflowAppCodeSequences");
e.HasKey(x => x.Prefix);
e.Property(x => x.Prefix).HasMaxLength(20); // "DT/LR/2026" max ~10 chars OK
}
}

View File

@ -137,6 +137,13 @@ public static class DbInitializer
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
await SeedSampleProposalWorkflowV2Async(db, userManager, logger);
// Phase 11 P11-A (S42) — Sample workflow V2 cho 4 WorkflowApps module.
// INFRASTRUCTURE seed NOT gated DemoSeed (gotcha #51) — mirror Proposal.
await SeedSampleLeaveRequestWorkflowV2Async(db, userManager, logger);
await SeedSampleOtRequestWorkflowV2Async(db, userManager, logger);
await SeedSampleTravelRequestWorkflowV2Async(db, userManager, logger);
await SeedSampleVehicleBookingWorkflowV2Async(db, userManager, logger);
await WarnDefaultAdminPasswordAsync(userManager, logger);
}
@ -294,6 +301,202 @@ public static class DbInitializer
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Proposal: QT-DX-V2-001 v01");
}
// Phase 11 P11-A (S42) — Sample workflow V2 cho 4 WorkflowApps module để UAT
// test approval ngay (Leave/OT/Travel/Vehicle). Cookie-cutter mirror
// SeedSampleProposalWorkflowV2Async CHÍNH XÁC. INFRASTRUCTURE seed NOT gated
// DemoSeed (gotcha #51). Idempotent — skip nếu đã có ANY workflow cùng type.
private static async Task SeedSampleLeaveRequestWorkflowV2Async(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var hasAny = await db.ApprovalWorkflows
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.LeaveRequest);
if (hasAny) return;
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
if (approver is null)
{
logger.LogWarning("SeedSampleLeaveRequestWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
return;
}
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
var wf = new ApprovalWorkflow
{
Code = "QT-NP-V2-001",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.LeaveRequest,
Name = "Quy trình duyệt đơn nghỉ phép (mẫu)",
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true,
ActivatedAt = DateTime.UtcNow,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = 1,
Name = "Cấp duyệt",
DepartmentId = ccmDept?.Id,
};
var level = new ApprovalWorkflowLevel
{
Step = step,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approver.Id,
};
wf.Steps.Add(step);
step.Levels.Add(level);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync();
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for LeaveRequest: QT-NP-V2-001 v01");
}
private static async Task SeedSampleOtRequestWorkflowV2Async(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var hasAny = await db.ApprovalWorkflows
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.OtRequest);
if (hasAny) return;
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
if (approver is null)
{
logger.LogWarning("SeedSampleOtRequestWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
return;
}
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
var wf = new ApprovalWorkflow
{
Code = "QT-OT-V2-001",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.OtRequest,
Name = "Quy trình duyệt đơn OT (mẫu)",
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true,
ActivatedAt = DateTime.UtcNow,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = 1,
Name = "Cấp duyệt",
DepartmentId = ccmDept?.Id,
};
var level = new ApprovalWorkflowLevel
{
Step = step,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approver.Id,
};
wf.Steps.Add(step);
step.Levels.Add(level);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync();
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for OtRequest: QT-OT-V2-001 v01");
}
private static async Task SeedSampleTravelRequestWorkflowV2Async(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var hasAny = await db.ApprovalWorkflows
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.TravelRequest);
if (hasAny) return;
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
if (approver is null)
{
logger.LogWarning("SeedSampleTravelRequestWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
return;
}
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
var wf = new ApprovalWorkflow
{
Code = "QT-CT-V2-001",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.TravelRequest,
Name = "Quy trình duyệt đơn công tác (mẫu)",
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true,
ActivatedAt = DateTime.UtcNow,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = 1,
Name = "Cấp duyệt",
DepartmentId = ccmDept?.Id,
};
var level = new ApprovalWorkflowLevel
{
Step = step,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approver.Id,
};
wf.Steps.Add(step);
step.Levels.Add(level);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync();
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for TravelRequest: QT-CT-V2-001 v01");
}
private static async Task SeedSampleVehicleBookingWorkflowV2Async(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
var hasAny = await db.ApprovalWorkflows
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.VehicleBooking);
if (hasAny) return;
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
if (approver is null)
{
logger.LogWarning("SeedSampleVehicleBookingWorkflowV2Async: skip — approver binh.le@solutions.com.vn not found");
return;
}
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
var wf = new ApprovalWorkflow
{
Code = "QT-XE-V2-001",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.VehicleBooking,
Name = "Quy trình duyệt đặt xe (mẫu)",
Description = "Sample seed UAT — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
IsActive = true,
IsUserSelectable = true,
ActivatedAt = DateTime.UtcNow,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflow = wf,
Order = 1,
Name = "Cấp duyệt",
DepartmentId = ccmDept?.Id,
};
var level = new ApprovalWorkflowLevel
{
Step = step,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approver.Id,
};
wf.Steps.Add(step);
step.Levels.Add(level);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync();
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for VehicleBooking: QT-XE-V2-001 v01");
}
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)

View File

@ -0,0 +1,275 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class WireWorkflowAppsApprovalV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RejectedFromStatus",
table: "VehicleBookings",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedFromStatus",
table: "TravelRequests",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedFromStatus",
table: "OtRequests",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedFromStatus",
table: "LeaveRequests",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "LeaveRequestLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LeaveRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LeaveRequestLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_LeaveRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_LeaveRequestLevelOpinions_LeaveRequests_LeaveRequestId",
column: x => x.LeaveRequestId,
principalTable: "LeaveRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OtRequestLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OtRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OtRequestLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_OtRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_OtRequestLevelOpinions_OtRequests_OtRequestId",
column: x => x.OtRequestId,
principalTable: "OtRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TravelRequestLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TravelRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TravelRequestLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_TravelRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TravelRequestLevelOpinions_TravelRequests_TravelRequestId",
column: x => x.TravelRequestId,
principalTable: "TravelRequests",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VehicleBookingLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
VehicleBookingId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VehicleBookingLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_VehicleBookingLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VehicleBookingLevelOpinions_VehicleBookings_VehicleBookingId",
column: x => x.VehicleBookingId,
principalTable: "VehicleBookings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "WorkflowAppCodeSequences",
columns: table => new
{
Prefix = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
LastSeq = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowAppCodeSequences", x => x.Prefix);
});
migrationBuilder.CreateIndex(
name: "IX_LeaveRequestLevelOpinions_ApprovalWorkflowLevelId",
table: "LeaveRequestLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_LeaveRequestLevelOpinions_LeaveRequestId_ApprovalWorkflowLevelId",
table: "LeaveRequestLevelOpinions",
columns: new[] { "LeaveRequestId", "ApprovalWorkflowLevelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OtRequestLevelOpinions_ApprovalWorkflowLevelId",
table: "OtRequestLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_OtRequestLevelOpinions_OtRequestId_ApprovalWorkflowLevelId",
table: "OtRequestLevelOpinions",
columns: new[] { "OtRequestId", "ApprovalWorkflowLevelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TravelRequestLevelOpinions_ApprovalWorkflowLevelId",
table: "TravelRequestLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_TravelRequestLevelOpinions_TravelRequestId_ApprovalWorkflowLevelId",
table: "TravelRequestLevelOpinions",
columns: new[] { "TravelRequestId", "ApprovalWorkflowLevelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VehicleBookingLevelOpinions_ApprovalWorkflowLevelId",
table: "VehicleBookingLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_VehicleBookingLevelOpinions_VehicleBookingId_ApprovalWorkflowLevelId",
table: "VehicleBookingLevelOpinions",
columns: new[] { "VehicleBookingId", "ApprovalWorkflowLevelId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LeaveRequestLevelOpinions");
migrationBuilder.DropTable(
name: "OtRequestLevelOpinions");
migrationBuilder.DropTable(
name: "TravelRequestLevelOpinions");
migrationBuilder.DropTable(
name: "VehicleBookingLevelOpinions");
migrationBuilder.DropTable(
name: "WorkflowAppCodeSequences");
migrationBuilder.DropColumn(
name: "RejectedFromStatus",
table: "VehicleBookings");
migrationBuilder.DropColumn(
name: "RejectedFromStatus",
table: "TravelRequests");
migrationBuilder.DropColumn(
name: "RejectedFromStatus",
table: "OtRequests");
migrationBuilder.DropColumn(
name: "RejectedFromStatus",
table: "LeaveRequests");
}
}
}

View File

@ -3676,6 +3676,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -3709,6 +3712,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("LeaveRequests", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("LeaveRequestId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("LeaveRequestId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("LeaveRequestLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.Property<Guid>("Id")
@ -3932,6 +3993,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -3965,6 +4029,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("OtRequests", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("OtRequestId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("OtRequestId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("OtRequestLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
{
b.Property<Guid>("Id")
@ -4236,6 +4358,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -4269,6 +4394,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("TravelRequests", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("TravelRequestId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("TravelRequestId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("TravelRequestLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
{
b.Property<Guid>("Id")
@ -4317,6 +4500,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("RejectedFromStatus")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
@ -4361,6 +4547,81 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("VehicleBookings", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("VehicleBookingId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("VehicleBookingId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("VehicleBookingLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.WorkflowAppCodeSequence", b =>
{
b.Property<string>("Prefix")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("LastSeq")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Prefix");
b.ToTable("WorkflowAppCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Property<Guid>("Id")
@ -5603,6 +5864,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Office.LeaveRequest", "LeaveRequest")
.WithMany("LevelOpinions")
.HasForeignKey("LeaveRequestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LeaveRequest");
b.Navigation("Level");
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room")
@ -5625,6 +5905,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Booking");
});
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Office.OtRequest", "OtRequest")
.WithMany("LevelOpinions")
.HasForeignKey("OtRequestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
b.Navigation("OtRequest");
});
modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b =>
{
b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal")
@ -5655,6 +5954,44 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Proposal");
});
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Office.TravelRequest", "TravelRequest")
.WithMany("LevelOpinions")
.HasForeignKey("TravelRequestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
b.Navigation("TravelRequest");
});
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Office.VehicleBooking", "VehicleBooking")
.WithMany("LevelOpinions")
.HasForeignKey("VehicleBookingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
b.Navigation("VehicleBooking");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
@ -5889,11 +6226,21 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Permissions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b =>
{
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.Navigation("Attendees");
});
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b =>
{
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
{
b.Navigation("Attachments");
@ -5901,6 +6248,16 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b =>
{
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
{
b.Navigation("LevelOpinions");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Navigation("Approvals");