[CLAUDE] Infra: PE workflow service N-stage logic + Migration 19 unique filter (Chunk C)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m2s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m2s
Migration 19 `AlterPeDeptApprovalsUniqueFilteredForInnerSteps` — fix
UNIQUE constraint conflict khi N-stage có nhiều inner step cùng dept:
- Drop UX_PEDeptApprovals_PE_Phase_Dept_Stage (Mig 16)
- Recreate filtered: UNIQUE (PEId, Phase, Dept, Stage) WHERE InnerStepId IS NULL
(legacy 2-stage rows giữ nguyên invariant)
- New filtered: UNIQUE (PEId, Phase, InnerStepId) WHERE InnerStepId IS NOT NULL
(N-stage 1 row per inner step per phase)
PurchaseEvaluationWorkflowService.TransitionAsync refactor:
- Load definition with InnerSteps eager (.Include ThenInclude)
- Reject branch: clear N-stage approval rows tại fromPhase (resume sẽ
approve lại từ inner step đầu — clean state)
- Department approval block split:
* hasInnerSteps=true → N-stage logic:
- Yêu cầu actor có DepartmentId + PositionLevel set (else throw 403)
- Match firstPending inner step (Order asc, IsRequired only):
same dept AND (exact PositionLevel OR canBypass + level ≥ pending)
- exact match: upsert 1 row (Stage=Confirm, InnerStepId=that)
- bypass: batch upsert NV+PP+TP cùng dept ≤ actor level (audit
IsBypassed=true cho cấp dưới skip)
- Recheck stillPending → BLOCK transition + log Approval/Changelog
"đã duyệt cấp X (còn Y cấp pending)"
- All done → fall through phase transition
* hasInnerSteps=false → Legacy 2-stage (Mig 16) — giữ nguyên logic
NV.Review/TPB.Confirm + InnerStepId=null filter
Backward compat: workflow cũ (no InnerSteps configured) chạy đúng logic
2-stage Mig 16. Data legacy InnerStepId=null vẫn match unique cũ qua
filtered index. Tests 83 pass — no regression.
Verify:
- dotnet build SolutionErp.slnx 0 error
- dotnet ef database update LocalDB applied Mig 19
- dotnet test 83 pass
Pending Chunk D: Tests N-stage workflow (~6-7 test mới: sequential pass /
bypass cùng dept / reject reset / resume jump-back / legacy fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -53,13 +53,24 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration
|
|||||||
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
|
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
|
||||||
b.Property(x => x.Comment).HasMaxLength(1000);
|
b.Property(x => x.Comment).HasMaxLength(1000);
|
||||||
|
|
||||||
|
// Legacy 2-stage rows (Mig 16): UNIQUE (PEId, Phase, Dept, Stage) chỉ áp
|
||||||
|
// khi InnerStepId IS NULL — tránh conflict với N-stage rows nhiều InnerStep
|
||||||
|
// cùng dept cùng Stage=Confirm.
|
||||||
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
|
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
|
.HasFilter("[InnerStepId] IS NULL")
|
||||||
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
|
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
|
||||||
|
|
||||||
|
// N-stage rows (Mig 18+): UNIQUE (PEId, Phase, InnerStepId) — 1 approval row
|
||||||
|
// per (phase × inner step) khi InnerStepId IS NOT NULL.
|
||||||
|
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.InnerStepId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("[InnerStepId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_InnerStep");
|
||||||
|
|
||||||
b.HasIndex(x => x.PurchaseEvaluationId);
|
b.HasIndex(x => x.PurchaseEvaluationId);
|
||||||
b.HasIndex(x => x.DepartmentId);
|
b.HasIndex(x => x.DepartmentId);
|
||||||
b.HasIndex(x => x.ApproverUserId);
|
b.HasIndex(x => x.ApproverUserId);
|
||||||
b.HasIndex(x => x.InnerStepId); // Mig 18 — query rows by sub-step
|
|
||||||
|
|
||||||
b.HasOne(x => x.PurchaseEvaluation)
|
b.HasOne(x => x.PurchaseEvaluation)
|
||||||
.WithMany(c => c.DepartmentApprovals)
|
.WithMany(c => c.DepartmentApprovals)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AlterPeDeptApprovalsUniqueFilteredForInnerSteps : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
|
||||||
|
table: "PurchaseEvaluationDepartmentApprovals");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
|
||||||
|
table: "PurchaseEvaluationDepartmentApprovals",
|
||||||
|
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[InnerStepId] IS NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_PEDeptApprovals_PE_Phase_InnerStep",
|
||||||
|
table: "PurchaseEvaluationDepartmentApprovals",
|
||||||
|
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "InnerStepId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[InnerStepId] IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
|
||||||
|
table: "PurchaseEvaluationDepartmentApprovals");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "UX_PEDeptApprovals_PE_Phase_InnerStep",
|
||||||
|
table: "PurchaseEvaluationDepartmentApprovals");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
|
||||||
|
table: "PurchaseEvaluationDepartmentApprovals",
|
||||||
|
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2693,9 +2693,15 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("PurchaseEvaluationId");
|
b.HasIndex("PurchaseEvaluationId");
|
||||||
|
|
||||||
|
b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "InnerStepId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_InnerStep")
|
||||||
|
.HasFilter("[InnerStepId] IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage")
|
b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
|
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage")
|
||||||
|
.HasFilter("[InnerStepId] IS NULL");
|
||||||
|
|
||||||
b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null);
|
b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,6 +45,15 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
{
|
{
|
||||||
evaluation.RejectedFromPhase = fromPhase;
|
evaluation.RejectedFromPhase = fromPhase;
|
||||||
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
|
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
|
||||||
|
|
||||||
|
// N-stage state reset (Mig 18): clear inner step approval rows tại
|
||||||
|
// fromPhase. User resume sẽ approve lại từ inner step đầu.
|
||||||
|
var staleNStageRows = await db.PurchaseEvaluationDepartmentApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == evaluation.Id
|
||||||
|
&& a.PhaseAtApproval == (int)fromPhase
|
||||||
|
&& a.InnerStepId != null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var r in staleNStageRows) db.PurchaseEvaluationDepartmentApprovals.Remove(r);
|
||||||
}
|
}
|
||||||
else if (isResumingAfterReject)
|
else if (isResumingAfterReject)
|
||||||
{
|
{
|
||||||
@ -53,14 +62,17 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
PurchaseEvaluationPolicy policy;
|
PurchaseEvaluationPolicy policy;
|
||||||
|
PurchaseEvaluationWorkflowDefinition? definition = null;
|
||||||
if (evaluation.WorkflowDefinitionId is Guid wfId)
|
if (evaluation.WorkflowDefinitionId is Guid wfId)
|
||||||
{
|
{
|
||||||
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
definition = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
||||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||||
.ThenInclude(s => s.Approvers)
|
.ThenInclude(s => s.Approvers)
|
||||||
|
.Include(d => d.Steps)
|
||||||
|
.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))
|
||||||
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
||||||
policy = def is not null
|
policy = definition is not null
|
||||||
? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
|
? PurchaseEvaluationPolicyRegistry.FromDefinition(definition)
|
||||||
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
|
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -86,13 +98,12 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
|
// ===== Department approval (N-stage Mig 18 hoặc Legacy 2-stage Mig 16) =====
|
||||||
// Bug fix anh Kiệt: NV duyệt được hết phase. Logic mới:
|
// Active block khi: Approve + chuyển sang phase trung gian (không phải
|
||||||
// - User.DepartmentId != null + KHÔNG admin/system + KHÔNG resume:
|
// DangSoanThao/TuChoi) + KHÔNG admin/system + KHÔNG resume sau reject.
|
||||||
// - DeptManager (TPB) → Stage=Confirm trực tiếp
|
var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase);
|
||||||
// - CanBypassReview=true → Stage=Confirm + IsBypassed=true
|
var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0;
|
||||||
// - Else (NV) → Stage=Review only, BLOCK phase transition cho đến khi TPB confirm
|
|
||||||
// - Skip với reject + resume + admin + system + actor không thuộc dept.
|
|
||||||
if (decision == ApprovalDecision.Approve
|
if (decision == ApprovalDecision.Approve
|
||||||
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
|
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
|
||||||
&& targetPhase != PurchaseEvaluationPhase.TuChoi
|
&& targetPhase != PurchaseEvaluationPhase.TuChoi
|
||||||
@ -101,21 +112,143 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
&& actorUserId is Guid actorUid)
|
&& actorUserId is Guid actorUid)
|
||||||
{
|
{
|
||||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||||
if (actor?.DepartmentId is Guid deptId)
|
|
||||||
|
if (hasInnerSteps && currentStepDef is not null)
|
||||||
{
|
{
|
||||||
|
// ===== N-stage logic (Mig 18) =====
|
||||||
|
// Yêu cầu user có DepartmentId + PositionLevel set. Match exact
|
||||||
|
// (DeptId + PositionLevel) inner step pending tiếp theo theo Order.
|
||||||
|
// Bypass: actor cùng dept + PositionLevel cao hơn + CanBypassReview
|
||||||
|
// → batch upsert luôn các inner step cấp dưới (audit IsBypassed=true).
|
||||||
|
if (actor?.DepartmentId is null || actor.PositionLevel is null)
|
||||||
|
{
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"User phải có Phòng + Cấp chức danh (NV/PP/TP) để duyệt N-stage workflow.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var actorDept = actor.DepartmentId.Value;
|
||||||
|
var actorPos = actor.PositionLevel.Value;
|
||||||
|
var canBypass = actor.CanBypassReview;
|
||||||
|
|
||||||
|
var inners = currentStepDef.InnerSteps.OrderBy(i => i.Order).ToList();
|
||||||
|
var innerIds = inners.Select(i => i.Id).ToList();
|
||||||
|
|
||||||
|
var existingApprovals = await db.PurchaseEvaluationDepartmentApprovals
|
||||||
|
.Where(a => a.PurchaseEvaluationId == evaluation.Id
|
||||||
|
&& a.PhaseAtApproval == (int)fromPhase
|
||||||
|
&& a.InnerStepId != null
|
||||||
|
&& innerIds.Contains(a.InnerStepId!.Value))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var doneInnerIds = existingApprovals.Select(a => a.InnerStepId!.Value).ToHashSet();
|
||||||
|
|
||||||
|
var pendingRequired = inners.Where(i => i.IsRequired && !doneInnerIds.Contains(i.Id)).ToList();
|
||||||
|
if (pendingRequired.Count > 0)
|
||||||
|
{
|
||||||
|
var firstPending = pendingRequired[0];
|
||||||
|
|
||||||
|
// Match: same dept AND (exact level OR canBypass + level higher)
|
||||||
|
var levelOk = actorPos == firstPending.PositionLevel
|
||||||
|
|| (canBypass && (int)actorPos >= (int)firstPending.PositionLevel);
|
||||||
|
if (actorDept != firstPending.DepartmentId || !levelOk)
|
||||||
|
{
|
||||||
|
throw new ForbiddenException(
|
||||||
|
$"Cấp duyệt tiếp theo: phòng {firstPending.DepartmentId} cấp {firstPending.PositionLevel}. " +
|
||||||
|
$"Bạn (phòng {actorDept} cấp {actorPos}{(canBypass ? "+bypass" : "")}) không khớp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine rows to upsert
|
||||||
|
var rowsToCreate = new List<(PurchaseEvaluationWorkflowStepInnerStep i, bool bypassed)>();
|
||||||
|
if (actorPos == firstPending.PositionLevel)
|
||||||
|
{
|
||||||
|
rowsToCreate.Add((firstPending, false));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Bypass cùng dept: upsert tất cả pending inner trong dept actor có level
|
||||||
|
// từ firstPending.PositionLevel đến actorPos (inclusive)
|
||||||
|
foreach (var inner in inners
|
||||||
|
.Where(i => i.DepartmentId == actorDept
|
||||||
|
&& (int)i.PositionLevel >= (int)firstPending.PositionLevel
|
||||||
|
&& (int)i.PositionLevel <= (int)actorPos
|
||||||
|
&& !doneInnerIds.Contains(i.Id)))
|
||||||
|
{
|
||||||
|
rowsToCreate.Add((inner, inner.PositionLevel != actorPos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowUtc = dateTime.UtcNow;
|
||||||
|
foreach (var (inner, bypassed) in rowsToCreate)
|
||||||
|
{
|
||||||
|
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
|
PhaseAtApproval = (int)fromPhase,
|
||||||
|
DepartmentId = inner.DepartmentId,
|
||||||
|
Stage = ApprovalStage.Confirm, // N-stage: tất cả row dùng Confirm semantically
|
||||||
|
ApproverUserId = actorUid,
|
||||||
|
ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}",
|
||||||
|
Comment = comment,
|
||||||
|
ApprovedAt = nowUtc,
|
||||||
|
IsBypassed = bypassed,
|
||||||
|
InnerStepId = inner.Id,
|
||||||
|
});
|
||||||
|
doneInnerIds.Add(inner.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recheck remaining pending sau upsert
|
||||||
|
var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id));
|
||||||
|
if (stillPending)
|
||||||
|
{
|
||||||
|
// Còn cấp duyệt tiếp theo → BLOCK phase transition.
|
||||||
|
// Log Approval (review-style) + Changelog audit.
|
||||||
|
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
|
FromPhase = fromPhase,
|
||||||
|
ToPhase = fromPhase,
|
||||||
|
ApproverUserId = actorUid,
|
||||||
|
Decision = ApprovalDecision.Approve,
|
||||||
|
Comment = $"[Inner step duyệt {actorPos}] {comment ?? ""}",
|
||||||
|
ApprovedAt = nowUtc,
|
||||||
|
});
|
||||||
|
|
||||||
|
string? reviewerName = actor.FullName ?? actor.Email;
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||||
|
Action = ChangelogAction.Transition,
|
||||||
|
PhaseAtChange = fromPhase,
|
||||||
|
UserId = actorUid,
|
||||||
|
UserName = reviewerName ?? "Hệ thống",
|
||||||
|
Summary = $"{reviewerName} duyệt cấp {actorPos} phase {fromPhase} (còn {inners.Count(i => i.IsRequired && !doneInnerIds.Contains(i.Id))} cấp pending)",
|
||||||
|
ContextNote = comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// All required inner steps done → fall through to phase transition
|
||||||
|
}
|
||||||
|
// pendingRequired.Count == 0 → all already done before this call → fall through
|
||||||
|
}
|
||||||
|
else if (actor?.DepartmentId is Guid deptId)
|
||||||
|
{
|
||||||
|
// ===== Legacy 2-stage logic (Mig 16) — fallback khi step KHÔNG có InnerSteps =====
|
||||||
var isManager = actorRoles.Contains(AppRoles.DeptManager);
|
var isManager = actorRoles.Contains(AppRoles.DeptManager);
|
||||||
var canBypass = actor.CanBypassReview;
|
var canBypass = actor.CanBypassReview;
|
||||||
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
||||||
var isBypassed = !isManager && canBypass;
|
var isBypassed = !isManager && canBypass;
|
||||||
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
||||||
|
|
||||||
// Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE index enforce.
|
// Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE filtered InnerStepId IS NULL.
|
||||||
var existing = await db.PurchaseEvaluationDepartmentApprovals
|
var existing = await db.PurchaseEvaluationDepartmentApprovals
|
||||||
.FirstOrDefaultAsync(a =>
|
.FirstOrDefaultAsync(a =>
|
||||||
a.PurchaseEvaluationId == evaluation.Id
|
a.PurchaseEvaluationId == evaluation.Id
|
||||||
&& a.PhaseAtApproval == (int)fromPhase
|
&& a.PhaseAtApproval == (int)fromPhase
|
||||||
&& a.DepartmentId == deptId
|
&& a.DepartmentId == deptId
|
||||||
&& a.Stage == stage, ct);
|
&& a.Stage == stage
|
||||||
|
&& a.InnerStepId == null, ct);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
|
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
|
||||||
@ -129,6 +262,7 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
Comment = comment,
|
Comment = comment,
|
||||||
ApprovedAt = dateTime.UtcNow,
|
ApprovedAt = dateTime.UtcNow,
|
||||||
IsBypassed = isBypassed,
|
IsBypassed = isBypassed,
|
||||||
|
InnerStepId = null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -146,24 +280,24 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
a.PurchaseEvaluationId == evaluation.Id
|
a.PurchaseEvaluationId == evaluation.Id
|
||||||
&& a.PhaseAtApproval == (int)fromPhase
|
&& a.PhaseAtApproval == (int)fromPhase
|
||||||
&& a.DepartmentId == deptId
|
&& a.DepartmentId == deptId
|
||||||
&& a.Stage == ApprovalStage.Confirm, ct);
|
&& a.Stage == ApprovalStage.Confirm
|
||||||
|
&& a.InnerStepId == null, ct);
|
||||||
|
|
||||||
if (!hasConfirm)
|
if (!hasConfirm)
|
||||||
{
|
{
|
||||||
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
|
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
|
||||||
// Log Approval + Changelog "đã review" để audit. Phase giữ nguyên.
|
|
||||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||||
{
|
{
|
||||||
PurchaseEvaluationId = evaluation.Id,
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
FromPhase = fromPhase,
|
FromPhase = fromPhase,
|
||||||
ToPhase = fromPhase, // không đổi phase
|
ToPhase = fromPhase,
|
||||||
ApproverUserId = actorUid,
|
ApproverUserId = actorUid,
|
||||||
Decision = ApprovalDecision.Approve,
|
Decision = ApprovalDecision.Approve,
|
||||||
Comment = $"[Review NV] {comment ?? ""}",
|
Comment = $"[Review NV] {comment ?? ""}",
|
||||||
ApprovedAt = dateTime.UtcNow,
|
ApprovedAt = dateTime.UtcNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
string? reviewerName = (actor.FullName ?? actor.Email);
|
string? reviewerName = actor.FullName ?? actor.Email;
|
||||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
{
|
{
|
||||||
PurchaseEvaluationId = evaluation.Id,
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
@ -185,9 +319,6 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
if (managers.Count > 0)
|
if (managers.Count > 0)
|
||||||
{
|
{
|
||||||
// Filter: chỉ notify user có role DeptManager (TPB).
|
|
||||||
// Không có direct join với UserRoles ở IApplicationDbContext —
|
|
||||||
// dùng UserManager để filter từng user.
|
|
||||||
foreach (var mgrId in managers)
|
foreach (var mgrId in managers)
|
||||||
{
|
{
|
||||||
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||||
|
|||||||
Reference in New Issue
Block a user