[CLAUDE] PE Workflow: wire Service V2 (Mig 24) — fix bug duyệt phiếu pin schema mới
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m14s

User báo bug eoffice: phiếu tạo mới không duyệt được + không bắt đc quy
trình mới. Root cause: Mig 23 pin ApprovalWorkflowId vào entity nhưng
Service vẫn đọc WorkflowDefinitionId legacy → match approver theo schema
cũ (Dept+PositionLevel/Role/User) thay vì ApproverUserId V2.

BE Domain — Migration 24 `AddCurrentApprovalLevelOrderToPe`:
- PurchaseEvaluation +CurrentApprovalLevelOrder int? (track Cấp 1/2/3
  đang chờ duyệt trong Step hiện tại khi pin V2). Null khi terminal/V1.
- RejectedAtStepIndex giữ deprecated DB column cho data cũ.

BE Service PE — branch theo schema pin:
- V2 (`ApprovalWorkflowId` set): ApproveV2Async() — load
  ApprovalWorkflows.Steps.Levels Include 3-level. Group Levels by Order
  = Cấp (OR-of-N approvers). Match `actor.Id ∈ levelGroup.ApproverUserId`
  (KHÔNG match Dept+Level/Role/User như V1). Advance:
    Còn cấp tiếp trong Step → levelOrder++
    Hết cấp → idx++, levelOrder=1
    Hết Step → DaDuyet
- V1 legacy (chỉ `WorkflowDefinitionId` set): ApproveV1LegacyAsync() —
  giữ nguyên logic Mig 21 (Dept+PositionLevel match)
- Drafter trình từ Nháp/Trả lại: init CurrentWorkflowStepIndex=0 +
  CurrentApprovalLevelOrder=1 (chỉ khi V2 pin)
- Reject (Trả lại): clear CurrentApprovalLevelOrder=null
- Reject (Từ chối): clear all tracking

BE Synthetic Policy V2:
- `PurchaseEvaluationPolicyRegistry.ForV2Schema()` — simple state machine
  policy (DangSoanThao/TraLai → ChoDuyet/TuChoi; ChoDuyet → ChoDuyet/
  TraLai/TuChoi). Roles="*" cho ChoDuyet branch — Service tự enforce
  ApproverUserId, Policy chỉ expose 3 nút FE.
- GetPurchaseEvaluationByIdQuery handler: ưu tiên ForV2Schema() khi pin
  V2 (FE đọc workflow.nextPhases để show button).

Verify: 81 test pass · BE 0 error · Mig 24 applied cả 2 LocalDB.

Test thử (Drafter eoffice):
1. Designer V2 tạo quy trình QT-DN-V2-001: Bước 1 (Phòng A), Cấp 1 (NV X)
2. Workspace tạo phiếu mới, Select QT-DN-V2-001 → Lưu phiếu + Gửi duyệt
3. Phiếu Phase=ChoDuyet, idx=0, levelOrder=1. NV X login → thấy phiếu
   trong Inbox + duyệt được. Sau approve → idx++, levelOrder reset 1.
4. Cấu hình level mismatch: NV Y khác → thấy ForbiddenException rõ tên.

Logic Contract V2 chưa wire (chỉ PE), defer Session sau khi user UAT PE OK.
This commit is contained in:
pqhuy1987
2026-05-08 14:54:51 +07:00
parent 0a40c65421
commit b41484b702
7 changed files with 4096 additions and 79 deletions

View File

@ -430,9 +430,13 @@ public class GetPurchaseEvaluationQueryHandler(
.Where(u => userIds.Contains(u.Id)) .Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct); .ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
// Resolve workflow policy // Resolve workflow policy (V2 ưu tiên nếu pin)
PurchaseEvaluationPolicy policy; PurchaseEvaluationPolicy policy;
if (e.WorkflowDefinitionId is Guid wfId) if (e.ApprovalWorkflowId is not null)
{
policy = PurchaseEvaluationPolicyRegistry.ForV2Schema();
}
else if (e.WorkflowDefinitionId is Guid wfId)
{ {
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order)) .Include(d => d.Steps.OrderBy(s => s.Order))

View File

@ -43,12 +43,17 @@ public class PurchaseEvaluation : AuditableEntity
// Flat workflow tracking (Session 16 — Migration 21): // Flat workflow tracking (Session 16 — Migration 21):
// - CurrentWorkflowStepIndex: 0-based index của step đang chờ approver // - CurrentWorkflowStepIndex: 0-based index của step đang chờ approver
// (khi Phase=ChoDuyet). Null khi DangSoanThao/DaDuyet/TuChoi. // (khi Phase=ChoDuyet). Null khi DangSoanThao/DaDuyet/TuChoi/TraLai.
// - RejectedAtStepIndex: snapshot CurrentWorkflowStepIndex tại Trả lại. // - RejectedAtStepIndex: [DEPRECATED Session 17] snapshot index tại Trả lại.
// Drafter resume → restore CurrentWorkflowStepIndex (jump-back). // Field giữ DB column cho data cũ — Service không set value mới.
public int? CurrentWorkflowStepIndex { get; set; } public int? CurrentWorkflowStepIndex { get; set; }
public int? RejectedAtStepIndex { get; set; } public int? RejectedAtStepIndex { get; set; }
// V2 schema tracking (Session 17 — Migration 24):
// - CurrentApprovalLevelOrder: Cấp đang chờ duyệt (1/2/3) trong Step
// hiện tại khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc terminal.
public int? CurrentApprovalLevelOrder { get; set; }
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new(); public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
public List<PurchaseEvaluationDetail> Details { get; set; } = new(); public List<PurchaseEvaluationDetail> Details { get; set; } = new();
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new(); public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();

View File

@ -156,6 +156,52 @@ public static class PurchaseEvaluationPolicyRegistry
public static PurchaseEvaluationPolicy ForEvaluation(PurchaseEvaluation ev) => public static PurchaseEvaluationPolicy ForEvaluation(PurchaseEvaluation ev) =>
For(ev.Type); For(ev.Type);
// Session 17 — synthetic policy cho phiếu pin schema V2 (ApprovalWorkflowsV2).
// Workflow chạy theo state machine 5 trạng thái + iterate Steps/Levels —
// Phase enum chỉ dùng (DangSoanThao/TraLai/ChoDuyet/DaDuyet/TuChoi). Service
// tự handle advance level/step bên trong ChoDuyet, FE chỉ cần biết:
// DangSoanThao/TraLai → ChoDuyet (trình) | TuChoi (huỷ)
// ChoDuyet → ChoDuyet (advance) | TraLai (trả lại) | TuChoi (từ chối)
public static PurchaseEvaluationPolicy ForV2Schema()
{
var transitions = new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]>
{
// Drafter trình từ Nháp HOẶC gửi lại từ Trả lại — cùng entry point
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoDuyet)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoDuyet)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
// ChoDuyet — Service guard match approver ApproverUserId, Policy chỉ
// expose 3 nút cho FE (Duyệt forward / Trả lại / Từ chối). Roles "*"
// để guard không block; Service tự enforce ApproverUserId match.
[(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet)] = ["*"],
[(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.TraLai)] = ["*"],
[(PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.TuChoi)] = ["*"],
};
var sla = new Dictionary<PurchaseEvaluationPhase, TimeSpan?>
{
[PurchaseEvaluationPhase.DangSoanThao] = TimeSpan.FromDays(3),
[PurchaseEvaluationPhase.TraLai] = TimeSpan.FromDays(3),
[PurchaseEvaluationPhase.ChoDuyet] = TimeSpan.FromDays(7),
[PurchaseEvaluationPhase.DaDuyet] = null,
[PurchaseEvaluationPhase.TuChoi] = null,
};
return new PurchaseEvaluationPolicy(
Name: "V2-Schema",
Description: "Schema mới ApprovalWorkflowsV2 — Service iterate Steps/Levels theo workflow pin.",
Transitions: transitions,
PhaseSla: sla,
ActivePhases:
[
PurchaseEvaluationPhase.DangSoanThao,
PurchaseEvaluationPhase.TraLai,
PurchaseEvaluationPhase.ChoDuyet,
PurchaseEvaluationPhase.DaDuyet,
PurchaseEvaluationPhase.TuChoi,
]);
}
// Build policy from persisted admin-authored definition (mirror // Build policy from persisted admin-authored definition (mirror
// WorkflowPolicyRegistry.FromDefinition for HĐ). // WorkflowPolicyRegistry.FromDefinition for HĐ).
public static PurchaseEvaluationPolicy FromDefinition(PurchaseEvaluationWorkflowDefinition def) public static PurchaseEvaluationPolicy FromDefinition(PurchaseEvaluationWorkflowDefinition def)

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCurrentApprovalLevelOrderToPe : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CurrentApprovalLevelOrder",
table: "PurchaseEvaluations",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrentApprovalLevelOrder",
table: "PurchaseEvaluations");
}
}
}

View File

@ -2508,6 +2508,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy") b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<int?>("CurrentWorkflowStepIndex") b.Property<int?>("CurrentWorkflowStepIndex")
.HasColumnType("int"); .HasColumnType("int");

View File

@ -18,11 +18,13 @@ namespace SolutionErp.Infrastructure.Services;
// └─ Từ chối ────────► Từ chối (TuChoi, terminal) // └─ Từ chối ────────► Từ chối (TuChoi, terminal)
// Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back) // Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back)
// //
// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại giờ là Phase // Service branch theo schema pin:
// RIÊNG (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại // - V2 (Mig 22-24): PE.ApprovalWorkflowId set → iterate ApprovalWorkflowSteps
// như case Nháp — workflow chạy lại từ Cấp 1 Bước 1. Field RejectedAtStepIndex // OrderBy Order → mỗi Step iterate Levels group by Order (Cấp 1/2/3, OR-of-N
// + RejectedFromPhase giữ DB column (nullable, không set value mới) cho data // approvers cùng cấp). Match actor.Id == ApprovalWorkflowLevel.ApproverUserId.
// cũ — sẽ cleanup migration sau. // CurrentApprovalLevelOrder track Cấp đang chờ.
// - V1 legacy (Mig 21): chỉ WorkflowDefinitionId set → iterate
// PurchaseEvaluationWorkflowSteps + match Dept+PositionLevel/Role/User.
public class PurchaseEvaluationWorkflowService( public class PurchaseEvaluationWorkflowService(
IApplicationDbContext db, IApplicationDbContext db,
IDateTime dateTime, IDateTime dateTime,
@ -59,6 +61,7 @@ public class PurchaseEvaluationWorkflowService(
// Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1. // Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1.
evaluation.Phase = PurchaseEvaluationPhase.TraLai; evaluation.Phase = PurchaseEvaluationPhase.TraLai;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
} }
evaluation.SlaDeadline = null; evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct); await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
@ -67,7 +70,7 @@ public class PurchaseEvaluationWorkflowService(
} }
// ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) ===== // ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) =====
// Cả 2 entry point cùng logic: chạy lại từ đầu (CurrentWorkflowStepIndex=0). // Cả 2 entry point cùng logic: chạy lại từ đầu (Step 0, Cấp 1).
if ((fromPhase == PurchaseEvaluationPhase.DangSoanThao if ((fromPhase == PurchaseEvaluationPhase.DangSoanThao
|| fromPhase == PurchaseEvaluationPhase.TraLai) || fromPhase == PurchaseEvaluationPhase.TraLai)
&& (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem)) && (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem))
@ -82,6 +85,8 @@ public class PurchaseEvaluationWorkflowService(
} }
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = 0; 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);
@ -91,78 +96,15 @@ public class PurchaseEvaluationWorkflowService(
// ===== APPROVE STEP (advance pointer trong ChoDuyet) ===== // ===== APPROVE STEP (advance pointer trong ChoDuyet) =====
if (fromPhase == PurchaseEvaluationPhase.ChoDuyet && decision == ApprovalDecision.Approve) if (fromPhase == PurchaseEvaluationPhase.ChoDuyet && decision == ApprovalDecision.Approve)
{ {
var def = evaluation.WorkflowDefinitionId is Guid wfId // Branch: V2 schema mới (ApprovalWorkflowId pin) hay V1 legacy
? await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() // (WorkflowDefinitionId pin Mig 21).
.Include(d => d.Steps.OrderBy(s => s.Order)) if (evaluation.ApprovalWorkflowId is Guid awId)
.ThenInclude(s => s.Approvers)
.FirstOrDefaultAsync(d => d.Id == wfId, ct)
: null;
if (def == null || def.Steps.Count == 0)
throw new ConflictException("Phiếu chưa pin workflow definition hoặc workflow không có step.");
var steps = def.Steps.OrderBy(s => s.Order).ToList();
var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
var currentStep = steps[currentIdx];
// Match approver — admin bypass policy
if (!isAdmin && !isSystem)
{ {
var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null; await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, ct);
if (actor == null)
throw new ForbiddenException("Không xác định được approver.");
var matchByDeptLevel = currentStep.DepartmentId != null
&& currentStep.PositionLevel != null
&& actor.DepartmentId == currentStep.DepartmentId
&& actor.PositionLevel != null
&& (int)actor.PositionLevel >= (int)currentStep.PositionLevel;
var matchByExplicitUser = currentStep.Approvers.Any(a =>
a.Kind == WorkflowApproverKind.User
&& Guid.TryParse(a.AssignmentValue, out var auid)
&& auid == actor.Id);
var matchByRole = currentStep.Approvers.Any(a =>
a.Kind == WorkflowApproverKind.Role
&& actorRoles.Contains(a.AssignmentValue));
if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole)
throw new ForbiddenException(
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
}
// Log approval row
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = fromPhase, // step advance — phase same
ApproverUserId = actorUserId,
Decision = decision,
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
// Advance pointer
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
// All steps done — terminal DaDuyet
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.DaDuyet, actorUserId, decision, comment, ct);
} }
else else
{ {
evaluation.CurrentWorkflowStepIndex = nextIdx; await ApproveV1LegacyAsync(evaluation, actorUserId, actorRoles, isAdmin, isSystem, comment, ct);
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, fromPhase, actorUserId, decision,
$"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return; return;
@ -182,6 +124,183 @@ public class PurchaseEvaluationWorkflowService(
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
} }
// ===== V2 schema (Mig 22-24) — iterate ApprovalWorkflowSteps + Levels =====
private async Task ApproveV2Async(
PurchaseEvaluation evaluation,
Guid awId,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
bool isAdmin,
bool isSystem,
string? comment,
CancellationToken ct)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
.FirstOrDefaultAsync(w => w.Id == awId, ct)
?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
if (steps.Count == 0)
throw new ConflictException("Quy trình chưa có bước nào.");
var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
var currentLevelOrder = evaluation.CurrentApprovalLevelOrder ?? 1;
var currentStep = steps[currentIdx];
// Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N).
var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
// Match approver: actor.Id ∈ pendingLevelGroup.ApproverUserId. Admin bypass.
if (!isAdmin && !isSystem)
{
if (actorUserId is null)
throw new ForbiddenException("Không xác định được approver.");
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
if (!allowedUserIds.Contains(actorUserId.Value))
{
var names = string.Join(", ", allowedUserIds);
throw new ForbiddenException(
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
}
}
// Log approval
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = evaluation.Phase,
ToPhase = evaluation.Phase,
ApproverUserId = actorUserId,
Decision = ApprovalDecision.Approve,
Comment = $"[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
if (currentLevelOrder < maxLevelOrder)
{
evaluation.CurrentApprovalLevelOrder = currentLevelOrder + 1;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, evaluation.Phase, evaluation.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất Cấp {currentLevelOrder}, sang Cấp {currentLevelOrder + 1} cùng Bước {currentIdx + 1}", ct);
return;
}
// Hết cấp trong Step — sang Step kế (Cấp 1)
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
// All Steps done — terminal DaDuyet
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet,
actorUserId, ApprovalDecision.Approve, comment, ct);
}
else
{
evaluation.CurrentWorkflowStepIndex = nextIdx;
evaluation.CurrentApprovalLevelOrder = 1;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, evaluation.Phase, evaluation.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count}, sang Bước {nextIdx + 1} (Cấp 1)", ct);
}
}
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====
private async Task ApproveV1LegacyAsync(
PurchaseEvaluation evaluation,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
bool isAdmin,
bool isSystem,
string? comment,
CancellationToken ct)
{
var def = evaluation.WorkflowDefinitionId is Guid wfId
? await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.FirstOrDefaultAsync(d => d.Id == wfId, ct)
: null;
if (def == null || def.Steps.Count == 0)
throw new ConflictException("Phiếu chưa pin workflow definition hoặc workflow không có step.");
var steps = def.Steps.OrderBy(s => s.Order).ToList();
var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
var currentStep = steps[currentIdx];
if (!isAdmin && !isSystem)
{
var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null;
if (actor == null)
throw new ForbiddenException("Không xác định được approver.");
var matchByDeptLevel = currentStep.DepartmentId != null
&& currentStep.PositionLevel != null
&& actor.DepartmentId == currentStep.DepartmentId
&& actor.PositionLevel != null
&& (int)actor.PositionLevel >= (int)currentStep.PositionLevel;
var matchByExplicitUser = currentStep.Approvers.Any(a =>
a.Kind == WorkflowApproverKind.User
&& Guid.TryParse(a.AssignmentValue, out var auid)
&& auid == actor.Id);
var matchByRole = currentStep.Approvers.Any(a =>
a.Kind == WorkflowApproverKind.Role
&& actorRoles.Contains(a.AssignmentValue));
if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole)
throw new ForbiddenException(
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
}
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = evaluation.Phase,
ToPhase = evaluation.Phase,
ApproverUserId = actorUserId,
Decision = ApprovalDecision.Approve,
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet,
actorUserId, ApprovalDecision.Approve, comment, ct);
}
else
{
evaluation.CurrentWorkflowStepIndex = nextIdx;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, evaluation.Phase, evaluation.Phase, actorUserId, ApprovalDecision.Approve,
$"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
}
}
private async Task LogTransitionAsync( private async Task LogTransitionAsync(
PurchaseEvaluation evaluation, PurchaseEvaluation evaluation,
PurchaseEvaluationPhase fromPhase, PurchaseEvaluationPhase fromPhase,