[CLAUDE] Workflow: fix workflow picker 2 bug (P11-A Max re-review) + SetWorkflow endpoint
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m5s

Double-check chất lượng P11-A ở Max (agents trước chạy High + truncate 3×) →
phát hiện 2 bug THẬT trong workflow-picker FE của WorkflowAppDetailPage (core
approve/reject/return ĐÚNG, chỉ sub-flow chọn quy trình hỏng):

Bug #1 (HIGH) — pinWorkflow PUT /{id} chỉ gửi {approvalWorkflowId} → UpdateDraft
  validator (Reason NotEmpty, NumDays>0...) fail → 400. Nút "Lưu quy trình" vỡ.
Bug #2 (HIGH) — fetch workflow expect flat array nhưng endpoint trả
  AwAdminOverviewDto {types:[...]} → picker rỗng/crash. FE copy nhầm pattern hỏng
  của ProposalCreatePage thay vì PE/Contract proven.

Fix:
- BE: thêm endpoint chuyên dụng PUT /{id}/workflow + Set{Module}WorkflowCommand/Handler
  cho 4 module — chỉ set ApprovalWorkflowId trên draft Nhap/TraLai (verify ApplicableType
  per module), KHÔNG validate field khác. Single-responsibility, bulletproof.
- FE: sửa fetch mirror PE/Contract (data.types.find(t=>t.applicableType===X)?.history
  .filter(isUserSelectable)) + pin gọi endpoint mới. fe-admin+fe-user SHA256 identical.
- Test: +3 SetWorkflow (happy no-status-change / wrong ApplicableType Conflict / submitted
  guard) → 141→144 PASS.

Verify: BE build 0 error · 144 test PASS · FE build ×2 · SHA256 identical.
Bonus phát hiện: ProposalCreatePage (S37) có bug #2 có sẵn (latent, chưa exercise UAT)
  → flag spawn task riêng, KHÔNG fix trong commit này.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-30 10:14:34 +07:00
parent e7b66cd52b
commit 75df04ec82
11 changed files with 302 additions and 31 deletions

View File

@ -64,6 +64,15 @@ public class LeaveRequestsController(IMediator mediator) : ControllerBase
return NoContent();
}
[HttpPut("{id:guid}/workflow")]
public async Task<IActionResult> SetWorkflow(Guid id, [FromBody] SetWorkflowBody body)
{
await mediator.Send(new SetLeaveRequestWorkflowCommand(id, body.ApprovalWorkflowId));
return NoContent();
}
public record SetWorkflowBody(Guid ApprovalWorkflowId);
public record UpdateLeaveRequestDraftBody(
Guid LeaveTypeId,
DateTime StartDate,

View File

@ -64,6 +64,15 @@ public class OtRequestsController(IMediator mediator) : ControllerBase
return NoContent();
}
[HttpPut("{id:guid}/workflow")]
public async Task<IActionResult> SetWorkflow(Guid id, [FromBody] SetWorkflowBody body)
{
await mediator.Send(new SetOtRequestWorkflowCommand(id, body.ApprovalWorkflowId));
return NoContent();
}
public record SetWorkflowBody(Guid ApprovalWorkflowId);
public record UpdateOtRequestDraftBody(
DateTime OtDate,
TimeSpan StartTime,

View File

@ -64,6 +64,15 @@ public class TravelRequestsController(IMediator mediator) : ControllerBase
return NoContent();
}
[HttpPut("{id:guid}/workflow")]
public async Task<IActionResult> SetWorkflow(Guid id, [FromBody] SetWorkflowBody body)
{
await mediator.Send(new SetTravelRequestWorkflowCommand(id, body.ApprovalWorkflowId));
return NoContent();
}
public record SetWorkflowBody(Guid ApprovalWorkflowId);
public record UpdateTravelRequestBody(
string Destination,
DateTime StartDate,

View File

@ -64,6 +64,15 @@ public class VehicleBookingsController(IMediator mediator) : ControllerBase
return NoContent();
}
[HttpPut("{id:guid}/workflow")]
public async Task<IActionResult> SetWorkflow(Guid id, [FromBody] SetWorkflowBody body)
{
await mediator.Send(new SetVehicleBookingWorkflowCommand(id, body.ApprovalWorkflowId));
return NoContent();
}
public record SetWorkflowBody(Guid ApprovalWorkflowId);
public record UpdateVehicleBookingBody(
string VehicleLicense,
string? VehicleName,

View File

@ -395,6 +395,69 @@ public class ReturnLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu
}
}
// =========================================================================
// Set workflow (pin quy trình cho draft) — Phase 11 P11-A fix S42.
// Endpoint riêng PUT /{id}/workflow: chỉ set ApprovalWorkflowId trên draft
// Nhap/TraLai (verify ApplicableType). KHÔNG validate field khác (khác UpdateDraft —
// tránh 400 khi FE chỉ gửi workflowId). Single-responsibility: chọn quy trình.
// =========================================================================
public record SetLeaveRequestWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest;
public class SetLeaveRequestWorkflowHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<SetLeaveRequestWorkflowCommand>
{
public async Task Handle(SetLeaveRequestWorkflowCommand 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 chọn quy trình.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId)
.Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct);
if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId);
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.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record SetOtRequestWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest;
public class SetOtRequestWorkflowHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<SetOtRequestWorkflowCommand>
{
public async Task Handle(SetOtRequestWorkflowCommand 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 chọn quy trình.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId)
.Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct);
if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.OtRequest)
throw new ConflictException("Quy trình duyệt không thuộc loại Đơn OT.");
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
// =========================================================================
// MODULE B: OtRequest (ApplicableType=6, prefix "DT/OT")
// =========================================================================

View File

@ -744,6 +744,68 @@ public class ReturnVehicleBookingHandler(IApplicationDbContext db, ICurrentUser
}
}
// =========================================================================
// Set workflow (pin quy trình cho draft) — Phase 11 P11-A fix S42.
// Endpoint riêng PUT /{id}/workflow: chỉ set ApprovalWorkflowId trên draft
// Nhap/TraLai (verify ApplicableType). KHÔNG validate field khác (khác UpdateDraft).
// =========================================================================
public record SetTravelRequestWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest;
public class SetTravelRequestWorkflowHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<SetTravelRequestWorkflowCommand>
{
public async Task Handle(SetTravelRequestWorkflowCommand 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 chọn quy trình.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId)
.Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct);
if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId);
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.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record SetVehicleBookingWorkflowCommand(Guid Id, Guid ApprovalWorkflowId) : IRequest;
public class SetVehicleBookingWorkflowHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<SetVehicleBookingWorkflowCommand>
{
public async Task Handle(SetVehicleBookingWorkflowCommand 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 chọn quy trình.");
if (p.Status != WorkflowAppStatus.Nhap && p.Status != WorkflowAppStatus.TraLai)
throw new ConflictException("Chỉ chọn quy trình khi trạng thái Nháp hoặc Trả lại.");
var wfType = await db.ApprovalWorkflows.AsNoTracking()
.Where(w => w.Id == req.ApprovalWorkflowId)
.Select(w => (int?)w.ApplicableType).FirstOrDefaultAsync(ct);
if (wfType is null) throw new NotFoundException("ApprovalWorkflow", req.ApprovalWorkflowId);
if (wfType.Value != (int)ApprovalWorkflowApplicableType.VehicleBooking)
throw new ConflictException("Quy trình duyệt không thuộc loại Đặt xe.");
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
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}.