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