[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
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:
@ -430,9 +430,13 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||
|
||||
// Resolve workflow policy
|
||||
// Resolve workflow policy (V2 ưu tiên nếu pin)
|
||||
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()
|
||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||
|
||||
@ -43,12 +43,17 @@ public class PurchaseEvaluation : AuditableEntity
|
||||
|
||||
// Flat workflow tracking (Session 16 — Migration 21):
|
||||
// - CurrentWorkflowStepIndex: 0-based index của step đang chờ approver
|
||||
// (khi Phase=ChoDuyet). Null khi DangSoanThao/DaDuyet/TuChoi.
|
||||
// - RejectedAtStepIndex: snapshot CurrentWorkflowStepIndex tại Trả lại.
|
||||
// Drafter resume → restore CurrentWorkflowStepIndex (jump-back).
|
||||
// (khi Phase=ChoDuyet). Null khi DangSoanThao/DaDuyet/TuChoi/TraLai.
|
||||
// - RejectedAtStepIndex: [DEPRECATED Session 17] snapshot index tại Trả lại.
|
||||
// Field giữ DB column cho data cũ — Service không set value mới.
|
||||
public int? CurrentWorkflowStepIndex { 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<PurchaseEvaluationDetail> Details { get; set; } = new();
|
||||
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
||||
|
||||
@ -156,6 +156,52 @@ public static class PurchaseEvaluationPolicyRegistry
|
||||
public static PurchaseEvaluationPolicy ForEvaluation(PurchaseEvaluation ev) =>
|
||||
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
|
||||
// WorkflowPolicyRegistry.FromDefinition for HĐ).
|
||||
public static PurchaseEvaluationPolicy FromDefinition(PurchaseEvaluationWorkflowDefinition def)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2508,6 +2508,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("CurrentWorkflowStepIndex")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
@ -18,11 +18,13 @@ namespace SolutionErp.Infrastructure.Services;
|
||||
// └─ 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)
|
||||
//
|
||||
// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại giờ là Phase
|
||||
// RIÊNG (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại
|
||||
// như case Nháp — workflow chạy lại từ Cấp 1 Bước 1. Field RejectedAtStepIndex
|
||||
// + RejectedFromPhase giữ DB column (nullable, không set value mới) cho data
|
||||
// cũ — sẽ cleanup migration sau.
|
||||
// Service branch theo schema pin:
|
||||
// - V2 (Mig 22-24): PE.ApprovalWorkflowId set → iterate ApprovalWorkflowSteps
|
||||
// OrderBy Order → mỗi Step iterate Levels group by Order (Cấp 1/2/3, OR-of-N
|
||||
// approvers cùng cấp). Match actor.Id == ApprovalWorkflowLevel.ApproverUserId.
|
||||
// CurrentApprovalLevelOrder track Cấp đang chờ.
|
||||
// - V1 legacy (Mig 21): chỉ WorkflowDefinitionId set → iterate
|
||||
// PurchaseEvaluationWorkflowSteps + match Dept+PositionLevel/Role/User.
|
||||
public class PurchaseEvaluationWorkflowService(
|
||||
IApplicationDbContext db,
|
||||
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.
|
||||
evaluation.Phase = PurchaseEvaluationPhase.TraLai;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
}
|
||||
evaluation.SlaDeadline = null;
|
||||
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) =====
|
||||
// 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
|
||||
|| fromPhase == PurchaseEvaluationPhase.TraLai)
|
||||
&& (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem))
|
||||
@ -82,6 +85,8 @@ public class PurchaseEvaluationWorkflowService(
|
||||
}
|
||||
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||
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);
|
||||
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@ -91,78 +96,15 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// ===== APPROVE STEP (advance pointer trong ChoDuyet) =====
|
||||
if (fromPhase == PurchaseEvaluationPhase.ChoDuyet && decision == ApprovalDecision.Approve)
|
||||
{
|
||||
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];
|
||||
|
||||
// Match approver — admin bypass policy
|
||||
if (!isAdmin && !isSystem)
|
||||
// Branch: V2 schema mới (ApprovalWorkflowId pin) hay V1 legacy
|
||||
// (WorkflowDefinitionId pin Mig 21).
|
||||
if (evaluation.ApprovalWorkflowId is Guid awId)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
// 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);
|
||||
await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
evaluation.CurrentWorkflowStepIndex = nextIdx;
|
||||
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 ApproveV1LegacyAsync(evaluation, actorUserId, actorRoles, isAdmin, isSystem, comment, ct);
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
@ -182,6 +124,183 @@ public class PurchaseEvaluationWorkflowService(
|
||||
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(
|
||||
PurchaseEvaluation evaluation,
|
||||
PurchaseEvaluationPhase fromPhase,
|
||||
|
||||
Reference in New Issue
Block a user