[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S37 Mig 37 enum + Plan G-O3 Đề xuất full-stack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s

Phase 10.3 G-O3 Đề xuất (Proposal) — Mig 37 enum extend +5 values + Mig 38
Proposal schema + BE CQRS 8 endpoint + FE 2 app SHA256 IDENTICAL.

Mig 37 (em main solo): extend ApprovalWorkflowApplicableType enum +5 values
  ProposalGeneral=4 / LeaveRequest=5 / OtRequest=6 / VehicleBooking=7 / ItTicket=8
  cookie-cutter Mig 22 pattern (Up/Down empty — enum mức Domain).

Mig 38 (em main solo): 4 entity Proposal (Code DX/YYYY/NNN) + ProposalAttachment
  + ProposalLevelOpinion (UNIQUE composite PEId+LevelId mirror PE Mig 26) +
  ProposalCodeSequence (Prefix PK atomic seq). 4 EF Config + 2 DbContext mod.

BE CQRS (em main solo ~700 LOC ProposalFeatures.cs sau Implementer truncate phase
exploration gotcha #53 5th + 529 Overload):
  - 4 Header handler (List paged + GetById detail + Create + UpdateDraft owner-OR-admin)
  - 4 Workflow handler (Submit gen MaDeXuat atomic + Approve UPSERT LevelOpinion advance + Reject + Return)
  - SERIALIZABLE transaction CodeGen
  - DTOs nested LevelOpinion với Step+Level metadata JOIN

ProposalsController 8 endpoint /api/proposals (List/GetById/Create/Update/Submit/Approve/Reject/Return)
class-level [Authorize] + handler-level owner-OR-admin guard.

DbInitializer: SeedSampleProposalWorkflowV2Async ~40 LOC seed QT-DX-V2-001 IsUserSelectable=true
NOT gated DemoSeed per gotcha #51. SeedMenuTreeAsync +4 row (Off_DeXuat sub-group + 3 leaf).

FE 2 app (em main solo + Implementer 529 fail fallback):
  - types/proposal.ts × 2 SHA256 IDENTICAL 95607052ff1138f2
  - ProposalsListPage.tsx × 2 IDENTICAL 603f0d9cf74cd09a — table 6 cột + Status badge + filter
  - ProposalCreatePage.tsx × 2 IDENTICAL 6aed3a76563dd576 — Form Header card
  - ProposalDetailPage.tsx × 2 IDENTICAL 3dc229ea8dcc9bc0 — 3 Section + WorkflowActions
  - Pattern 16-bis 8× cumulative (App.tsx + menuKeys + Layout staticMap 3 entry)

Verify:
- dotnet build PASS 0 error 2 warning pre-existing DocxRenderer
- dotnet test 130/130 PASS baseline preserve
- npm build × 2 PASS (fe-admin 14.72s + fe-user 6.40s)
- SHA256 verify 4 file × 2 app all IDENTICAL

Pattern reinforced cumulative S37:
- Pattern 12-bis cross-module mirror 11× (PE V2 → Proposal V2 ApproveV2)
- Pattern 16-bis 4-place mirror cross-app 8×
- gotcha #53 5th occurrence Implementer mid-exploration truncation + 529 Overload 1× — em main solo fallback proven

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 15:51:14 +07:00
parent 37593f95b5
commit de1c378279
35 changed files with 13650 additions and 0 deletions

View File

@ -111,5 +111,13 @@ public interface IApplicationDbContext
DbSet<MeetingBooking> MeetingBookings { get; }
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
// Phase 10.3 G-O3 (Mig 38 — S37) — Đề xuất (Proposal) cookie-cutter mirror PE.
// Workflow V2 dynamic ApplicableType=ProposalGeneral=4 (Mig 37 enum extend).
// CodeGen "DX/YYYY/NNN" atomic via ProposalCodeSequences.
DbSet<Proposal> Proposals { get; }
DbSet<ProposalAttachment> ProposalAttachments { get; }
DbSet<ProposalLevelOpinion> ProposalLevelOpinions { get; }
DbSet<ProposalCodeSequence> ProposalCodeSequences { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,556 @@
using System.Data;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
// Phase 10.3 G-O3 (S37 2026-05-28) — Đề xuất (Proposal) CQRS + Workflow V2 ApproveV2.
// Cookie-cutter mirror PurchaseEvaluation Plan B Chunk D pattern (Mig 22-26 V2).
// ApplicableType=ProposalGeneral=4 (Mig 37 enum extend).
//
// 8 endpoint qua ProposalsController:
// GET /api/proposals — list paged (filter status/dept/drafter/inbox)
// GET /api/proposals/{id} — detail Include Attachments + LevelOpinions + Workflow
// POST /api/proposals — create Status=Nhap (MaDeXuat null tới Submit)
// PUT /api/proposals/{id} — update draft (Nhap or TraLai only)
// POST /api/proposals/{id}/submit — gen MaDeXuat atomic + Status=DaGuiDuyet
// POST /api/proposals/{id}/approve — ApproveV2: UPSERT LevelOpinion + advance level/terminal
// POST /api/proposals/{id}/reject — Status=TuChoi terminal (no opinion sync)
// POST /api/proposals/{id}/return — Status=TraLai (no opinion sync, Drafter resubmit từ Cấp 1)
// ===== DTOs =====
public record ProposalListItemDto(
Guid Id,
string? MaDeXuat,
string Title,
decimal? AmountEstimate,
int Status,
Guid? DepartmentId,
string? DepartmentName,
Guid DrafterUserId,
string? DrafterFullName,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
int? CurrentApprovalLevelOrder,
DateTime CreatedAt);
public record ProposalDetailDto(
Guid Id,
string? MaDeXuat,
string Title,
string? Description,
decimal? AmountEstimate,
int Status,
Guid? DepartmentId,
string? DepartmentName,
Guid DrafterUserId,
string? DrafterFullName,
Guid? ApprovalWorkflowId,
string? WorkflowCode,
string? WorkflowName,
int? CurrentApprovalLevelOrder,
int? RejectedFromStatus,
DateTime CreatedAt,
List<ProposalAttachmentDto> Attachments,
List<ProposalLevelOpinionDto> LevelOpinions);
public record ProposalAttachmentDto(
Guid Id,
string FileName,
string FilePath,
long FileSize,
string? MimeType,
Guid UploadedByUserId,
string UploadedByFullName,
DateTime CreatedAt);
public record ProposalLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int? StepOrder,
string? StepName,
int? LevelOrder,
Guid? ApproverUserId,
string? Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
// ===== Region 1: Header CRUD =====
public record GetProposalsQuery(
int? Status,
Guid? DepartmentId,
Guid? DrafterUserId,
bool InboxOnly = false,
int Page = 1,
int PageSize = 50,
string? Search = null) : IRequest<PagedResult<ProposalListItemDto>>;
public class GetProposalsHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<GetProposalsQuery, PagedResult<ProposalListItemDto>>
{
public async Task<PagedResult<ProposalListItemDto>> Handle(GetProposalsQuery req, CancellationToken ct)
{
var page = req.Page < 1 ? 1 : req.Page;
var pageSize = req.PageSize switch { < 1 => 50, > 200 => 200, _ => req.PageSize };
var q = db.Proposals.AsNoTracking().Where(p => !p.IsDeleted);
if (req.Status.HasValue)
q = q.Where(p => (int)p.Status == req.Status.Value);
if (req.DepartmentId.HasValue)
q = q.Where(p => p.DepartmentId == req.DepartmentId.Value);
if (req.DrafterUserId.HasValue)
q = q.Where(p => p.DrafterUserId == req.DrafterUserId.Value);
// Inbox = DaGuiDuyet phiếu có Cấp hiện tại match current user trong Workflow.
// Lite version: filter Status=DaGuiDuyet client-side resolve approver via Workflow join below.
if (req.InboxOnly && currentUser.UserId.HasValue)
{
q = q.Where(p => p.Status == ProposalStatus.DaGuiDuyet);
}
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim();
q = q.Where(p => p.Title.Contains(s) || (p.MaDeXuat != null && p.MaDeXuat.Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProposalListItemDto(
p.Id,
p.MaDeXuat,
p.Title,
p.AmountEstimate,
(int)p.Status,
p.DepartmentId,
db.Departments.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefault(),
p.DrafterUserId,
db.Users.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefault(),
p.ApprovalWorkflowId,
db.ApprovalWorkflows.Where(w => w.Id == p.ApprovalWorkflowId).Select(w => w.Code).FirstOrDefault(),
p.CurrentApprovalLevelOrder,
p.CreatedAt))
.ToListAsync(ct);
return new PagedResult<ProposalListItemDto>(items, total, page, pageSize);
}
}
public record GetProposalByIdQuery(Guid Id) : IRequest<ProposalDetailDto?>;
public class GetProposalByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetProposalByIdQuery, ProposalDetailDto?>
{
public async Task<ProposalDetailDto?> Handle(GetProposalByIdQuery req, CancellationToken ct)
{
var p = await db.Proposals.AsNoTracking()
.Include(x => x.Attachments)
.Include(x => x.LevelOpinions)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) return null;
var deptName = await db.Departments.AsNoTracking()
.Where(d => d.Id == p.DepartmentId).Select(d => d.Name).FirstOrDefaultAsync(ct);
var drafterName = await db.Users.AsNoTracking()
.Where(u => u.Id == p.DrafterUserId).Select(u => u.FullName).FirstOrDefaultAsync(ct);
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;
}
// Build LevelOpinions với Step+Level metadata via JOIN
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 ProposalLevelOpinionDto(
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();
var attachments = p.Attachments.Select(a => new ProposalAttachmentDto(
a.Id, a.FileName, a.FilePath, a.FileSize, a.MimeType,
a.UploadedByUserId, a.UploadedByFullName, a.CreatedAt)).ToList();
return new ProposalDetailDto(
p.Id, p.MaDeXuat, p.Title, p.Description, p.AmountEstimate, (int)p.Status,
p.DepartmentId, deptName, p.DrafterUserId, drafterName,
p.ApprovalWorkflowId, wfCode, wfName, p.CurrentApprovalLevelOrder,
p.RejectedFromStatus.HasValue ? (int)p.RejectedFromStatus.Value : (int?)null,
p.CreatedAt, attachments, opinions);
}
}
public record CreateProposalCommand(
string Title,
string? Description,
decimal? AmountEstimate,
Guid? DepartmentId,
Guid? ApprovalWorkflowId) : IRequest<Guid>;
public class CreateProposalValidator : AbstractValidator<CreateProposalCommand>
{
public CreateProposalValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(5000);
RuleFor(x => x.AmountEstimate).GreaterThanOrEqualTo(0).When(x => x.AmountEstimate.HasValue);
}
}
public class CreateProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<CreateProposalCommand, Guid>
{
public async Task<Guid> Handle(CreateProposalCommand req, CancellationToken ct)
{
if (currentUser.UserId is null)
throw new UnauthorizedException();
// Verify workflow ApplicableType=ProposalGeneral=4 nếu pin
if (req.ApprovalWorkflowId.HasValue)
{
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.ProposalGeneral)
throw new ConflictException("Quy trình duyệt không thuộc loại Đề xuất.");
}
var entity = new Proposal
{
Title = req.Title.Trim(),
Description = req.Description?.Trim(),
AmountEstimate = req.AmountEstimate,
DepartmentId = req.DepartmentId,
DrafterUserId = currentUser.UserId.Value,
ApprovalWorkflowId = req.ApprovalWorkflowId,
Status = ProposalStatus.Nhap,
CreatedAt = clock.UtcNow,
CreatedBy = currentUser.UserId,
};
db.Proposals.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateProposalDraftCommand(
Guid Id,
string Title,
string? Description,
decimal? AmountEstimate,
Guid? DepartmentId,
Guid? ApprovalWorkflowId) : IRequest;
public class UpdateProposalDraftValidator : AbstractValidator<UpdateProposalDraftCommand>
{
public UpdateProposalDraftValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(5000);
RuleFor(x => x.AmountEstimate).GreaterThanOrEqualTo(0).When(x => x.AmountEstimate.HasValue);
}
}
public class UpdateProposalDraftHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<UpdateProposalDraftCommand>
{
public async Task Handle(UpdateProposalDraftCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
// Only Drafter (or admin) can edit, only when Nhap/TraLai
var isOwner = p.DrafterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người soạn hoặc Admin được sửa đề xuất.");
if (p.Status != ProposalStatus.Nhap && p.Status != ProposalStatus.TraLai)
throw new ConflictException("Chỉ sửa được khi trạng thái Nháp hoặc Trả lại.");
// Verify workflow if changed
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.ProposalGeneral)
throw new ConflictException("Quy trình duyệt không thuộc loại Đề xuất.");
}
p.Title = req.Title.Trim();
p.Description = req.Description?.Trim();
p.AmountEstimate = req.AmountEstimate;
p.DepartmentId = req.DepartmentId;
p.ApprovalWorkflowId = req.ApprovalWorkflowId;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
// ===== Region 2: Workflow Actions =====
public record SubmitProposalCommand(Guid Id) : IRequest;
public class SubmitProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<SubmitProposalCommand>
{
public async Task Handle(SubmitProposalCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
var isOwner = p.DrafterUserId == currentUser.UserId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người soạn hoặc Admin được gửi duyệt.");
if (p.Status != ProposalStatus.Nhap && p.Status != ProposalStatus.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.");
// Gen MaDeXuat nếu null (lần Submit đầu tiên)
if (string.IsNullOrEmpty(p.MaDeXuat))
{
p.MaDeXuat = await GenerateMaDeXuatAsync(db, clock.Now.Year, clock, ct);
}
p.Status = ProposalStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = 1;
p.RejectedFromStatus = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
internal static async Task<string> GenerateMaDeXuatAsync(
IApplicationDbContext db, int year, IDateTime clock, CancellationToken ct)
{
var prefix = $"DX/{year}";
var dbContext = (DbContext)db;
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
var seq = await db.ProposalCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new ProposalCodeSequence { Prefix = prefix, LastSeq = 0, UpdatedAt = clock.UtcNow };
db.ProposalCodeSequences.Add(seq);
}
seq.LastSeq++;
seq.UpdatedAt = clock.UtcNow;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D3}";
}
}
public record ApproveProposalCommand(Guid Id, string? Comment) : IRequest;
public class ApproveProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ApproveProposalCommand>
{
public async Task Handle(ApproveProposalCommand req, CancellationToken ct)
{
if (currentUser.UserId is null) throw new UnauthorizedException();
var p = await db.Proposals
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
if (p.Status != ProposalStatus.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);
// Tìm Step hiện tại = chứa Level Order == CurrentApprovalLevelOrder
// Multi-step workflow: traverse step-by-step. Lite version: assume 1 step per workflow
// hoặc CurrentApprovalLevelOrder reset mỗi step (mirror PE design).
// Đây dùng global level order trong workflow (sum tất cả level cross steps).
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.");
// Verify actor match — must be ApproverUserId hoặc Admin override
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.");
// UPSERT LevelOpinion
var existing = await db.ProposalLevelOpinions
.FirstOrDefaultAsync(o => o.ProposalId == 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.ProposalLevelOpinions.Add(new ProposalLevelOpinion
{
ProposalId = 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;
}
// Advance level OR terminal
if (p.CurrentApprovalLevelOrder.Value < allLevels.Count)
{
p.CurrentApprovalLevelOrder = p.CurrentApprovalLevelOrder.Value + 1;
}
else
{
p.Status = ProposalStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record RejectProposalCommand(Guid Id, string? Comment) : IRequest;
public class RejectProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<RejectProposalCommand>
{
public async Task Handle(RejectProposalCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
if (p.Status != ProposalStatus.DaGuiDuyet)
throw new ConflictException("Chỉ từ chối được khi đang trong workflow duyệt.");
// Verify actor (lite — only check if has Admin role or matches any level approver)
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 = ProposalStatus.TuChoi;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
public record ReturnProposalCommand(Guid Id, string? Comment) : IRequest;
public class ReturnProposalHandler(IApplicationDbContext db, ICurrentUser currentUser, IDateTime clock)
: IRequestHandler<ReturnProposalCommand>
{
public async Task Handle(ReturnProposalCommand req, CancellationToken ct)
{
var p = await db.Proposals.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (p is null) throw new NotFoundException("Proposal", req.Id);
if (p.Status != ProposalStatus.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 = ProposalStatus.TraLai;
p.RejectedFromStatus = ProposalStatus.DaGuiDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}