[CLAUDE] Workflow: wire ApproveV2 + LevelOpinions cho 4 WorkflowApps module (Phase 11 P11-A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m6s

Wire full approval workflow V2 cho Leave/OT/Travel/Vehicle — cookie-cutter
mirror Proposal (Mig 38). Trước đây skeleton Phase 1 (Create+List), giờ
ApproveV2 advance-level + UPSERT LevelOpinion + atomic codegen.

Schema (Mig 41 WireWorkflowAppsApprovalV2 — 84→89 tables, pure additive):
- 4 bảng {Leave,Ot,Travel,Vehicle}LevelOpinions (UNIQUE composite + Cascade
  parent + Restrict Level — mirror ProposalLevelOpinion)
- 1 bảng WorkflowAppCodeSequences (shared atomic MaDonTu, Prefix-keyed)
- 4 cột RejectedFromStatus (smart return tracking)
- enum ApprovalWorkflowApplicableType.TravelRequest = 9

Application (LeaveOt + TravelVehicle ApprovalFeatures.cs — 30 handler):
- GetById detail (Include LevelOpinions + JOIN Step/Level) · UpdateDraft
- Submit (gen MaDonTu + DaGuiDuyet + level=1, verify ApplicableType per module)
- Approve (verify actor==ApproverUserId OR Admin, UPSERT opinion latest-write-wins,
  advance level OR terminal DaDuyet, empty comment → placeholder)
- Reject (→TuChoi) · Return (→TraLai + RejectedFromStatus)

Api: 4 controller +6 route mỗi cái (GET/{id}, PUT/{id}, submit/approve/reject/return)
Infra: DbInitializer seed 4 workflow V2 mẫu (QT-NP/OT/CT/XE-V2-001) → UAT test ngay
FE: WorkflowAppDetailPage.tsx declarative 4-kind (fe-admin+fe-user SHA256 identical)
  — workflow status + opinion timeline + action buttons; gỡ banner skeleton + row nav

Tests: +11 WorkflowAppApproveV2Tests (130→141 PASS) — state machine + UPSERT
  invariant + guards + codegen + forbidden + placeholder (Leave full + Ot smoke)

Verify: build 0 error · 141 test PASS · FE build ×2 · reviewer checklist
  (ApplicableType per-module + cross-module DbSet + [Authorize] — no copy-paste bug)
Known-minor (unreachable): Reject/Return actor-check skip nếu CurrentApprovalLevelOrder
  null — nhưng DaGuiDuyet luôn có set (defer hardening).
ItTicket KHÔNG đụng (kanban, no workflow V2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-30 09:44:00 +07:00
parent ad1dea9349
commit e7b66cd52b
39 changed files with 10604 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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