[CLAUDE] Workflow: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Phase 11 P11-A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s
Wire full approval workflow V2 cho Leave/OT/Travel/Vehicle — cookie-cutter
mirror Proposal (Mig 38). Trước đây skeleton Phase 1 (Create+List), giờ
ApproveV2 advance-level + UPSERT LevelOpinion + atomic codegen.
Schema (Mig 41 WireWorkflowAppsApprovalV2 — 84→89 tables, pure additive):
- 4 bảng {Leave,Ot,Travel,Vehicle}LevelOpinions (UNIQUE composite + Cascade
parent + Restrict Level — mirror ProposalLevelOpinion)
- 1 bảng WorkflowAppCodeSequences (shared atomic MaDonTu, Prefix-keyed)
- 4 cột RejectedFromStatus (smart return tracking)
- enum ApprovalWorkflowApplicableType.TravelRequest = 9
Application (LeaveOt + TravelVehicle ApprovalFeatures.cs — 30 handler):
- GetById detail (Include LevelOpinions + JOIN Step/Level) · UpdateDraft
- Submit (gen MaDonTu + DaGuiDuyet + level=1, verify ApplicableType per module)
- Approve (verify actor==ApproverUserId OR Admin, UPSERT opinion latest-write-wins,
advance level OR terminal DaDuyet, empty comment → placeholder)
- Reject (→TuChoi) · Return (→TraLai + RejectedFromStatus)
Api: 4 controller +6 route mỗi cái (GET/{id}, PUT/{id}, submit/approve/reject/return)
Infra: DbInitializer seed 4 workflow V2 mẫu (QT-NP/OT/CT/XE-V2-001) → UAT test ngay
FE: WorkflowAppDetailPage.tsx declarative 4-kind (fe-admin+fe-user SHA256 identical)
— workflow status + opinion timeline + action buttons; gỡ banner skeleton + row nav
Tests: +11 WorkflowAppApproveV2Tests (130→141 PASS) — state machine + UPSERT
invariant + guards + codegen + forbidden + placeholder (Leave full + Ot smoke)
Verify: build 0 error · 141 test PASS · FE build ×2 · reviewer checklist
(ApplicableType per-module + cross-module DbSet + [Authorize] — no copy-paste bug)
Known-minor (unreachable): Reject/Return actor-check skip nếu CurrentApprovalLevelOrder
null — nhưng DaGuiDuyet luôn có set (defer hardening).
ItTicket KHÔNG đụng (kanban, no workflow V2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -128,6 +128,14 @@ public interface IApplicationDbContext
|
||||
DbSet<VehicleBooking> VehicleBookings { get; }
|
||||
DbSet<ItTicket> ItTickets { get; }
|
||||
|
||||
// Phase 11 P11-A (Mig 41) — Wire ApproveV2 + LevelOpinions cho 4 WorkflowApps
|
||||
// module (cookie-cutter mirror Proposal Mig 38). + shared atomic codegen MaDonTu.
|
||||
DbSet<LeaveRequestLevelOpinion> LeaveRequestLevelOpinions { get; }
|
||||
DbSet<OtRequestLevelOpinion> OtRequestLevelOpinions { get; }
|
||||
DbSet<TravelRequestLevelOpinion> TravelRequestLevelOpinions { get; }
|
||||
DbSet<VehicleBookingLevelOpinion> VehicleBookingLevelOpinions { get; }
|
||||
DbSet<WorkflowAppCodeSequence> WorkflowAppCodeSequences { get; }
|
||||
|
||||
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
||||
DbSet<Attendance> Attendances { get; }
|
||||
|
||||
|
||||
@ -0,0 +1,749 @@
|
||||
using System.Data;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 11 P11-A Wave 2a (S41/S42 2026-05-30) — wire ApproveV2 CQRS cho LeaveRequest + OtRequest.
|
||||
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema sẵn (Mig 41).
|
||||
// ApplicableType: LeaveRequest=5, OtRequest=6.
|
||||
//
|
||||
// Per module 7 handler: GetById detail · UpdateDraft · Submit · Approve(UPSERT+advance) ·
|
||||
// Reject(TuChoi) · Return(TraLai). 1 static helper GenerateMaDonTuAsync dùng chung.
|
||||
//
|
||||
// CONSTRAINT: KHÔNG sửa WorkflowAppsFeatures.cs (Region 1 Create/List ở đó). KHÔNG đụng
|
||||
// Travel/Vehicle (Wave 2b song song). MaDonTu gen lần Submit đầu (null→gen).
|
||||
|
||||
// ===== Shared CodeGen helper (dùng chung Leave + Ot trong file này) =====
|
||||
|
||||
internal static class WorkflowAppCodeGen
|
||||
{
|
||||
// Mirror ProposalFeatures.GenerateMaDeXuatAsync — Serializable tx + Prefix-keyed sequence.
|
||||
// Format: "{prefix}/{seq:D3}" — prefix vd "DT/LR/2026" → "DT/LR/2026/001".
|
||||
internal static async Task<string> GenerateMaDonTuAsync(
|
||||
IApplicationDbContext db, string prefix, int year, IDateTime clock, CancellationToken ct)
|
||||
{
|
||||
var fullPrefix = $"{prefix}/{year}";
|
||||
var dbContext = (DbContext)db;
|
||||
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == fullPrefix, ct);
|
||||
if (seq is null)
|
||||
{
|
||||
seq = new WorkflowAppCodeSequence { Prefix = fullPrefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||
db.WorkflowAppCodeSequences.Add(seq);
|
||||
}
|
||||
seq.LastSeq++;
|
||||
seq.UpdatedAt = clock.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return $"{fullPrefix}/{seq.LastSeq:D3}";
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MODULE A: LeaveRequest (ApplicableType=5, prefix "DT/LR")
|
||||
// =========================================================================
|
||||
|
||||
public record LeaveRequestLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record LeaveRequestDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
Guid LeaveTypeId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
decimal NumDays,
|
||||
string Reason,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<LeaveRequestLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetLeaveRequestByIdQuery(Guid Id) : IRequest<LeaveRequestDetailDto?>;
|
||||
|
||||
public class GetLeaveRequestByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetLeaveRequestByIdQuery, LeaveRequestDetailDto?>
|
||||
{
|
||||
public async Task<LeaveRequestDetailDto?> Handle(GetLeaveRequestByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new LeaveRequestLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new LeaveRequestDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName, p.LeaveTypeId,
|
||||
p.StartDate, p.EndDate, p.NumDays, p.Reason, (int)p.Status,
|
||||
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateLeaveRequestDraftCommand(
|
||||
Guid Id,
|
||||
Guid LeaveTypeId,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
decimal NumDays,
|
||||
string Reason,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateLeaveRequestDraftValidator : AbstractValidator<UpdateLeaveRequestDraftCommand>
|
||||
{
|
||||
public UpdateLeaveRequestDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateLeaveRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<UpdateLeaveRequestDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateLeaveRequestDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
|
||||
}
|
||||
|
||||
p.LeaveTypeId = req.LeaveTypeId;
|
||||
p.StartDate = req.StartDate;
|
||||
p.EndDate = req.EndDate;
|
||||
p.NumDays = req.NumDays;
|
||||
p.Reason = req.Reason.Trim();
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitLeaveRequestCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<SubmitLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(SubmitLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.LeaveRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn nghỉ phép.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/LR", clock.Now.Year, clock, ct);
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ApproveLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(ApproveLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.LeaveRequestLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.LeaveRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.LeaveRequestLevelOpinions.Add(new LeaveRequestLevelOpinion
|
||||
{
|
||||
LeaveRequestId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = cu.UserId.Value,
|
||||
SignedByFullName = cu.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = cu.UserId.Value;
|
||||
existing.SignedByFullName = cu.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = cu.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<RejectLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(RejectLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnLeaveRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ReturnLeaveRequestCommand>
|
||||
{
|
||||
public async Task Handle(ReturnLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.LeaveRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("LeaveRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MODULE B: OtRequest (ApplicableType=6, prefix "DT/OT")
|
||||
// =========================================================================
|
||||
|
||||
public record OtRequestLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record OtRequestDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
DateTime OtDate,
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
decimal Hours,
|
||||
string Reason,
|
||||
Guid? OtPolicyId,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<OtRequestLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetOtRequestByIdQuery(Guid Id) : IRequest<OtRequestDetailDto?>;
|
||||
|
||||
public class GetOtRequestByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetOtRequestByIdQuery, OtRequestDetailDto?>
|
||||
{
|
||||
public async Task<OtRequestDetailDto?> Handle(GetOtRequestByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new OtRequestLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new OtRequestDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||
p.OtDate, p.StartTime, p.EndTime, p.Hours, p.Reason, p.OtPolicyId, (int)p.Status,
|
||||
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateOtRequestDraftCommand(
|
||||
Guid Id,
|
||||
DateTime OtDate,
|
||||
TimeSpan StartTime,
|
||||
TimeSpan EndTime,
|
||||
decimal Hours,
|
||||
string Reason,
|
||||
Guid? OtPolicyId,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateOtRequestDraftValidator : AbstractValidator<UpdateOtRequestDraftCommand>
|
||||
{
|
||||
public UpdateOtRequestDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.Hours).GreaterThan(0);
|
||||
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateOtRequestDraftHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<UpdateOtRequestDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateOtRequestDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
|
||||
}
|
||||
|
||||
p.OtDate = req.OtDate;
|
||||
p.StartTime = req.StartTime;
|
||||
p.EndTime = req.EndTime;
|
||||
p.Hours = req.Hours;
|
||||
p.Reason = req.Reason.Trim();
|
||||
p.OtPolicyId = req.OtPolicyId;
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitOtRequestCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<SubmitOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(SubmitOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == cu.UserId;
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
p.MaDonTu = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "DT/OT", clock.Now.Year, clock, ct);
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ApproveOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(ApproveOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != cu.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.OtRequestLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.OtRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.OtRequestLevelOpinions.Add(new OtRequestLevelOpinion
|
||||
{
|
||||
OtRequestId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = cu.UserId.Value,
|
||||
SignedByFullName = cu.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = cu.UserId.Value;
|
||||
existing.SignedByFullName = cu.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = cu.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<RejectOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(RejectOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnOtRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<ReturnOtRequestCommand>
|
||||
{
|
||||
public async Task Handle(ReturnOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.OtRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("OtRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = cu.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != cu.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,773 @@
|
||||
using System.Data;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 11 P11-A Wave 2b (S41 2026-05-30) — Wire ApproveV2 CQRS cho TravelRequest + VehicleBooking.
|
||||
// Cookie-cutter mirror ProposalFeatures Region 2 (Mig 38). Schema Wave 1 (Mig 41) đã sẵn:
|
||||
// TravelRequestLevelOpinions + VehicleBookingLevelOpinions + WorkflowAppCodeSequences
|
||||
// parent +RejectedFromStatus + nav LevelOpinions.
|
||||
//
|
||||
// ApplicableType: TravelRequest=9 (Mig 41 mới) · VehicleBooking=7.
|
||||
// WorkflowAppStatus {Nhap=1, DaGuiDuyet=2, TraLai=3, TuChoi=4, DaDuyet=5}.
|
||||
//
|
||||
// Endpoint per module (qua {Travel,Vehicle}*Controller):
|
||||
// GET /{id} — detail Include LevelOpinions + Workflow metadata
|
||||
// PUT /{id} — update draft (Nhap or TraLai only)
|
||||
// POST /{id}/submit — gen MaDonTu atomic + Status=DaGuiDuyet
|
||||
// POST /{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
|
||||
// POST /{id}/reject — Status=TuChoi terminal (no opinion sync)
|
||||
// POST /{id}/return — Status=TraLai + RejectedFromStatus=DaGuiDuyet (no opinion sync)
|
||||
//
|
||||
// Note: GetList + Create giữ nguyên trong WorkflowAppsFeatures.cs (KHÔNG sửa file đó).
|
||||
|
||||
// =========================================================================
|
||||
// REGION 1: TravelRequest — ApproveV2 wire
|
||||
// =========================================================================
|
||||
|
||||
public record TravelRequestLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record TravelRequestDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
string Destination,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int NumDays,
|
||||
string Purpose,
|
||||
decimal? EstimatedCost,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<TravelRequestLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetTravelRequestByIdQuery(Guid Id) : IRequest<TravelRequestDetailDto?>;
|
||||
|
||||
public class GetTravelRequestByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetTravelRequestByIdQuery, TravelRequestDetailDto?>
|
||||
{
|
||||
public async Task<TravelRequestDetailDto?> Handle(GetTravelRequestByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new TravelRequestLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new TravelRequestDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||
p.Destination, p.StartDate, p.EndDate, p.NumDays, p.Purpose, p.EstimatedCost,
|
||||
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateTravelRequestDraftCommand(
|
||||
Guid Id,
|
||||
string Destination,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int NumDays,
|
||||
string Purpose,
|
||||
decimal? EstimatedCost,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateTravelRequestDraftValidator : AbstractValidator<UpdateTravelRequestDraftCommand>
|
||||
{
|
||||
public UpdateTravelRequestDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateTravelRequestDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<UpdateTravelRequestDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateTravelRequestDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn công tác.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
|
||||
}
|
||||
|
||||
p.Destination = req.Destination.Trim();
|
||||
p.StartDate = req.StartDate;
|
||||
p.EndDate = req.EndDate;
|
||||
p.NumDays = req.NumDays;
|
||||
p.Purpose = req.Purpose.Trim();
|
||||
p.EstimatedCost = req.EstimatedCost;
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitTravelRequestCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<SubmitTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(SubmitTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.TravelRequest)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn công tác.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
{
|
||||
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
|
||||
db, $"DT/CT/{clock.Now.Year}", clock, ct);
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ApproveTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(ApproveTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.TravelRequestLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.TravelRequestId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.TravelRequestLevelOpinions.Add(new TravelRequestLevelOpinion
|
||||
{
|
||||
TravelRequestId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = currentUser.UserId.Value,
|
||||
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = currentUser.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = currentUser.UserId.Value;
|
||||
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = currentUser.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<RejectTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(RejectTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnTravelRequestCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnTravelRequestHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ReturnTravelRequestCommand>
|
||||
{
|
||||
public async Task Handle(ReturnTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.TravelRequests.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("TravelRequest", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 2: VehicleBooking — ApproveV2 wire
|
||||
// =========================================================================
|
||||
|
||||
public record VehicleBookingLevelOpinionDto(
|
||||
Guid Id,
|
||||
Guid ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
Guid? ApproverUserId,
|
||||
string? Comment,
|
||||
DateTime SignedAt,
|
||||
Guid SignedByUserId,
|
||||
string SignedByFullName);
|
||||
|
||||
public record VehicleBookingDetailDto(
|
||||
Guid Id,
|
||||
string? MaDonTu,
|
||||
Guid RequesterUserId,
|
||||
string RequesterFullName,
|
||||
string VehicleLicense,
|
||||
string? VehicleName,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Destination,
|
||||
string Purpose,
|
||||
string? DriverName,
|
||||
int Status,
|
||||
Guid? ApprovalWorkflowId,
|
||||
string? WorkflowCode,
|
||||
string? WorkflowName,
|
||||
int? CurrentApprovalLevelOrder,
|
||||
int? RejectedFromStatus,
|
||||
DateTime CreatedAt,
|
||||
List<VehicleBookingLevelOpinionDto> LevelOpinions);
|
||||
|
||||
public record GetVehicleBookingByIdQuery(Guid Id) : IRequest<VehicleBookingDetailDto?>;
|
||||
|
||||
public class GetVehicleBookingByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetVehicleBookingByIdQuery, VehicleBookingDetailDto?>
|
||||
{
|
||||
public async Task<VehicleBookingDetailDto?> Handle(GetVehicleBookingByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.AsNoTracking()
|
||||
.Include(x => x.LevelOpinions)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) return null;
|
||||
|
||||
string? wfCode = null;
|
||||
string? wfName = null;
|
||||
if (p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => new { w.Code, w.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
wfCode = wf?.Code;
|
||||
wfName = wf?.Name;
|
||||
}
|
||||
|
||||
var levelIds = p.LevelOpinions.Select(o => o.ApprovalWorkflowLevelId).Distinct().ToList();
|
||||
var levels = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.Levels.Any(l => levelIds.Contains(l.Id)))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Order,
|
||||
s.Name,
|
||||
Levels = s.Levels.Where(l => levelIds.Contains(l.Id))
|
||||
.Select(l => new { l.Id, l.Order, l.ApproverUserId })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var levelLookup = levels.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
|
||||
.ToDictionary(x => x.Level.Id);
|
||||
|
||||
var opinions = p.LevelOpinions
|
||||
.Select(o =>
|
||||
{
|
||||
levelLookup.TryGetValue(o.ApprovalWorkflowLevelId, out var lvl);
|
||||
return new VehicleBookingLevelOpinionDto(
|
||||
o.Id,
|
||||
o.ApprovalWorkflowLevelId,
|
||||
lvl?.Step.Order,
|
||||
lvl?.Step.Name,
|
||||
lvl?.Level.Order,
|
||||
lvl?.Level.ApproverUserId,
|
||||
o.Comment,
|
||||
o.SignedAt,
|
||||
o.SignedByUserId,
|
||||
o.SignedByFullName);
|
||||
})
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
return new VehicleBookingDetailDto(
|
||||
p.Id, p.MaDonTu, p.RequesterUserId, p.RequesterFullName,
|
||||
p.VehicleLicense, p.VehicleName, p.StartAt, p.EndAt, p.Destination, p.Purpose, p.DriverName,
|
||||
(int)p.Status, p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
|
||||
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
|
||||
p.CreatedAt, opinions);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateVehicleBookingDraftCommand(
|
||||
Guid Id,
|
||||
string VehicleLicense,
|
||||
string? VehicleName,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Destination,
|
||||
string Purpose,
|
||||
string? DriverName,
|
||||
Guid? ApprovalWorkflowId) : IRequest;
|
||||
|
||||
public class UpdateVehicleBookingDraftValidator : AbstractValidator<UpdateVehicleBookingDraftCommand>
|
||||
{
|
||||
public UpdateVehicleBookingDraftValidator()
|
||||
{
|
||||
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateVehicleBookingDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<UpdateVehicleBookingDraftCommand>
|
||||
{
|
||||
public async Task Handle(UpdateVehicleBookingDraftCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được sửa đơn đặt xe.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
if (req.ApprovalWorkflowId.HasValue && req.ApprovalWorkflowId != p.ApprovalWorkflowId)
|
||||
{
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == req.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
|
||||
}
|
||||
|
||||
p.VehicleLicense = req.VehicleLicense.Trim();
|
||||
p.VehicleName = req.VehicleName?.Trim();
|
||||
p.StartAt = req.StartAt;
|
||||
p.EndAt = req.EndAt;
|
||||
p.Destination = req.Destination.Trim();
|
||||
p.Purpose = req.Purpose.Trim();
|
||||
p.DriverName = req.DriverName?.Trim();
|
||||
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SubmitVehicleBookingCommand(Guid Id) : IRequest;
|
||||
|
||||
public class SubmitVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<SubmitVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(SubmitVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
|
||||
var isOwner = p.RequesterUserId == currentUser.UserId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người tạo hoặc Admin được gửi duyệt.");
|
||||
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
|
||||
throw new ConflictException("Chỉ gửi duyệt được khi trạng thái Nháp hoặc Trả lại.");
|
||||
if (!p.ApprovalWorkflowId.HasValue)
|
||||
throw new ConflictException("Chưa chọn quy trình duyệt.");
|
||||
|
||||
var wfType = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Where(w => w.Id == p.ApprovalWorkflowId.Value)
|
||||
.Select(w => (int?)w.ApplicableType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (wfType is null)
|
||||
throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
|
||||
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
|
||||
|
||||
if (string.IsNullOrEmpty(p.MaDonTu))
|
||||
{
|
||||
p.MaDonTu = await TravelVehicleCodeGen.GenerateMaDonTuAsync(
|
||||
db, $"DX/XE/{clock.Now.Year}", clock, ct);
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = 1;
|
||||
p.RejectedFromStatus = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ApproveVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ApproveVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ApproveVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(ApproveVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is null) throw new UnauthorizedException();
|
||||
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được khi trạng thái Đã gửi duyệt.");
|
||||
if (!p.ApprovalWorkflowId.HasValue || !p.CurrentApprovalLevelOrder.HasValue)
|
||||
throw new ConflictException("Quy trình duyệt chưa pin hoặc thiếu cấp hiện tại.");
|
||||
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
if (wf is null) throw new NotFoundException("ApprovalWorkflow", p.ApprovalWorkflowId.Value);
|
||||
|
||||
var allLevels = wf.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order).Select(l => new { Step = s, Level = l }))
|
||||
.ToList();
|
||||
if (allLevels.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt không có cấp duyệt.");
|
||||
|
||||
var currentSlot = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentSlot is null)
|
||||
throw new ConflictException($"Cấp duyệt {p.CurrentApprovalLevelOrder.Value} không tồn tại trong quy trình.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && currentSlot.Level.ApproverUserId != currentUser.UserId.Value)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
|
||||
var existing = await db.VehicleBookingLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.VehicleBookingId == p.Id && o.ApprovalWorkflowLevelId == currentSlot.Level.Id, ct);
|
||||
var commentFinal = string.IsNullOrWhiteSpace(req.Comment)
|
||||
? "(duyệt — không ý kiến)"
|
||||
: req.Comment.Trim();
|
||||
if (existing is null)
|
||||
{
|
||||
db.VehicleBookingLevelOpinions.Add(new VehicleBookingLevelOpinion
|
||||
{
|
||||
VehicleBookingId = p.Id,
|
||||
ApprovalWorkflowLevelId = currentSlot.Level.Id,
|
||||
Comment = commentFinal,
|
||||
SignedAt = clock.UtcNow,
|
||||
SignedByUserId = currentUser.UserId.Value,
|
||||
SignedByFullName = currentUser.FullName ?? "(unknown)",
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = currentUser.UserId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Comment = commentFinal;
|
||||
existing.SignedAt = clock.UtcNow;
|
||||
existing.SignedByUserId = currentUser.UserId.Value;
|
||||
existing.SignedByFullName = currentUser.FullName ?? "(unknown)";
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = currentUser.UserId;
|
||||
}
|
||||
|
||||
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
|
||||
{
|
||||
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record RejectVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class RejectVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<RejectVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(RejectVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TuChoi;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ReturnVehicleBookingCommand(Guid Id, string? Comment) : IRequest;
|
||||
|
||||
public class ReturnVehicleBookingHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
|
||||
: IRequestHandler<ReturnVehicleBookingCommand>
|
||||
{
|
||||
public async Task Handle(ReturnVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
var p = await db.VehicleBookings.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (p is null) throw new NotFoundException("VehicleBooking", req.Id);
|
||||
if (p.Status != WorkflowAppStatus.DaGuiDuyet)
|
||||
throw new ConflictException("Chỉ trả lại được khi đang trong workflow duyệt.");
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isAdmin && p.CurrentApprovalLevelOrder.HasValue && p.ApprovalWorkflowId.HasValue)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == p.ApprovalWorkflowId.Value, ct);
|
||||
var allLevels = wf?.Steps.OrderBy(s => s.Order)
|
||||
.SelectMany(s => s.Levels.OrderBy(l => l.Order))
|
||||
.ToList() ?? new();
|
||||
var currentLevel = allLevels.ElementAtOrDefault(p.CurrentApprovalLevelOrder.Value - 1);
|
||||
if (currentLevel?.ApproverUserId != currentUser.UserId)
|
||||
throw new ForbiddenException("Không phải người duyệt của cấp này.");
|
||||
}
|
||||
|
||||
p.Status = WorkflowAppStatus.TraLai;
|
||||
p.RejectedFromStatus = WorkflowAppStatus.DaGuiDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = currentUser.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shared CodeGen helper — Prefix-keyed WorkflowAppCodeSequences (SERIALIZABLE tx).
|
||||
// Mirror SubmitProposalHandler.GenerateMaDeXuatAsync. Format: {prefix}/{seq:D3}.
|
||||
// Travel prefix = "DT/CT/{year}"
|
||||
// Vehicle prefix = "DX/XE/{year}"
|
||||
// =========================================================================
|
||||
|
||||
internal static class TravelVehicleCodeGen
|
||||
{
|
||||
internal static async Task<string> GenerateMaDonTuAsync(
|
||||
IApplicationDbContext db, string prefix, IDateTime clock, CancellationToken ct)
|
||||
{
|
||||
var dbContext = (DbContext)db;
|
||||
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
var seq = await db.WorkflowAppCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
|
||||
if (seq is null)
|
||||
{
|
||||
seq = new WorkflowAppCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
|
||||
db.WorkflowAppCodeSequences.Add(seq);
|
||||
}
|
||||
seq.LastSeq++;
|
||||
seq.UpdatedAt = clock.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return $"{prefix}/{seq.LastSeq:D3}";
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho LeaveRequest.
|
||||
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||
//
|
||||
// Mỗi row = 1 (LeaveRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||
//
|
||||
// UNIQUE composite (LeaveRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||
// FK Cascade LeaveRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||
public class LeaveRequestLevelOpinion : AuditableEntity
|
||||
{
|
||||
public Guid LeaveRequestId { get; set; }
|
||||
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||
|
||||
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||
public DateTime SignedAt { get; set; }
|
||||
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||
|
||||
public LeaveRequest? LeaveRequest { get; set; }
|
||||
public ApprovalWorkflowLevel? Level { get; set; }
|
||||
}
|
||||
@ -19,4 +19,7 @@ public class OtRequest : AuditableEntity
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public 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();
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho OtRequest.
|
||||
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||
//
|
||||
// Mỗi row = 1 (OtRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||
//
|
||||
// UNIQUE composite (OtRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||
// FK Cascade OtRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||
public class OtRequestLevelOpinion : AuditableEntity
|
||||
{
|
||||
public Guid OtRequestId { get; set; }
|
||||
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||
|
||||
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||
public DateTime SignedAt { get; set; }
|
||||
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||
|
||||
public OtRequest? OtRequest { get; set; }
|
||||
public ApprovalWorkflowLevel? Level { get; set; }
|
||||
}
|
||||
@ -18,4 +18,7 @@ public class TravelRequest : AuditableEntity
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public 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();
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho TravelRequest.
|
||||
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||
//
|
||||
// Mỗi row = 1 (TravelRequest × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||
//
|
||||
// UNIQUE composite (TravelRequestId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||
// FK Cascade TravelRequest (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||
public class TravelRequestLevelOpinion : AuditableEntity
|
||||
{
|
||||
public Guid TravelRequestId { get; set; }
|
||||
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||
|
||||
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||
public DateTime SignedAt { get; set; }
|
||||
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||
|
||||
public TravelRequest? TravelRequest { get; set; }
|
||||
public ApprovalWorkflowLevel? Level { get; set; }
|
||||
}
|
||||
@ -20,4 +20,7 @@ public class VehicleBooking : AuditableEntity
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public 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();
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 11 P11-A (Mig 41) — Ý kiến cấp duyệt V2 dynamic cho VehicleBooking.
|
||||
// Cookie-cutter mirror ProposalLevelOpinion (Mig 38).
|
||||
//
|
||||
// Mỗi row = 1 (VehicleBooking × ApprovalWorkflowLevel). Service ApproveV2Async sau
|
||||
// khi approve thành công Cấp hiện tại sẽ UPSERT row này (latest-write-wins).
|
||||
// Reject (TraLai/TuChoi) KHÔNG sync.
|
||||
//
|
||||
// UNIQUE composite (VehicleBookingId, ApprovalWorkflowLevelId) — 1 row / level / đơn.
|
||||
// FK Cascade VehicleBooking (wipe khi xoá) + Restrict Level (admin xoá Level chặn).
|
||||
// SignedByUserId track actor thật (có thể Admin override) + denorm FullName.
|
||||
public class VehicleBookingLevelOpinion : AuditableEntity
|
||||
{
|
||||
public Guid VehicleBookingId { get; set; }
|
||||
public Guid ApprovalWorkflowLevelId { get; set; }
|
||||
|
||||
public string? Comment { get; set; } // max 2000 hoặc placeholder
|
||||
public DateTime SignedAt { get; set; }
|
||||
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể Admin thay)
|
||||
public string SignedByFullName { get; set; } = string.Empty; // snapshot denorm
|
||||
|
||||
public VehicleBooking? VehicleBooking { get; set; }
|
||||
public ApprovalWorkflowLevel? Level { get; set; }
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 11 P11-A (Mig 41) — Sequence generator dùng chung cho mã đơn từ (MaDonTu)
|
||||
// của 4 WorkflowApps module (Leave / OT / Travel / VehicleBooking).
|
||||
// Mirror ProposalCodeSequence pattern (Prefix string PK + LastSeq atomic).
|
||||
//
|
||||
// Prefix-keyed per module per năm, vd:
|
||||
// "DT/LR/2026" (Leave) → "DT/LR/2026/001" → "DT/LR/2026/002" → ...
|
||||
// "DT/OT/2026" (OT)
|
||||
// "DT/CT/2026" (Travel — Công tác)
|
||||
// "DX/XE/2026" (VehicleBooking — Đặt xe)
|
||||
// LastSeq reset đầu năm tự nhiên (key Prefix mới). Update atomic qua
|
||||
// SERIALIZABLE transaction trong CodeGen service.
|
||||
public class WorkflowAppCodeSequence
|
||||
{
|
||||
public string Prefix { get; set; } = string.Empty; // PK — "DT/LR/2026"
|
||||
public int LastSeq { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@ -115,6 +115,13 @@ public class ApplicationDbContext
|
||||
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
|
||||
public DbSet<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>();
|
||||
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho LeaveRequest.
|
||||
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||
public class LeaveRequestLevelOpinionConfiguration : IEntityTypeConfiguration<LeaveRequestLevelOpinion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LeaveRequestLevelOpinion> e)
|
||||
{
|
||||
e.ToTable("LeaveRequestLevelOpinions");
|
||||
|
||||
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||
|
||||
e.HasOne(x => x.LeaveRequest)
|
||||
.WithMany(p => p.LevelOpinions)
|
||||
.HasForeignKey(x => x.LeaveRequestId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasOne(x => x.Level)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.LeaveRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho OtRequest.
|
||||
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||
public class OtRequestLevelOpinionConfiguration : IEntityTypeConfiguration<OtRequestLevelOpinion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OtRequestLevelOpinion> e)
|
||||
{
|
||||
e.ToTable("OtRequestLevelOpinions");
|
||||
|
||||
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||
|
||||
e.HasOne(x => x.OtRequest)
|
||||
.WithMany(p => p.LevelOpinions)
|
||||
.HasForeignKey(x => x.OtRequestId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasOne(x => x.Level)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.OtRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho TravelRequest.
|
||||
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||
public class TravelRequestLevelOpinionConfiguration : IEntityTypeConfiguration<TravelRequestLevelOpinion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TravelRequestLevelOpinion> e)
|
||||
{
|
||||
e.ToTable("TravelRequestLevelOpinions");
|
||||
|
||||
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||
|
||||
e.HasOne(x => x.TravelRequest)
|
||||
.WithMany(p => p.LevelOpinions)
|
||||
.HasForeignKey(x => x.TravelRequestId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasOne(x => x.Level)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.TravelRequestId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 41 P11-A (Phase 11) — Ý kiến cấp duyệt V2 dynamic cho VehicleBooking.
|
||||
// Cookie-cutter mirror ProposalLevelOpinionConfiguration (Mig 38).
|
||||
public class VehicleBookingLevelOpinionConfiguration : IEntityTypeConfiguration<VehicleBookingLevelOpinion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<VehicleBookingLevelOpinion> e)
|
||||
{
|
||||
e.ToTable("VehicleBookingLevelOpinions");
|
||||
|
||||
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||
|
||||
e.HasOne(x => x.VehicleBooking)
|
||||
.WithMany(p => p.LevelOpinions)
|
||||
.HasForeignKey(x => x.VehicleBookingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasOne(x => x.Level)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.VehicleBookingId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 41 P11-A (Phase 11) — Sequence generator dùng chung cho mã đơn từ 4
|
||||
// WorkflowApps module (Leave/OT/Travel/VehicleBooking). Mirror
|
||||
// ProposalCodeSequenceConfiguration pattern. PK = Prefix (string), LastSeq atomic.
|
||||
public class WorkflowAppCodeSequenceConfiguration : IEntityTypeConfiguration<WorkflowAppCodeSequence>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkflowAppCodeSequence> e)
|
||||
{
|
||||
e.ToTable("WorkflowAppCodeSequences");
|
||||
|
||||
e.HasKey(x => x.Prefix);
|
||||
e.Property(x => x.Prefix).HasMaxLength(20); // "DT/LR/2026" max ~10 chars OK
|
||||
}
|
||||
}
|
||||
@ -137,6 +137,13 @@ public static class DbInitializer
|
||||
// NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed.
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class WireWorkflowAppsApprovalV2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RejectedFromStatus",
|
||||
table: "VehicleBookings",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RejectedFromStatus",
|
||||
table: "TravelRequests",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RejectedFromStatus",
|
||||
table: "OtRequests",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RejectedFromStatus",
|
||||
table: "LeaveRequests",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LeaveRequestLevelOpinions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
LeaveRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LeaveRequestLevelOpinions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LeaveRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||
column: x => x.ApprovalWorkflowLevelId,
|
||||
principalTable: "ApprovalWorkflowLevels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_LeaveRequestLevelOpinions_LeaveRequests_LeaveRequestId",
|
||||
column: x => x.LeaveRequestId,
|
||||
principalTable: "LeaveRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtRequestLevelOpinions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
OtRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OtRequestLevelOpinions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||
column: x => x.ApprovalWorkflowLevelId,
|
||||
principalTable: "ApprovalWorkflowLevels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_OtRequestLevelOpinions_OtRequests_OtRequestId",
|
||||
column: x => x.OtRequestId,
|
||||
principalTable: "OtRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TravelRequestLevelOpinions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
TravelRequestId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TravelRequestLevelOpinions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TravelRequestLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||
column: x => x.ApprovalWorkflowLevelId,
|
||||
principalTable: "ApprovalWorkflowLevels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TravelRequestLevelOpinions_TravelRequests_TravelRequestId",
|
||||
column: x => x.TravelRequestId,
|
||||
principalTable: "TravelRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VehicleBookingLevelOpinions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
VehicleBookingId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VehicleBookingLevelOpinions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VehicleBookingLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||
column: x => x.ApprovalWorkflowLevelId,
|
||||
principalTable: "ApprovalWorkflowLevels",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VehicleBookingLevelOpinions_VehicleBookings_VehicleBookingId",
|
||||
column: x => x.VehicleBookingId,
|
||||
principalTable: "VehicleBookings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkflowAppCodeSequences",
|
||||
columns: table => new
|
||||
{
|
||||
Prefix = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
LastSeq = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkflowAppCodeSequences", x => x.Prefix);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeaveRequestLevelOpinions_ApprovalWorkflowLevelId",
|
||||
table: "LeaveRequestLevelOpinions",
|
||||
column: "ApprovalWorkflowLevelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeaveRequestLevelOpinions_LeaveRequestId_ApprovalWorkflowLevelId",
|
||||
table: "LeaveRequestLevelOpinions",
|
||||
columns: new[] { "LeaveRequestId", "ApprovalWorkflowLevelId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtRequestLevelOpinions_ApprovalWorkflowLevelId",
|
||||
table: "OtRequestLevelOpinions",
|
||||
column: "ApprovalWorkflowLevelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtRequestLevelOpinions_OtRequestId_ApprovalWorkflowLevelId",
|
||||
table: "OtRequestLevelOpinions",
|
||||
columns: new[] { "OtRequestId", "ApprovalWorkflowLevelId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TravelRequestLevelOpinions_ApprovalWorkflowLevelId",
|
||||
table: "TravelRequestLevelOpinions",
|
||||
column: "ApprovalWorkflowLevelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TravelRequestLevelOpinions_TravelRequestId_ApprovalWorkflowLevelId",
|
||||
table: "TravelRequestLevelOpinions",
|
||||
columns: new[] { "TravelRequestId", "ApprovalWorkflowLevelId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VehicleBookingLevelOpinions_ApprovalWorkflowLevelId",
|
||||
table: "VehicleBookingLevelOpinions",
|
||||
column: "ApprovalWorkflowLevelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VehicleBookingLevelOpinions_VehicleBookingId_ApprovalWorkflowLevelId",
|
||||
table: "VehicleBookingLevelOpinions",
|
||||
columns: new[] { "VehicleBookingId", "ApprovalWorkflowLevelId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LeaveRequestLevelOpinions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtRequestLevelOpinions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TravelRequestLevelOpinions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VehicleBookingLevelOpinions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkflowAppCodeSequences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RejectedFromStatus",
|
||||
table: "VehicleBookings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RejectedFromStatus",
|
||||
table: "TravelRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RejectedFromStatus",
|
||||
table: "OtRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RejectedFromStatus",
|
||||
table: "LeaveRequests");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3676,6 +3676,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(1000)
|
||||
.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");
|
||||
|
||||
Reference in New Issue
Block a user