[CLAUDE] PE-Workflow: Chunk B — BE Service + handlers + DTOs (F1+F2+F3)

F1 — 4 mode Trả lại (Service.ApplyReturnModeAsync helper):
- WorkflowReturnMode enum (OneLevel / OneStep / Assignee / Drafter)
- OneLevel: lùi 1 Cấp trong cùng Step (peer review). Bước 1 Cấp 1 → fallback Drafter.
- OneStep: lùi sang Bước trước Cấp cuối. Bước 1 → fallback Drafter.
- Assignee: pick runtime → tìm Step+Level match ApproverUserId trong workflow.
- Drafter: Phase=TraLai clear pointer như S17 (backward compat).
- 3 mode đầu giữ Phase=ChoDuyet, reset SLA 7d. Mode Drafter clear SLA.
- Admin bypass workflow.Allow* flag check. Non-admin → throw ConflictException
  với message rõ "Workflow không bật mode X".

F2 — Drafter skipToFinal (extend DRAFTER trình branch):
- Workflow.AllowDrafterSkipToFinal=true required (non-admin)
- Set CurrentWorkflowStepIndex = Steps.Count-1 + CurrentApprovalLevelOrder = max Level
- Audit comment append "[Drafter gửi thẳng Cấp cuối]"

F3 — Approver edit Section 2 (Detail + NCC + Báo giá):
- New helper `EnsureEditableForDetailsAsync` (extend pattern PurchaseEvaluationDraftGuard):
  - Drafter scope: DangSoanThao OR TraLai (any role, Controller [Authorize] handles)
  - F3 Approver scope: ChoDuyet + workflow.AllowApproverEditDetails=true +
    actor.Id match CurrentLevel.ApproverUserId. Admin bypass flag check.
  - Throw ForbiddenException nếu approver Cấp khác nhau (rõ Bước/Cấp trong message).
- 8 handler switch helper + inject ICurrentUser khi cần:
  - Detail: Add (existing ICurrentUser) / Update + Delete (inject new)
  - Quote: Upsert + Delete (inject new)
  - Supplier: Add (existing) / Update + Delete (inject new + add guard, trước
    đây hoàn toàn KHÔNG có phase guard — bonus security fix)
- Audit: thêm changelog Update/Delete handler (trước đây silent). Khi phase=
  ChoDuyet append " [Approver edit khi đang duyệt]" cho lịch sử rõ ai sửa.

Extension Service `TransitionAsync` signature (backward compat — 3 optional
param thêm cuối + default null/false):
- WorkflowReturnMode? returnMode = null
- Guid? returnTargetUserId = null
- bool skipToFinal = false

TransitionPurchaseEvaluationCommand DTO + Validator + Handler — mirror signature.

DTO extensions:
- ApprovalWorkflowOptionsDto NEW sub-record (6 Allow* flag) cho FE filter
- PurchaseEvaluationDetailBundleDto + WorkflowOptions field (null nếu V1 legacy)
- GetPe handler populate awOptions từ ApprovalWorkflow entity load (Mig 23 path)
- AwDefinitionDto + 6 Allow* field (admin Designer GET overview)
- CreateAwDefinitionCommand + 6 Allow* param (admin Designer POST new version)
- Handler ToDto + entity new() — propagate Allow* end-to-end

Default backward compat: workflow cũ → AllowReturnToDrafter=true (Mig 28 DB
default), 5 flag còn lại false. Phiếu cũ V2 vẫn Trả lại Drafter như S17 sau
deploy — no breaking change.

Verify:
- dotnet build SolutionErp.slnx → 0 err, 2 warn pre-existing DocxRenderer
- 3 regression test gotcha #45 vẫn PASS (backward compat signature change)
- LocalDB Dev + Design đã apply Mig 28 (Chunk A)

Pending Chunk C: FE Admin Designer mirror 2 app (6 checkbox + DTO types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-13 18:57:09 +07:00
parent 0294693a4a
commit c56024ba25
7 changed files with 478 additions and 29 deletions

View File

@ -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<AwStepDto> 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<CreateAwStepInput> Steps) : IRequest<Guid>;
List<CreateAwStepInput> 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<Guid>;
public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
{
@ -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

View File

@ -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<PurchaseEvaluationSupplierDto> Suppliers,

View File

@ -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<PurchaseEvaluation> 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<PurchaseEvaluation> 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<Guid> 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<UpdatePurchaseEvaluationDetailCommand>
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
{
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<DeletePurchaseEvaluationDetailCommand>
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
{
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<Guid>;
public class UpsertPurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
{
public async Task<Guid> 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<DeletePurchaseEvaluationQuoteCommand>
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
{
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);
}

View File

@ -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<TransitionPurchaseEvaluationCommand>
{
@ -254,6 +259,11 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
RuleFor(x => 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)

View File

@ -41,8 +41,10 @@ public class AddPurchaseEvaluationSupplierCommandHandler(
{
public async Task<Guid> 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<UpdatePurchaseEvaluationSupplierCommand>
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
{
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<RemovePurchaseEvaluationSupplierCommand>
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
{
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);
}

View File

@ -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<string> 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)