diff --git a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs index 05897e9..9283400 100644 --- a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs @@ -46,6 +46,15 @@ public record AwDefinitionDto( string? Description, bool IsActive, bool IsUserSelectable, + // Mig 28 (S21 t4) — 6 advanced options của workflow per version. Admin + // Designer tick stick → checkbox. FE eOffice render dropdown / Skip / Edit + // conditional theo flag tương ứng. + bool AllowReturnOneLevel, + bool AllowReturnOneStep, + bool AllowReturnToAssignee, + bool AllowReturnToDrafter, + bool AllowDrafterSkipToFinal, + bool AllowApproverEditDetails, DateTime? ActivatedAt, DateTime CreatedAt, List Steps); @@ -128,6 +137,13 @@ public class GetAwAdminOverviewQueryHandler( d.Description, d.IsActive, d.IsUserSelectable, + // Mig 28 — 6 Allow* flag + d.AllowReturnOneLevel, + d.AllowReturnOneStep, + d.AllowReturnToAssignee, + d.AllowReturnToDrafter, + d.AllowDrafterSkipToFinal, + d.AllowApproverEditDetails, d.ActivatedAt, d.CreatedAt, d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto( @@ -178,7 +194,15 @@ public record CreateAwDefinitionCommand( string Code, string Name, string? Description, - List Steps) : IRequest; + List Steps, + // Mig 28 (S21 t4) — 6 Allow* options. Default = backward compat S17 + // (chỉ Trả về Drafter enabled). Admin tick stick để mở mode khác. + bool AllowReturnOneLevel = false, + bool AllowReturnOneStep = false, + bool AllowReturnToAssignee = false, + bool AllowReturnToDrafter = true, + bool AllowDrafterSkipToFinal = false, + bool AllowApproverEditDetails = false) : IRequest; public class CreateAwDefinitionCommandValidator : AbstractValidator { @@ -271,6 +295,13 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db) Description = request.Description, IsActive = true, IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick + // Mig 28 (S21 t4) — 6 Allow* options + AllowReturnOneLevel = request.AllowReturnOneLevel, + AllowReturnOneStep = request.AllowReturnOneStep, + AllowReturnToAssignee = request.AllowReturnToAssignee, + AllowReturnToDrafter = request.AllowReturnToDrafter, + AllowDrafterSkipToFinal = request.AllowDrafterSkipToFinal, + AllowApproverEditDetails = request.AllowApproverEditDetails, ActivatedAt = DateTime.UtcNow, Steps = request.Steps.OrderBy(s => s.Order) .Select(s => new ApprovalWorkflowStep diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs index 272a462..2ec43e3 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -78,6 +78,16 @@ public record PurchaseEvaluationChangelogDto( string? ContextNote, DateTime CreatedAt); +// Mig 28 (S21 t4) — 6 advanced options của workflow pin. FE filter Trả lại +// dropdown / Skip submit / Edit Section 2 enabled theo flag tương ứng. +public record ApprovalWorkflowOptionsDto( + bool AllowReturnOneLevel, + bool AllowReturnOneStep, + bool AllowReturnToAssignee, + bool AllowReturnToDrafter, + bool AllowDrafterSkipToFinal, + bool AllowApproverEditDetails); + public record PurchaseEvaluationWorkflowSummaryDto( string PolicyName, string PolicyDescription, @@ -194,6 +204,9 @@ public record PurchaseEvaluationDetailBundleDto( string? ApprovalWorkflowCode, string? ApprovalWorkflowName, int? ApprovalWorkflowVersion, + // Mig 28 (S21 t4) — 6 Allow* options của workflow pin. Null nếu phiếu V1 + // legacy. FE render Trả lại dropdown + Skip + Edit Section 2 conditional. + ApprovalWorkflowOptionsDto? WorkflowOptions, PurchaseEvaluationCurrentApprovalDto? CurrentApproval, PurchaseEvaluationApprovalFlowDto? ApprovalFlow, List Suppliers, diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs index 9f4ed4d..95ffa85 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs @@ -4,23 +4,96 @@ using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; using SolutionErp.Domain.PurchaseEvaluations; namespace SolutionErp.Application.PurchaseEvaluations; // ========== Helper: Lock edit guard (Phase 9 — Migration 16) ========== -// Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia. +// Original: Chỉ Phase=DangSoanThao mới được CRUD chi tiết / báo giá / NCC tham gia. // Đã trình duyệt → KHÔNG sửa được. Phải reject về DangSoanThao trước. +// +// Mig 28 (S21 t4 — F3): Extend Section 2 (Detail + NCC + Báo giá) cho phép +// Approver edit khi phase=ChoDuyet + workflow.AllowApproverEditDetails=true + +// actor.Id == currentLevel.ApproverUserId. KHÔNG đụng PE Header / Attachment / +// DepartmentOpinion — vẫn dùng EnsureDraftAsync strict. internal static class PurchaseEvaluationDraftGuard { + /// Strict guard cho PE Header / Attachment / DepartmentOpinion / Winner select — + /// chỉ Drafter scope (DangSoanThao OR TraLai để Drafter sửa rồi gửi lại). public static async Task EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct) { var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct) ?? throw new NotFoundException("PurchaseEvaluation", id); - if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao) - throw new ConflictException($"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa chi tiết. Phải reject để Drafter sửa lại."); + if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao + && pe.Phase != PurchaseEvaluationPhase.TraLai) + throw new ConflictException( + $"Phiếu PE đã trình duyệt (Phase={pe.Phase}), không thể chỉnh sửa. " + + "Phải Trả lại Drafter sửa lại."); return pe; } + + /// F3 (Mig 28 — S21 t4) — Edit guard cho Section 2 (Detail + NCC + Báo giá). + /// 2 trường hợp accepted: + /// 1. Drafter scope: DangSoanThao OR TraLai — Controller [Authorize] handle role. + /// 2. Approver scope: ChoDuyet + workflow.AllowApproverEditDetails=true + + /// actor.Id match CurrentLevel.ApproverUserId. KHÔNG reset workflow, + /// giữ Cấp hiện tại. Admin bypass workflow flag check. + public static async Task EnsureEditableForDetailsAsync( + IApplicationDbContext db, Guid id, ICurrentUser currentUser, CancellationToken ct) + { + var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct) + ?? throw new NotFoundException("PurchaseEvaluation", id); + + // Drafter scope — any authenticated, Controller [Authorize(Policy)] gates role + if (pe.Phase == PurchaseEvaluationPhase.DangSoanThao + || pe.Phase == PurchaseEvaluationPhase.TraLai) + return pe; + + // F3 Approver scope (Mig 28) — chỉ ChoDuyet với V2 schema + if (pe.Phase == PurchaseEvaluationPhase.ChoDuyet + && currentUser.IsAuthenticated + && currentUser.UserId is Guid actorUserId) + { + // Admin bypass — admin có thể edit bất chấp Allow* flag + if (currentUser.Roles.Contains(AppRoles.Admin)) return pe; + + // V2 schema required + if (pe.ApprovalWorkflowId is Guid awId + && pe.CurrentWorkflowStepIndex is int stepIdx + && pe.CurrentApprovalLevelOrder is int levelOrder) + { + var workflow = await db.ApprovalWorkflows + .Include(w => w.Steps).ThenInclude(s => s.Levels) + .FirstOrDefaultAsync(w => w.Id == awId, ct) + ?? throw new ConflictException("Workflow không tồn tại."); + + if (!workflow.AllowApproverEditDetails) + throw new ConflictException( + "Workflow không bật mode 'Approver chỉnh sửa Section 2'. " + + "Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer."); + + var step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault(); + var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder); + if (level is null) + throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi."); + + if (level.ApproverUserId != actorUserId) + throw new ForbiddenException( + $"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " + + "mới được chỉnh sửa Section 2 lúc đang duyệt."); + + return pe; + } + + throw new ConflictException( + "Phiếu chưa pin workflow V2 hoặc chưa init Bước/Cấp — không thể chỉnh sửa."); + } + + throw new ConflictException( + $"Phiếu PE ở Phase={pe.Phase}, không thể chỉnh sửa Section 2. " + + "Phải Trả lại Drafter sửa."); + } } // ========== Detail (hạng mục + ngân sách) ========== @@ -55,7 +128,8 @@ public class AddPurchaseEvaluationDetailCommandHandler( { public async Task Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct) { - var evaluation = await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); var maxOrder = await db.PurchaseEvaluationDetails .Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId) @@ -110,11 +184,13 @@ public record UpdatePurchaseEvaluationDetailCommand( string? GhiChu) : IRequest; public class UpdatePurchaseEvaluationDetailCommandHandler( - IApplicationDbContext db) : IRequestHandler + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler { public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct) { - await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); var entity = await db.PurchaseEvaluationDetails .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); @@ -130,6 +206,21 @@ public class UpdatePurchaseEvaluationDetailCommandHandler( entity.ThanhTienNganSach = request.ThanhTienNganSach; entity.GhiChu = request.GhiChu; + // F3 audit (Mig 28) — log Approver edit Section 2. Drafter edit cũng log + // để audit trail consistent. Phase ChoDuyet → flag "Approver" trong summary. + var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet + ? " [Approver edit khi đang duyệt]" : string.Empty; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Detail, + EntityId = entity.Id, + Action = ChangelogAction.Update, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Cập nhật hạng mục {request.GroupCode} — {request.NoiDung}{approverNote}", + }); + await db.SaveChangesAsync(ct); } } @@ -137,15 +228,30 @@ public class UpdatePurchaseEvaluationDetailCommandHandler( public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest; public class DeletePurchaseEvaluationDetailCommandHandler( - IApplicationDbContext db) : IRequestHandler + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler { public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct) { - await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); var entity = await db.PurchaseEvaluationDetails .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); + var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet + ? " [Approver edit khi đang duyệt]" : string.Empty; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Detail, + EntityId = entity.Id, + Action = ChangelogAction.Delete, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Xóa hạng mục {entity.GroupCode} — {entity.NoiDung}{approverNote}", + }); + db.PurchaseEvaluationDetails.Remove(entity); await db.SaveChangesAsync(ct); } @@ -164,11 +270,13 @@ public record UpsertPurchaseEvaluationQuoteCommand( string? Note) : IRequest; public class UpsertPurchaseEvaluationQuoteCommandHandler( - IApplicationDbContext db) : IRequestHandler + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler { public async Task Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct) { - await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); // Verify parents exist + same phiếu var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync( d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) @@ -182,6 +290,9 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler( q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId && q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct); + var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet + ? " [Approver edit khi đang duyệt]" : string.Empty; + if (existing is not null) { existing.BgVat = request.BgVat; @@ -189,6 +300,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler( existing.ThanhTien = request.ThanhTien; existing.IsSelected = request.IsSelected; existing.Note = request.Note; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Quote, + EntityId = existing.Id, + Action = ChangelogAction.Update, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Cập nhật báo giá cho hạng mục {detail.GroupCode}{approverNote}", + }); await db.SaveChangesAsync(ct); return existing.Id; } @@ -204,6 +325,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler( Note = request.Note, }; db.PurchaseEvaluationQuotes.Add(entity); + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Quote, + EntityId = entity.Id, + Action = ChangelogAction.Insert, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Thêm báo giá cho hạng mục {detail.GroupCode}{approverNote}", + }); await db.SaveChangesAsync(ct); return entity.Id; } @@ -212,11 +343,13 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler( public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest; public class DeletePurchaseEvaluationQuoteCommandHandler( - IApplicationDbContext db) : IRequestHandler + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler { public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct) { - await PurchaseEvaluationDraftGuard.EnsureDraftAsync(db, request.PurchaseEvaluationId, ct); + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); var quote = await ( from q in db.PurchaseEvaluationQuotes join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id @@ -224,6 +357,19 @@ public class DeletePurchaseEvaluationQuoteCommandHandler( select q).FirstOrDefaultAsync(ct) ?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId); + var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet + ? " [Approver edit khi đang duyệt]" : string.Empty; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Quote, + EntityId = quote.Id, + Action = ChangelogAction.Delete, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Xóa báo giá{approverNote}", + }); + db.PurchaseEvaluationQuotes.Remove(quote); await db.SaveChangesAsync(ct); } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index c37c4e1..eb539e5 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -244,7 +244,12 @@ public record TransitionPurchaseEvaluationCommand( Guid Id, PurchaseEvaluationPhase TargetPhase, ApprovalDecision Decision, - string? Comment) : IRequest; + string? Comment, + // Mig 28 (S21 t4) — F1 mode Trả lại (optional, null = default Drafter) + WorkflowReturnMode? ReturnMode = null, + Guid? ReturnTargetUserId = null, + // F2 — Drafter skip thẳng Cấp cuối khi trình duyệt (optional, default false) + bool SkipToFinal = false) : IRequest; public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator { @@ -254,6 +259,11 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator x.TargetPhase).IsInEnum(); RuleFor(x => x.Decision).IsInEnum(); RuleFor(x => x.Comment).MaximumLength(1000); + RuleFor(x => x.ReturnMode!.Value).IsInEnum().When(x => x.ReturnMode.HasValue); + // Assignee mode → returnTargetUserId required + RuleFor(x => x.ReturnTargetUserId).NotEmpty() + .When(x => x.ReturnMode == WorkflowReturnMode.Assignee) + .WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee."); } } @@ -277,6 +287,9 @@ public class TransitionPurchaseEvaluationCommandHandler( currentUser.Roles, request.Decision, request.Comment, + request.ReturnMode, + request.ReturnTargetUserId, + request.SkipToFinal, ct); } } @@ -549,6 +562,7 @@ public class GetPurchaseEvaluationQueryHandler( // Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards. string? awCode = null, awName = null; int? awVersion = null; + ApprovalWorkflowOptionsDto? awOptions = null; PurchaseEvaluationCurrentApprovalDto? currentApproval = null; PurchaseEvaluationApprovalFlowDto? approvalFlow = null; if (e.ApprovalWorkflowId is Guid awId) @@ -562,6 +576,14 @@ public class GetPurchaseEvaluationQueryHandler( awCode = aw.Code; awName = aw.Name; awVersion = aw.Version; + // Mig 28 — 6 Allow* options pin lúc PE create + awOptions = new ApprovalWorkflowOptionsDto( + aw.AllowReturnOneLevel, + aw.AllowReturnOneStep, + aw.AllowReturnToAssignee, + aw.AllowReturnToDrafter, + aw.AllowDrafterSkipToFinal, + aw.AllowApproverEditDetails); var steps = aw.Steps.OrderBy(s => s.Order).ToList(); // Resolve dept names cho Steps @@ -681,7 +703,7 @@ public class GetPurchaseEvaluationQueryHandler( e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.BudgetId, budgetSummary, e.BudgetManualName, e.BudgetManualAmount, - e.ApprovalWorkflowId, awCode, awName, awVersion, + e.ApprovalWorkflowId, awCode, awName, awVersion, awOptions, currentApproval, approvalFlow, e.Suppliers .OrderBy(s => s.Order) diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs index 51a781c..fa20146 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationSupplierFeatures.cs @@ -41,8 +41,10 @@ public class AddPurchaseEvaluationSupplierCommandHandler( { public async Task Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct) { - var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) - ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); + // Mig 28 (S21 t4 F3) — Section 2 edit guard: Drafter (DangSoanThao/TraLai) + // OR Approver (ChoDuyet + workflow.AllowApproverEditDetails + actor match). + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); _ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct) ?? throw new NotFoundException("Supplier", request.SupplierId); @@ -97,10 +99,14 @@ public record UpdatePurchaseEvaluationSupplierCommand( string? Note) : IRequest; public class UpdatePurchaseEvaluationSupplierCommandHandler( - IApplicationDbContext db) : IRequestHandler + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler { public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct) { + // Mig 28 (S21 t4 F3) — Section 2 edit guard. + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); var row = await db.PurchaseEvaluationSuppliers .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); @@ -112,6 +118,19 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler( row.PaymentTermText = request.PaymentTermText; row.Note = request.Note; + var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet + ? " [Approver edit khi đang duyệt]" : string.Empty; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Supplier, + EntityId = row.Id, + Action = ChangelogAction.Update, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Cập nhật NCC {request.DisplayName ?? "#" + row.SupplierId.ToString()[..8]}{approverNote}", + }); + await db.SaveChangesAsync(ct); } } @@ -119,10 +138,14 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler( public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest; public class RemovePurchaseEvaluationSupplierCommandHandler( - IApplicationDbContext db) : IRequestHandler + IApplicationDbContext db, + ICurrentUser currentUser) : IRequestHandler { public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct) { + // Mig 28 (S21 t4 F3) — Section 2 edit guard. + var evaluation = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, request.PurchaseEvaluationId, currentUser, ct); var row = await db.PurchaseEvaluationSuppliers .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); @@ -131,6 +154,19 @@ public class RemovePurchaseEvaluationSupplierCommandHandler( var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct); if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước."); + var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet + ? " [Approver edit khi đang duyệt]" : string.Empty; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = request.PurchaseEvaluationId, + EntityType = PurchaseEvaluationEntityType.Supplier, + EntityId = row.Id, + Action = ChangelogAction.Delete, + PhaseAtChange = evaluation.Phase, + UserId = currentUser.UserId, + Summary = $"Xóa NCC {row.DisplayName ?? "#" + row.SupplierId.ToString()[..8]}{approverNote}", + }); + db.PurchaseEvaluationSuppliers.Remove(row); await db.SaveChangesAsync(ct); } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs index 9113ca4..4590f8b 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Services/IPurchaseEvaluationWorkflowService.cs @@ -7,6 +7,14 @@ public interface IPurchaseEvaluationWorkflowService { // Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ. // Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline. + // + // Optional params Mig 28 (S21 t4 — F1+F2 advanced workflow options): + // - returnMode: mode Trả lại (F1). Null = default Drafter behavior khi Reject+TraLai. + // OneLevel/OneStep/Assignee → giữ Phase=ChoDuyet, lùi pointer (peer review). + // Drafter → Phase=TraLai clear pointer như S17. + // - returnTargetUserId: required khi returnMode=Assignee — pick từ list NV đã duyệt. + // - skipToFinal: F2 Drafter trình duyệt → skip mọi Bước/Cấp trung gian, set pointer + // = max Step + max Level. Workflow phải AllowDrafterSkipToFinal=true. Task TransitionAsync( PurchaseEvaluation evaluation, PurchaseEvaluationPhase targetPhase, @@ -14,11 +22,23 @@ public interface IPurchaseEvaluationWorkflowService IReadOnlyList actorRoles, ApprovalDecision decision, string? comment, + WorkflowReturnMode? returnMode = null, + Guid? returnTargetUserId = null, + bool skipToFinal = false, CancellationToken ct = default); TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); } +/// Mig 28 (S21 t4) — F1 mode Trả lại. Mapping với ApprovalWorkflow.Allow* flag. +public enum WorkflowReturnMode +{ + OneLevel = 1, // Lùi 1 Cấp trong cùng Step (peer review) + OneStep = 2, // Lùi sang Bước trước, level = max của bước đó + Assignee = 3, // Pick runtime từ list NV đã duyệt + Drafter = 4, // Trả về Drafter, Phase=TraLai clear pointer (S17 default fallback) +} + // Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator. // Format: PE/{YYYY}/{TypeLetter}/{Seq:D3} // - YYYY = năm hiện tại (UTC) diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index e03c826..312a865 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -41,6 +41,9 @@ public class PurchaseEvaluationWorkflowService( IReadOnlyList actorRoles, ApprovalDecision decision, string? comment, + WorkflowReturnMode? returnMode = null, + Guid? returnTargetUserId = null, + bool skipToFinal = false, CancellationToken ct = default) { var fromPhase = evaluation.Phase; @@ -67,23 +70,26 @@ public class PurchaseEvaluationWorkflowService( "(xem gotcha #45 + docs/workflow-contract.md)."); } - // ===== REJECT BRANCH ===== + // ===== REJECT BRANCH (extended Mig 28 — F1 multi-mode Trả lại) ===== if (decision == ApprovalDecision.Reject) { if (targetPhase == PurchaseEvaluationPhase.TuChoi) { // Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16). evaluation.Phase = PurchaseEvaluationPhase.TuChoi; + evaluation.SlaDeadline = null; } else { - // Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao). - // Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1. - evaluation.Phase = PurchaseEvaluationPhase.TraLai; - evaluation.CurrentWorkflowStepIndex = null; - evaluation.CurrentApprovalLevelOrder = null; + // F1 (S21 t4) — 4 mode Trả lại theo workflow.Allow* flag. + // Default fallback (returnMode=null) = Drafter mode = S17 behavior. + var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter; + var returnSummary = await ApplyReturnModeAsync( + evaluation, effectiveMode, returnTargetUserId, isAdmin, ct); + comment = string.IsNullOrWhiteSpace(comment) + ? returnSummary + : $"{comment} [{returnSummary}]"; } - evaluation.SlaDeadline = null; await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct); await db.SaveChangesAsync(ct); return; @@ -104,9 +110,36 @@ public class PurchaseEvaluationWorkflowService( $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu."); } evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; - evaluation.CurrentWorkflowStepIndex = 0; - // Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set). - evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null; + + // F2 (Mig 28 — S21 t4) — Drafter skip thẳng Cấp cuối. Workflow phải + // AllowDrafterSkipToFinal=true. Set pointer = max Step + max Level. + // Audit changelog ghi rõ "Drafter skip" để approver Cấp cuối biết. + if (skipToFinal && evaluation.ApprovalWorkflowId is Guid skipAwId) + { + var wfSkip = await db.ApprovalWorkflows + .Include(w => w.Steps).ThenInclude(s => s.Levels) + .FirstOrDefaultAsync(w => w.Id == skipAwId, ct) + ?? throw new ConflictException("Workflow không tồn tại."); + if (!wfSkip.AllowDrafterSkipToFinal) + throw new ConflictException( + "Workflow không bật mode 'Gửi thẳng Cấp cuối'. " + + "Liên hệ Admin để config Designer."); + var finalStep = wfSkip.Steps.OrderBy(s => s.Order).LastOrDefault() + ?? throw new ConflictException("Workflow chưa có Bước nào."); + var finalLevelOrder = finalStep.Levels.OrderBy(l => l.Order).LastOrDefault()?.Order + ?? throw new ConflictException($"Bước {finalStep.Order} chưa có Cấp nào."); + evaluation.CurrentWorkflowStepIndex = wfSkip.Steps.Count - 1; // 0-based last step + evaluation.CurrentApprovalLevelOrder = finalLevelOrder; + comment = string.IsNullOrWhiteSpace(comment) + ? "[Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]" + : $"{comment} [Drafter gửi thẳng Cấp cuối — skip Bước/Cấp trung gian]"; + } + else + { + evaluation.CurrentWorkflowStepIndex = 0; + // Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set). + evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null; + } evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct); await db.SaveChangesAsync(ct); @@ -144,6 +177,154 @@ public class PurchaseEvaluationWorkflowService( throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); } + // ===== F1 (Mig 28 — S21 t4) — Apply Return Mode ===== + // Switch theo effectiveMode → set Phase + pointer. 3 mode đầu giữ ChoDuyet + // (peer review chain). Mode Drafter set Phase=TraLai như S17. + // Validate workflow.Allow* flag match mode → throw nếu disabled. + // Return summary text để chèn vào comment changelog (audit trail). + private async Task ApplyReturnModeAsync( + PurchaseEvaluation evaluation, + WorkflowReturnMode mode, + Guid? returnTargetUserId, + bool isAdmin, + CancellationToken ct) + { + // Mode Drafter — Session 17 default (always allowed for backward compat, + // workflow.AllowReturnToDrafter default true). + if (mode == WorkflowReturnMode.Drafter) + { + // Validate workflow flag (admin có thể disable mode này force peer review) + if (evaluation.ApprovalWorkflowId is Guid awId0 && !isAdmin) + { + var wf0 = await db.ApprovalWorkflows.FirstOrDefaultAsync(w => w.Id == awId0, ct); + if (wf0 is not null && !wf0.AllowReturnToDrafter) + throw new ConflictException( + "Workflow không bật mode 'Trả về Drafter'. Phải dùng mode khác."); + } + evaluation.Phase = PurchaseEvaluationPhase.TraLai; + evaluation.CurrentWorkflowStepIndex = null; + evaluation.CurrentApprovalLevelOrder = null; + evaluation.SlaDeadline = null; + return "Trả về Người soạn thảo"; + } + + // 3 mode còn lại (OneLevel / OneStep / Assignee) — yêu cầu V2 schema + + // pointer hợp lệ. + if (evaluation.ApprovalWorkflowId is not Guid awId) + throw new ConflictException( + $"Mode '{mode}' yêu cầu phiếu pin V2 workflow (ApprovalWorkflowId)."); + if (evaluation.CurrentWorkflowStepIndex is not int curStepIdx + || evaluation.CurrentApprovalLevelOrder is not int curLevel) + throw new ConflictException( + $"Mode '{mode}' yêu cầu phiếu đang ChoDuyet + pointer init. " + + $"State hiện tại: Step={evaluation.CurrentWorkflowStepIndex}, Level={evaluation.CurrentApprovalLevelOrder}."); + + var workflow = await db.ApprovalWorkflows + .Include(w => w.Steps).ThenInclude(s => s.Levels) + .FirstOrDefaultAsync(w => w.Id == awId, ct) + ?? throw new ConflictException("Workflow không tồn tại."); + + // Validate Allow* flag (Admin bypass — admin có thể trả lại bất chấp config) + if (!isAdmin) + { + var allowed = mode switch + { + WorkflowReturnMode.OneLevel => workflow.AllowReturnOneLevel, + WorkflowReturnMode.OneStep => workflow.AllowReturnOneStep, + WorkflowReturnMode.Assignee => workflow.AllowReturnToAssignee, + _ => false, + }; + if (!allowed) + throw new ConflictException( + $"Workflow không bật mode '{mode}'. Liên hệ Admin Designer để config."); + } + + var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList(); + var summary = string.Empty; + + switch (mode) + { + case WorkflowReturnMode.OneLevel: + // Lùi 1 Cấp trong cùng Step. Nếu đang Cấp 1 → lùi sang Bước trước + // Cấp cuối. Nếu đang Bước 1 Cấp 1 → fallback Drafter (no further). + if (curLevel > 1) + { + evaluation.CurrentApprovalLevelOrder = curLevel - 1; + summary = $"Trả về Cấp {curLevel - 1} (cùng Bước {stepsOrdered[curStepIdx].Order})"; + } + else if (curStepIdx > 0) + { + var prevStep = stepsOrdered[curStepIdx - 1]; + var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order; + evaluation.CurrentWorkflowStepIndex = curStepIdx - 1; + evaluation.CurrentApprovalLevelOrder = prevMaxLevel; + summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel} (Bước trước)"; + } + else + { + // Bước 1 Cấp 1 — no further back. Fallback Drafter. + evaluation.Phase = PurchaseEvaluationPhase.TraLai; + evaluation.CurrentWorkflowStepIndex = null; + evaluation.CurrentApprovalLevelOrder = null; + evaluation.SlaDeadline = null; + return "Trả về Người soạn thảo (fallback — đang Bước 1 Cấp 1)"; + } + break; + + case WorkflowReturnMode.OneStep: + // Lùi sang Bước trước, set Level = max của Bước đó. + if (curStepIdx > 0) + { + var prevStep = stepsOrdered[curStepIdx - 1]; + var prevMaxLevel = prevStep.Levels.OrderBy(l => l.Order).Last().Order; + evaluation.CurrentWorkflowStepIndex = curStepIdx - 1; + evaluation.CurrentApprovalLevelOrder = prevMaxLevel; + summary = $"Trả về Bước {prevStep.Order} Cấp {prevMaxLevel}"; + } + else + { + // Đang Bước 1 → fallback Drafter + evaluation.Phase = PurchaseEvaluationPhase.TraLai; + evaluation.CurrentWorkflowStepIndex = null; + evaluation.CurrentApprovalLevelOrder = null; + evaluation.SlaDeadline = null; + return "Trả về Người soạn thảo (fallback — đang Bước đầu)"; + } + break; + + case WorkflowReturnMode.Assignee: + if (returnTargetUserId is not Guid targetUid) + throw new ConflictException("returnTargetUserId yêu cầu khi mode=Assignee."); + var foundStepIdx = -1; + int foundLevel = -1; + string? foundStepName = null; + for (int si = 0; si < stepsOrdered.Count; si++) + { + var match = stepsOrdered[si].Levels + .FirstOrDefault(l => l.ApproverUserId == targetUid); + if (match is not null) + { + foundStepIdx = si; + foundLevel = match.Order; + foundStepName = stepsOrdered[si].Name; + break; + } + } + if (foundStepIdx < 0) + throw new ConflictException( + "Không tìm thấy người chỉ định trong workflow. " + + "Chỉ pick từ list NV đã duyệt trước đó (PeLevelOpinions)."); + evaluation.CurrentWorkflowStepIndex = foundStepIdx; + evaluation.CurrentApprovalLevelOrder = foundLevel; + summary = $"Trả về Người chỉ định — Bước {stepsOrdered[foundStepIdx].Order} ({foundStepName}) Cấp {foundLevel}"; + break; + } + + // 3 mode trên đều giữ Phase=ChoDuyet — reset SLA cho approver mới. + evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); + return summary; + } + // ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels ===== private async Task ApproveV2Async( PurchaseEvaluation evaluation,