[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:
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user