[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, string? Description,
bool IsActive, bool IsActive,
bool IsUserSelectable, 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? ActivatedAt,
DateTime CreatedAt, DateTime CreatedAt,
List<AwStepDto> Steps); List<AwStepDto> Steps);
@ -128,6 +137,13 @@ public class GetAwAdminOverviewQueryHandler(
d.Description, d.Description,
d.IsActive, d.IsActive,
d.IsUserSelectable, d.IsUserSelectable,
// Mig 28 — 6 Allow* flag
d.AllowReturnOneLevel,
d.AllowReturnOneStep,
d.AllowReturnToAssignee,
d.AllowReturnToDrafter,
d.AllowDrafterSkipToFinal,
d.AllowApproverEditDetails,
d.ActivatedAt, d.ActivatedAt,
d.CreatedAt, d.CreatedAt,
d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto( d.Steps.OrderBy(s => s.Order).Select(s => new AwStepDto(
@ -178,7 +194,15 @@ public record CreateAwDefinitionCommand(
string Code, string Code,
string Name, string Name,
string? Description, 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> public class CreateAwDefinitionCommandValidator : AbstractValidator<CreateAwDefinitionCommand>
{ {
@ -271,6 +295,13 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
Description = request.Description, Description = request.Description,
IsActive = true, IsActive = true,
IsUserSelectable = true, // Mig 25 — version mới mặc định cho user pick 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, ActivatedAt = DateTime.UtcNow,
Steps = request.Steps.OrderBy(s => s.Order) Steps = request.Steps.OrderBy(s => s.Order)
.Select(s => new ApprovalWorkflowStep .Select(s => new ApprovalWorkflowStep

View File

@ -78,6 +78,16 @@ public record PurchaseEvaluationChangelogDto(
string? ContextNote, string? ContextNote,
DateTime CreatedAt); 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( public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName, string PolicyName,
string PolicyDescription, string PolicyDescription,
@ -194,6 +204,9 @@ public record PurchaseEvaluationDetailBundleDto(
string? ApprovalWorkflowCode, string? ApprovalWorkflowCode,
string? ApprovalWorkflowName, string? ApprovalWorkflowName,
int? ApprovalWorkflowVersion, 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, PurchaseEvaluationCurrentApprovalDto? CurrentApproval,
PurchaseEvaluationApprovalFlowDto? ApprovalFlow, PurchaseEvaluationApprovalFlowDto? ApprovalFlow,
List<PurchaseEvaluationSupplierDto> Suppliers, List<PurchaseEvaluationSupplierDto> Suppliers,

View File

@ -4,23 +4,96 @@ using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations; using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations; namespace SolutionErp.Application.PurchaseEvaluations;
// ========== Helper: Lock edit guard (Phase 9 — Migration 16) ========== // ========== 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. // Đã 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 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) public static async Task<PurchaseEvaluation> EnsureDraftAsync(IApplicationDbContext db, Guid id, CancellationToken ct)
{ {
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct) var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == id, ct)
?? throw new NotFoundException("PurchaseEvaluation", id); ?? throw new NotFoundException("PurchaseEvaluation", id);
if (pe.Phase != PurchaseEvaluationPhase.DangSoanThao) 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."); && 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; 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) ========== // ========== 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) 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 var maxOrder = await db.PurchaseEvaluationDetails
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId) .Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
@ -110,11 +184,13 @@ public record UpdatePurchaseEvaluationDetailCommand(
string? GhiChu) : IRequest; string? GhiChu) : IRequest;
public class UpdatePurchaseEvaluationDetailCommandHandler( public class UpdatePurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
{ {
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct) 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 var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); ?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
@ -130,6 +206,21 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
entity.ThanhTienNganSach = request.ThanhTienNganSach; entity.ThanhTienNganSach = request.ThanhTienNganSach;
entity.GhiChu = request.GhiChu; 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); await db.SaveChangesAsync(ct);
} }
} }
@ -137,15 +228,30 @@ public class UpdatePurchaseEvaluationDetailCommandHandler(
public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest; public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest;
public class DeletePurchaseEvaluationDetailCommandHandler( public class DeletePurchaseEvaluationDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationDetailCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
{ {
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct) 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 var entity = await db.PurchaseEvaluationDetails
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId); ?? 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); db.PurchaseEvaluationDetails.Remove(entity);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
@ -164,11 +270,13 @@ public record UpsertPurchaseEvaluationQuoteCommand(
string? Note) : IRequest<Guid>; string? Note) : IRequest<Guid>;
public class UpsertPurchaseEvaluationQuoteCommandHandler( public class UpsertPurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
{ {
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct) 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 // Verify parents exist + same phiếu
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync( var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
@ -182,6 +290,9 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId
&& q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct); && q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct);
var approverNote = evaluation.Phase == PurchaseEvaluationPhase.ChoDuyet
? " [Approver edit khi đang duyệt]" : string.Empty;
if (existing is not null) if (existing is not null)
{ {
existing.BgVat = request.BgVat; existing.BgVat = request.BgVat;
@ -189,6 +300,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
existing.ThanhTien = request.ThanhTien; existing.ThanhTien = request.ThanhTien;
existing.IsSelected = request.IsSelected; existing.IsSelected = request.IsSelected;
existing.Note = request.Note; 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); await db.SaveChangesAsync(ct);
return existing.Id; return existing.Id;
} }
@ -204,6 +325,16 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
Note = request.Note, Note = request.Note,
}; };
db.PurchaseEvaluationQuotes.Add(entity); 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); await db.SaveChangesAsync(ct);
return entity.Id; return entity.Id;
} }
@ -212,11 +343,13 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest; public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest;
public class DeletePurchaseEvaluationQuoteCommandHandler( public class DeletePurchaseEvaluationQuoteCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
{ {
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct) 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 ( var quote = await (
from q in db.PurchaseEvaluationQuotes from q in db.PurchaseEvaluationQuotes
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
@ -224,6 +357,19 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
select q).FirstOrDefaultAsync(ct) select q).FirstOrDefaultAsync(ct)
?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId); ?? 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); db.PurchaseEvaluationQuotes.Remove(quote);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }

View File

@ -244,7 +244,12 @@ public record TransitionPurchaseEvaluationCommand(
Guid Id, Guid Id,
PurchaseEvaluationPhase TargetPhase, PurchaseEvaluationPhase TargetPhase,
ApprovalDecision Decision, 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> public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
{ {
@ -254,6 +259,11 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
RuleFor(x => x.TargetPhase).IsInEnum(); RuleFor(x => x.TargetPhase).IsInEnum();
RuleFor(x => x.Decision).IsInEnum(); RuleFor(x => x.Decision).IsInEnum();
RuleFor(x => x.Comment).MaximumLength(1000); 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, currentUser.Roles,
request.Decision, request.Decision,
request.Comment, request.Comment,
request.ReturnMode,
request.ReturnTargetUserId,
request.SkipToFinal,
ct); ct);
} }
} }
@ -549,6 +562,7 @@ public class GetPurchaseEvaluationQueryHandler(
// Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards. // Bước/Cấp tree với Status) cho FE render flow vertical thay phase cards.
string? awCode = null, awName = null; string? awCode = null, awName = null;
int? awVersion = null; int? awVersion = null;
ApprovalWorkflowOptionsDto? awOptions = null;
PurchaseEvaluationCurrentApprovalDto? currentApproval = null; PurchaseEvaluationCurrentApprovalDto? currentApproval = null;
PurchaseEvaluationApprovalFlowDto? approvalFlow = null; PurchaseEvaluationApprovalFlowDto? approvalFlow = null;
if (e.ApprovalWorkflowId is Guid awId) if (e.ApprovalWorkflowId is Guid awId)
@ -562,6 +576,14 @@ public class GetPurchaseEvaluationQueryHandler(
awCode = aw.Code; awCode = aw.Code;
awName = aw.Name; awName = aw.Name;
awVersion = aw.Version; 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(); var steps = aw.Steps.OrderBy(s => s.Order).ToList();
// Resolve dept names cho Steps // Resolve dept names cho Steps
@ -681,7 +703,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetId, budgetSummary, e.BudgetId, budgetSummary,
e.BudgetManualName, e.BudgetManualAmount, e.BudgetManualName, e.BudgetManualAmount,
e.ApprovalWorkflowId, awCode, awName, awVersion, e.ApprovalWorkflowId, awCode, awName, awVersion, awOptions,
currentApproval, approvalFlow, currentApproval, approvalFlow,
e.Suppliers e.Suppliers
.OrderBy(s => s.Order) .OrderBy(s => s.Order)

View File

@ -41,8 +41,10 @@ public class AddPurchaseEvaluationSupplierCommandHandler(
{ {
public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct) public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct)
{ {
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) // Mig 28 (S21 t4 F3) — Section 2 edit guard: Drafter (DangSoanThao/TraLai)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); // 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) _ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
?? throw new NotFoundException("Supplier", request.SupplierId); ?? throw new NotFoundException("Supplier", request.SupplierId);
@ -97,10 +99,14 @@ public record UpdatePurchaseEvaluationSupplierCommand(
string? Note) : IRequest; string? Note) : IRequest;
public class UpdatePurchaseEvaluationSupplierCommandHandler( public class UpdatePurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
{ {
public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct) 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 var row = await db.PurchaseEvaluationSuppliers
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); ?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
@ -112,6 +118,19 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler(
row.PaymentTermText = request.PaymentTermText; row.PaymentTermText = request.PaymentTermText;
row.Note = request.Note; 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); await db.SaveChangesAsync(ct);
} }
} }
@ -119,10 +138,14 @@ public class UpdatePurchaseEvaluationSupplierCommandHandler(
public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest; public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest;
public class RemovePurchaseEvaluationSupplierCommandHandler( public class RemovePurchaseEvaluationSupplierCommandHandler(
IApplicationDbContext db) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand> IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
{ {
public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct) 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 var row = await db.PurchaseEvaluationSuppliers
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct) .FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId); ?? 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); 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."); 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); db.PurchaseEvaluationSuppliers.Remove(row);
await db.SaveChangesAsync(ct); 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ệ. // Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline. // 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( Task TransitionAsync(
PurchaseEvaluation evaluation, PurchaseEvaluation evaluation,
PurchaseEvaluationPhase targetPhase, PurchaseEvaluationPhase targetPhase,
@ -14,11 +22,23 @@ public interface IPurchaseEvaluationWorkflowService
IReadOnlyList<string> actorRoles, IReadOnlyList<string> actorRoles,
ApprovalDecision decision, ApprovalDecision decision,
string? comment, string? comment,
WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null,
bool skipToFinal = false,
CancellationToken ct = default); CancellationToken ct = default);
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); 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. // Atomic sequence generator cho mã PE (MaPhieu) — mirror IContractCodeGenerator.
// Format: PE/{YYYY}/{TypeLetter}/{Seq:D3} // Format: PE/{YYYY}/{TypeLetter}/{Seq:D3}
// - YYYY = năm hiện tại (UTC) // - YYYY = năm hiện tại (UTC)

View File

@ -41,6 +41,9 @@ public class PurchaseEvaluationWorkflowService(
IReadOnlyList<string> actorRoles, IReadOnlyList<string> actorRoles,
ApprovalDecision decision, ApprovalDecision decision,
string? comment, string? comment,
WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null,
bool skipToFinal = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var fromPhase = evaluation.Phase; var fromPhase = evaluation.Phase;
@ -67,23 +70,26 @@ public class PurchaseEvaluationWorkflowService(
"(xem gotcha #45 + docs/workflow-contract.md)."); "(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 (decision == ApprovalDecision.Reject)
{ {
if (targetPhase == PurchaseEvaluationPhase.TuChoi) if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{ {
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16). // Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
evaluation.Phase = PurchaseEvaluationPhase.TuChoi; evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
evaluation.SlaDeadline = null;
} }
else else
{ {
// Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao). // F1 (S21 t4) — 4 mode Trả lại theo workflow.Allow* flag.
// Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1. // Default fallback (returnMode=null) = Drafter mode = S17 behavior.
evaluation.Phase = PurchaseEvaluationPhase.TraLai; var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
evaluation.CurrentWorkflowStepIndex = null; var returnSummary = await ApplyReturnModeAsync(
evaluation.CurrentApprovalLevelOrder = null; 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 LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return; return;
@ -104,9 +110,36 @@ public class PurchaseEvaluationWorkflowService(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu."); $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
} }
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = 0;
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set). // F2 (Mig 28 — S21 t4) — Drafter skip thẳng Cấp cuối. Workflow phải
evaluation.CurrentApprovalLevelOrder = evaluation.ApprovalWorkflowId is not null ? 1 : null; // 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); evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct); await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@ -144,6 +177,154 @@ public class PurchaseEvaluationWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); 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<string> 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 ===== // ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
private async Task ApproveV2Async( private async Task ApproveV2Async(
PurchaseEvaluation evaluation, PurchaseEvaluation evaluation,