[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
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:
@ -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);
|
||||
}
|
||||
|
||||
556
src/Backend/SolutionErp.Application/Office/ProposalFeatures.cs
Normal file
556
src/Backend/SolutionErp.Application/Office/ProposalFeatures.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user