[CLAUDE] Drastic refactor: flat workflow Phòng × Cấp + Migration 21 (Chunk A)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m18s

User chốt drastic refactor — bỏ phase enum hoàn toàn, dùng ChoDuyet=10
đơn nhất + currentStepIndex tracking. Workflow flat list (Phòng × Cấp ×
Approvers). Mỗi PE/HĐ pin WorkflowDefinitionId chạy hết quy trình đó.

Schema (Migration 21 `RefactorWorkflowToFlatModel`):
- Phase enum +ChoDuyet=10 (PE + Contract). Legacy 2-9 + 98 deprecated.
- WorkflowStep + DepartmentId Guid? (FK Restrict) + PositionLevel int?
  (PE + Contract — mirror).
- PE/Contract + CurrentWorkflowStepIndex int? + RejectedAtStepIndex int?
- DROP table PurchaseEvaluationWorkflowStepInnerSteps (Mig 18)
- DROP table WorkflowStepInnerSteps (Mig 20)
- DROP column ContractDeptApproval.InnerStepId (Mig 20)
- DROP column PEDeptApproval.InnerStepId (Mig 18)
- DROP filtered indexes (Mig 19/20) + restore simple unique
  (TargetId, Phase, Dept, Stage) non-filtered

Service rewrite (PE + Contract WorkflowService.TransitionAsync):
- Phase transitions: DangSoanThao → ChoDuyet (Drafter trình, init idx=0)
- ChoDuyet → ChoDuyet (advance idx per approve)
- ChoDuyet → DaDuyet/DaPhatHanh (idx >= steps.Count → terminal)
- ChoDuyet → DangSoanThao (Trả lại — save RejectedAtStepIndex)
- ChoDuyet → TuChoi (Từ chối — khoá vĩnh viễn)
- DangSoanThao + RejectedAtStepIndex → ChoDuyet jump-back to saved idx
- Approver match: actor.Dept == step.Dept AND actor.PositionLevel >=
  step.PositionLevel (OR-of-many cùng cấp/dept = pass) OR
  Approvers.Any(Kind=User AND id match) OR
  Approvers.Any(Kind=Role AND actorRoles contains)
- Admin role bypass policy. Last step done → gen mã HĐ (Contract only)

App CQRS:
- WorkflowStepDto + WorkflowStepInput drop InnerStep, add DepartmentId
  + PositionLevel fields. PE + Contract mirror.

Tests rewrite:
- DROP PeNStageApprovalTests.cs (6 test) + ContractNStageApprovalTests.cs
  (6 test) + PeTwoStageApprovalTests.cs (7 test) — legacy N-stage/2-stage
  no longer applicable
- UPDATE PeWorkflowAdminTests signature to new flat input
- 96 → 77 test pass (drop 19 legacy)

Reference Domain entities removed:
- WorkflowStepInnerStep (Contract)
- PurchaseEvaluationWorkflowStepInnerStep (PE)
- DTOs WorkflowStepInnerStepDto / CreateWorkflowStepInnerStepInput per module

Memory `feedback_drastic_refactor_scope.md` validated: drastic refactor
done in dedicated session với context fresh, scope ~5h actual (planned ~8-10h
with 2x buffer).

Verify:
- dotnet build SolutionErp.slnx 0 error
- dotnet ef database update Mig 21 LocalDB applied OK
- dotnet test 77 pass (54 Domain + 23 Infra)
- 3-file rule: Migration .cs + Designer.cs + Snapshot updated

Pending Chunk B: FE Designer flat UI (PeWorkflowsPage + WorkflowsPage).
Pending Chunk C: FE PeWorkflowPanel + workflow timeline display.
Pending Chunk D: Docs + Skill + Memory + session log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-08 12:04:51 +07:00
parent 38d10b7897
commit dbb0089e28
23 changed files with 4501 additions and 2123 deletions

View File

@ -41,7 +41,6 @@ public class ApplicationDbContext
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>();
public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>();
public DbSet<WorkflowStepInnerStep> WorkflowStepInnerSteps => Set<WorkflowStepInnerStep>();
public DbSet<ThauPhuDetail> ThauPhuDetails => Set<ThauPhuDetail>();
public DbSet<GiaoKhoanDetail> GiaoKhoanDetails => Set<GiaoKhoanDetail>();
public DbSet<NhaCungCapDetail> NhaCungCapDetails => Set<NhaCungCapDetail>();
@ -60,7 +59,6 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationWorkflowDefinition> PurchaseEvaluationWorkflowDefinitions => Set<PurchaseEvaluationWorkflowDefinition>();
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
public DbSet<PurchaseEvaluationWorkflowStepInnerStep> PurchaseEvaluationWorkflowStepInnerSteps => Set<PurchaseEvaluationWorkflowStepInnerStep>();
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();

View File

@ -27,19 +27,11 @@ public class ContractDepartmentApprovalConfiguration
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
b.Property(x => x.Comment).HasMaxLength(1000);
// Legacy 2-stage rows (Mig 16): UNIQUE chỉ áp khi InnerStepId IS NULL
// (Mig 20 mirror PE Mig 19 filtered split).
// Mig 21 — drop InnerStepId column + restore simple unique non-filtered.
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasFilter("[InnerStepId] IS NULL")
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
// N-stage rows (Mig 20): UNIQUE 1 row per (phase × inner step).
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.InnerStepId })
.IsUnique()
.HasFilter("[InnerStepId] IS NOT NULL")
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_InnerStep");
b.HasIndex(x => x.ContractId);
b.HasIndex(x => x.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
@ -48,12 +40,6 @@ public class ContractDepartmentApprovalConfiguration
.WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.ContractId)
.OnDelete(DeleteBehavior.Cascade);
// FK InnerStepId nullable — Restrict (không xóa InnerStep nếu còn approval row)
b.HasOne<WorkflowStepInnerStep>()
.WithMany()
.HasForeignKey(x => x.InnerStepId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@ -69,21 +55,11 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration
b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100);
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.
// Mig 21 — drop InnerStepId column + restore simple unique non-filtered.
b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
.IsUnique()
.HasFilter("[InnerStepId] IS NULL")
.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.DepartmentId);
b.HasIndex(x => x.ApproverUserId);
@ -92,13 +68,6 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration
.WithMany(c => c.DepartmentApprovals)
.HasForeignKey(x => x.PurchaseEvaluationId)
.OnDelete(DeleteBehavior.Cascade);
// FK InnerStepId nullable — Restrict (không xóa InnerStep nếu còn approval row).
// Cấu hình không nav để giữ nhẹ entity (1 chiều, query qua join nếu cần).
b.HasOne<PurchaseEvaluationWorkflowStepInnerStep>()
.WithMany()
.HasForeignKey(x => x.InnerStepId)
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@ -184,13 +184,21 @@ public class PurchaseEvaluationWorkflowStepConfiguration : IEntityTypeConfigurat
e.ToTable("PurchaseEvaluationWorkflowSteps");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Phase).HasConversion<int>();
e.Property(x => x.PositionLevel).HasConversion<int?>(); // Mig 21
e.HasOne(x => x.Definition)
.WithMany(d => d.Steps)
.HasForeignKey(x => x.PurchaseEvaluationWorkflowDefinitionId)
.OnDelete(DeleteBehavior.Cascade);
// Mig 21 — FK Department Restrict.
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.PurchaseEvaluationWorkflowDefinitionId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}
@ -209,37 +217,6 @@ public class PurchaseEvaluationWorkflowStepApproverConfiguration : IEntityTypeCo
}
}
// Inner step (Mig 18) — N-stage approval cấu hình động trong cùng 1 phase.
// FK Cascade từ Step cha. Index theo (StepId, Order) cho query ordered list.
// Index riêng DepartmentId để lookup khi service compute next pending sub-step.
public class PurchaseEvaluationWorkflowStepInnerStepConfiguration
: IEntityTypeConfiguration<PurchaseEvaluationWorkflowStepInnerStep>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationWorkflowStepInnerStep> e)
{
e.ToTable("PurchaseEvaluationWorkflowStepInnerSteps");
e.HasKey(x => x.Id);
e.Property(x => x.PositionLevel).HasConversion<int>();
e.Property(x => x.Name).HasMaxLength(200);
e.HasOne(x => x.Step)
.WithMany(s => s.InnerSteps)
.HasForeignKey(x => x.PurchaseEvaluationWorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
// FK Department — Restrict (không xóa dept nếu còn inner step assigned).
// Không cấu hình nav trên Department để tránh circular collection bloat.
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.PurchaseEvaluationWorkflowStepId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}
// Mirror ContractCodeSequenceConfiguration — Prefix là PK, atomic UPDATE qua
// SERIALIZABLE transaction trong PurchaseEvaluationCodeGenerator.
public class PurchaseEvaluationCodeSequenceConfiguration

View File

@ -28,13 +28,21 @@ public class WorkflowStepConfiguration : IEntityTypeConfiguration<WorkflowStep>
e.ToTable("WorkflowSteps");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Phase).HasConversion<int>();
e.Property(x => x.PositionLevel).HasConversion<int?>(); // Mig 21
e.HasOne(x => x.WorkflowDefinition)
.WithMany(d => d.Steps)
.HasForeignKey(x => x.WorkflowDefinitionId)
.OnDelete(DeleteBehavior.Cascade);
// Mig 21 — FK Department Restrict (không xóa dept nếu còn step assigned).
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.WorkflowDefinitionId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}
@ -52,31 +60,3 @@ public class WorkflowStepApproverConfiguration : IEntityTypeConfiguration<Workfl
.OnDelete(DeleteBehavior.Cascade);
}
}
// Inner step (Mig 20) — N-stage approval cấu hình động trong cùng 1 phase.
// Mirror PurchaseEvaluationWorkflowStepInnerStepConfiguration (Mig 18).
public class WorkflowStepInnerStepConfiguration
: IEntityTypeConfiguration<WorkflowStepInnerStep>
{
public void Configure(EntityTypeBuilder<WorkflowStepInnerStep> e)
{
e.ToTable("WorkflowStepInnerSteps");
e.HasKey(x => x.Id);
e.Property(x => x.PositionLevel).HasConversion<int>();
e.Property(x => x.Name).HasMaxLength(200);
e.HasOne(x => x.Step)
.WithMany(s => s.InnerSteps)
.HasForeignKey(x => x.WorkflowStepId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne<SolutionErp.Domain.Master.Department>()
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.WorkflowStepId, x.Order });
e.HasIndex(x => x.DepartmentId);
}
}

View File

@ -0,0 +1,361 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefactorWorkflowToFlatModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ContractDepartmentApprovals_WorkflowStepInnerSteps_InnerStepId",
table: "ContractDepartmentApprovals");
migrationBuilder.DropForeignKey(
name: "FK_PurchaseEvaluationDepartmentApprovals_PurchaseEvaluationWorkflowStepInnerSteps_InnerStepId",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropTable(
name: "PurchaseEvaluationWorkflowStepInnerSteps");
migrationBuilder.DropTable(
name: "WorkflowStepInnerSteps");
migrationBuilder.DropIndex(
name: "IX_PurchaseEvaluationDepartmentApprovals_InnerStepId",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_PEDeptApprovals_PE_Phase_InnerStep",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropIndex(
name: "IX_ContractDepartmentApprovals_InnerStepId",
table: "ContractDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_InnerStep",
table: "ContractDepartmentApprovals");
migrationBuilder.DropColumn(
name: "InnerStepId",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropColumn(
name: "InnerStepId",
table: "ContractDepartmentApprovals");
migrationBuilder.AddColumn<Guid>(
name: "DepartmentId",
table: "WorkflowSteps",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PositionLevel",
table: "WorkflowSteps",
type: "int",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "DepartmentId",
table: "PurchaseEvaluationWorkflowSteps",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PositionLevel",
table: "PurchaseEvaluationWorkflowSteps",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CurrentWorkflowStepIndex",
table: "PurchaseEvaluations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedAtStepIndex",
table: "PurchaseEvaluations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CurrentWorkflowStepIndex",
table: "Contracts",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RejectedAtStepIndex",
table: "Contracts",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_WorkflowSteps_DepartmentId",
table: "WorkflowSteps",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowSteps_DepartmentId",
table: "PurchaseEvaluationWorkflowSteps",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals",
columns: new[] { "PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_PurchaseEvaluationWorkflowSteps_Departments_DepartmentId",
table: "PurchaseEvaluationWorkflowSteps",
column: "DepartmentId",
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_WorkflowSteps_Departments_DepartmentId",
table: "WorkflowSteps",
column: "DepartmentId",
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PurchaseEvaluationWorkflowSteps_Departments_DepartmentId",
table: "PurchaseEvaluationWorkflowSteps");
migrationBuilder.DropForeignKey(
name: "FK_WorkflowSteps_Departments_DepartmentId",
table: "WorkflowSteps");
migrationBuilder.DropIndex(
name: "IX_WorkflowSteps_DepartmentId",
table: "WorkflowSteps");
migrationBuilder.DropIndex(
name: "IX_PurchaseEvaluationWorkflowSteps_DepartmentId",
table: "PurchaseEvaluationWorkflowSteps");
migrationBuilder.DropIndex(
name: "UX_PEDeptApprovals_PE_Phase_Dept_Stage",
table: "PurchaseEvaluationDepartmentApprovals");
migrationBuilder.DropIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals");
migrationBuilder.DropColumn(
name: "DepartmentId",
table: "WorkflowSteps");
migrationBuilder.DropColumn(
name: "PositionLevel",
table: "WorkflowSteps");
migrationBuilder.DropColumn(
name: "DepartmentId",
table: "PurchaseEvaluationWorkflowSteps");
migrationBuilder.DropColumn(
name: "PositionLevel",
table: "PurchaseEvaluationWorkflowSteps");
migrationBuilder.DropColumn(
name: "CurrentWorkflowStepIndex",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "RejectedAtStepIndex",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "CurrentWorkflowStepIndex",
table: "Contracts");
migrationBuilder.DropColumn(
name: "RejectedAtStepIndex",
table: "Contracts");
migrationBuilder.AddColumn<Guid>(
name: "InnerStepId",
table: "PurchaseEvaluationDepartmentApprovals",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "InnerStepId",
table: "ContractDepartmentApprovals",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "PurchaseEvaluationWorkflowStepInnerSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationWorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
IsRequired = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Order = table.Column<int>(type: "int", nullable: false),
PositionLevel = table.Column<int>(type: "int", nullable: false),
SlaDays = table.Column<int>(type: "int", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationWorkflowStepInnerSteps", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationWorkflowStepInnerSteps_Departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseEvaluationWorkflowStepInnerSteps_PurchaseEvaluationWorkflowSteps_PurchaseEvaluationWorkflowStepId",
column: x => x.PurchaseEvaluationWorkflowStepId,
principalTable: "PurchaseEvaluationWorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "WorkflowStepInnerSteps",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
WorkflowStepId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
IsRequired = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Order = table.Column<int>(type: "int", nullable: false),
PositionLevel = table.Column<int>(type: "int", nullable: false),
SlaDays = table.Column<int>(type: "int", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkflowStepInnerSteps", x => x.Id);
table.ForeignKey(
name: "FK_WorkflowStepInnerSteps_Departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "Departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_WorkflowStepInnerSteps_WorkflowSteps_WorkflowStepId",
column: x => x.WorkflowStepId,
principalTable: "WorkflowSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationDepartmentApprovals_InnerStepId",
table: "PurchaseEvaluationDepartmentApprovals",
column: "InnerStepId");
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");
migrationBuilder.CreateIndex(
name: "IX_ContractDepartmentApprovals_InnerStepId",
table: "ContractDepartmentApprovals",
column: "InnerStepId");
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_Dept_Stage",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "DepartmentId", "Stage" },
unique: true,
filter: "[InnerStepId] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_ContractDeptApprovals_Contract_Phase_InnerStep",
table: "ContractDepartmentApprovals",
columns: new[] { "ContractId", "PhaseAtApproval", "InnerStepId" },
unique: true,
filter: "[InnerStepId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowStepInnerSteps_DepartmentId",
table: "PurchaseEvaluationWorkflowStepInnerSteps",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationWorkflowStepInnerSteps_PurchaseEvaluationWorkflowStepId_Order",
table: "PurchaseEvaluationWorkflowStepInnerSteps",
columns: new[] { "PurchaseEvaluationWorkflowStepId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_WorkflowStepInnerSteps_DepartmentId",
table: "WorkflowStepInnerSteps",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_WorkflowStepInnerSteps_WorkflowStepId_Order",
table: "WorkflowStepInnerSteps",
columns: new[] { "WorkflowStepId", "Order" });
migrationBuilder.AddForeignKey(
name: "FK_ContractDepartmentApprovals_WorkflowStepInnerSteps_InnerStepId",
table: "ContractDepartmentApprovals",
column: "InnerStepId",
principalTable: "WorkflowStepInnerSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_PurchaseEvaluationDepartmentApprovals_PurchaseEvaluationWorkflowStepInnerSteps_InnerStepId",
table: "PurchaseEvaluationDepartmentApprovals",
column: "InnerStepId",
principalTable: "PurchaseEvaluationWorkflowStepInnerSteps",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

View File

@ -484,6 +484,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentWorkflowStepIndex")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
@ -520,6 +523,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("RejectedAtStepIndex")
.HasColumnType("int");
b.Property<int?>("RejectedFromPhase")
.HasColumnType("int");
@ -824,9 +830,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("InnerStepId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed")
.HasColumnType("bit");
@ -853,17 +856,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("DepartmentId");
b.HasIndex("InnerStepId");
b.HasIndex("ContractId", "PhaseAtApproval", "InnerStepId")
.IsUnique()
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_InnerStep")
.HasFilter("[InnerStepId] IS NOT NULL");
b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage")
.IsUnique()
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage")
.HasFilter("[InnerStepId] IS NULL");
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
b.ToTable("ContractDepartmentApprovals", (string)null);
});
@ -1422,6 +1417,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@ -1433,6 +1431,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<int?>("PositionLevel")
.HasColumnType("int");
b.Property<int?>("SlaDays")
.HasColumnType("int");
@ -1447,6 +1448,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("WorkflowDefinitionId", "Order");
b.ToTable("WorkflowSteps", (string)null);
@ -1488,55 +1491,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("WorkflowStepApprovers", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsRequired")
.HasColumnType("bit");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PositionLevel")
.HasColumnType("int");
b.Property<int?>("SlaDays")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("WorkflowStepId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("WorkflowStepId", "Order");
b.ToTable("WorkflowStepInnerSteps", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
{
b.Property<Guid>("Id")
@ -2416,6 +2370,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentWorkflowStepIndex")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
@ -2452,6 +2409,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("RejectedAtStepIndex")
.HasColumnType("int");
b.Property<int?>("RejectedFromPhase")
.HasColumnType("int");
@ -2719,9 +2679,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("InnerStepId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsBypassed")
.HasColumnType("bit");
@ -2749,19 +2706,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("DepartmentId");
b.HasIndex("InnerStepId");
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")
.IsUnique()
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage")
.HasFilter("[InnerStepId] IS NULL");
.HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage");
b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null);
});
@ -3080,6 +3029,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@ -3091,6 +3043,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<int?>("PositionLevel")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationWorkflowDefinitionId")
.HasColumnType("uniqueidentifier");
@ -3105,6 +3060,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order");
b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null);
@ -3146,55 +3103,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepInnerStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsRequired")
.HasColumnType("bit");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PositionLevel")
.HasColumnType("int");
b.Property<Guid>("PurchaseEvaluationWorkflowStepId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("SlaDays")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("DepartmentId");
b.HasIndex("PurchaseEvaluationWorkflowStepId", "Order");
b.ToTable("PurchaseEvaluationWorkflowStepInnerSteps", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("SolutionErp.Domain.Identity.Role", null)
@ -3342,11 +3250,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", null)
.WithMany()
.HasForeignKey("InnerStepId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Contract");
});
@ -3429,6 +3332,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
{
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition")
.WithMany("Steps")
.HasForeignKey("WorkflowDefinitionId")
@ -3449,23 +3357,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", b =>
{
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step")
.WithMany("InnerSteps")
.HasForeignKey("WorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
@ -3538,11 +3429,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepInnerStep", null)
.WithMany()
.HasForeignKey("InnerStepId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("DepartmentApprovals")
.HasForeignKey("PurchaseEvaluationId")
@ -3610,6 +3496,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
{
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition")
.WithMany("Steps")
.HasForeignKey("PurchaseEvaluationWorkflowDefinitionId")
@ -3630,23 +3521,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepInnerStep", b =>
{
b.HasOne("SolutionErp.Domain.Master.Department", null)
.WithMany()
.HasForeignKey("DepartmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step")
.WithMany("InnerSteps")
.HasForeignKey("PurchaseEvaluationWorkflowStepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Navigation("Approvals");
@ -3693,8 +3567,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
{
b.Navigation("Approvers");
b.Navigation("InnerSteps");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
@ -3736,8 +3608,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
{
b.Navigation("Approvers");
b.Navigation("InnerSteps");
});
#pragma warning restore 612, 618
}

View File

@ -11,9 +11,10 @@ using SolutionErp.Domain.Notifications;
namespace SolutionErp.Infrastructure.Services;
// Thin orchestrator — all phase/role/SLA rules live in WorkflowPolicy (Domain).
// This class is responsible only for *applying* transitions: DB writes, code
// generation at DangDongDau, SLA deadline computation, notification dispatch.
// Contract Workflow Service — Session 16 drastic refactor (Mig 21):
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
// steps OrderBy Order, advance Contract.CurrentWorkflowStepIndex per approve.
// Phase enum simplified: DangSoanThao → ChoDuyet → DaPhatHanh / TuChoi.
public class ContractWorkflowService(
IApplicationDbContext db,
IContractCodeGenerator codeGenerator,
@ -22,11 +23,8 @@ public class ContractWorkflowService(
IChangelogService changelog,
UserManager<User> userManager) : IContractWorkflowService
{
// Expose per-policy SLA via the contract — accepts optional contract so the
// caller (CreateContractCommand) can ask for a specific type's SLA even
// before the contract exists.
public TimeSpan? GetPhaseSla(ContractPhase phase) =>
WorkflowPolicies.Standard.PhaseSla.GetValueOrDefault(phase);
phase == ContractPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;
public async Task TransitionAsync(
Contract contract,
@ -37,378 +35,196 @@ public class ContractWorkflowService(
string? comment,
CancellationToken ct = default)
{
if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích.");
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
// Reject: override target = DangSoanThao + lưu phase gốc → Drafter sửa.
// Resume sau reject: Drafter trình từ DangSoanThao + RejectedFromPhase
// != null → jump straight tới phase đã reject, bypass phase trung gian.
var fromPhase = contract.Phase;
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == ContractPhase.DangSoanThao
&& contract.RejectedFromPhase != null;
if (decision == ApprovalDecision.Reject)
{
contract.RejectedFromPhase = fromPhase;
targetPhase = ContractPhase.DangSoanThao;
// N-stage state reset (Mig 20): clear inner step approval rows tại
// fromPhase. User resume sẽ approve lại từ inner step đầu.
var staleNStageRows = await db.ContractDepartmentApprovals
.Where(a => a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.InnerStepId != null)
.ToListAsync(ct);
foreach (var r in staleNStageRows) db.ContractDepartmentApprovals.Remove(r);
}
else if (isResumingAfterReject)
{
targetPhase = contract.RejectedFromPhase!.Value;
contract.RejectedFromPhase = null;
}
// Resolve the workflow: prefer the pinned WorkflowDefinition (new
// versioned system), else fall back to the static/override registry
// (legacy path for contracts created before versioning rolled out).
WorkflowPolicy policy;
WorkflowDefinition? definition = null;
if (contract.WorkflowDefinitionId is Guid wfId)
{
definition = await db.WorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.Include(d => d.Steps)
.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
policy = definition is not null
? WorkflowPolicyRegistry.FromDefinition(definition)
: WorkflowPolicyRegistry.ForContract(contract);
}
else
{
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
}
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
// Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi
// sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase).
if (!isAdmin && !isSystem && !isResumingAfterReject)
// ===== REJECT BRANCH =====
if (decision == ApprovalDecision.Reject)
{
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
throw new ForbiddenException(
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}. " +
$"Kiểm tra ContractType hoặc BypassProcurementAndCCM.");
// Sử dụng IsTransitionAllowed — check Role + User-kind fallback.
// User-kind chỉ áp dụng khi WorkflowDefinition pinned có
// WorkflowStepApprover Kind=User cho step này.
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
if (targetPhase == ContractPhase.TuChoi)
{
var userExtra = policy.UserTransitions is not null
&& policy.UserTransitions.TryGetValue((fromPhase, targetPhase), out var userIds)
&& userIds.Length > 0
? $" hoặc {userIds.Length} user explicit"
: "";
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}.");
contract.Phase = ContractPhase.TuChoi;
}
else
{
contract.RejectedFromPhase = fromPhase;
contract.RejectedAtStepIndex = contract.CurrentWorkflowStepIndex;
contract.Phase = ContractPhase.DangSoanThao;
contract.CurrentWorkflowStepIndex = null;
}
contract.SlaDeadline = null;
await LogTransitionAsync(contract, fromPhase, contract.Phase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// ===== Department approval (N-stage Mig 20 hoặc Legacy 2-stage Mig 16) =====
// Mirror PE workflow service. Step có InnerSteps → N-stage logic
// (Phòng × PositionLevel sequential). Else fallback legacy 2-stage
// (NV.Review/TPB.Confirm). Skip với reject + resume + admin + system.
var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase);
var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0;
// ===== RESUME AFTER REJECT =====
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == ContractPhase.DangSoanThao
&& contract.RejectedAtStepIndex != null;
if (decision == ApprovalDecision.Approve
&& targetPhase != ContractPhase.DangSoanThao
&& targetPhase != ContractPhase.TuChoi
&& !isResumingAfterReject
&& !isAdmin && !isSystem
&& actorUserId is Guid actorUid)
if (isResumingAfterReject)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
contract.Phase = ContractPhase.ChoDuyet;
contract.CurrentWorkflowStepIndex = contract.RejectedAtStepIndex;
contract.RejectedAtStepIndex = null;
contract.RejectedFromPhase = null;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
if (hasInnerSteps && currentStepDef is not null)
// ===== DRAFTER TRÌNH =====
if (fromPhase == ContractPhase.DangSoanThao
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
{
if (!isAdmin && !isSystem
&& !actorRoles.Contains(AppRoles.Drafter)
&& !actorRoles.Contains(AppRoles.DeptManager))
{
// ===== N-stage logic (Mig 20) — mirror PE Mig 18 =====
if (actor?.DepartmentId is null || actor.PositionLevel is null)
{
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ.");
}
contract.Phase = ContractPhase.ChoDuyet;
contract.CurrentWorkflowStepIndex = 0;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// ===== APPROVE STEP =====
if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
{
var def = contract.WorkflowDefinitionId is Guid wfId
? await db.WorkflowDefinitions.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("HĐ chưa pin workflow definition hoặc workflow không có step.");
var steps = def.Steps.OrderBy(s => s.Order).ToList();
var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
if (currentIdx < 0 || currentIdx >= steps.Count)
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ.");
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(
"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.ContractDepartmentApprovals
.Where(a => a.ContractId == contract.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];
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.");
}
var rowsToCreate = new List<(WorkflowStepInnerStep 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.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
{
ContractId = contract.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = inner.DepartmentId,
Stage = ApprovalStage.Confirm,
ApproverUserId = actorUid,
ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}",
Comment = comment,
ApprovedAt = nowUtc,
IsBypassed = bypassed,
InnerStepId = inner.Id,
});
doneInnerIds.Add(inner.Id);
}
var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id));
if (stillPending)
{
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.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.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.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 phase transition
}
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
}
else if (actor?.DepartmentId is Guid deptId)
db.ContractApprovals.Add(new ContractApproval
{
// ===== Legacy 2-stage logic (Mig 16) — fallback khi step KHÔNG có InnerSteps =====
var isManager = actorRoles.Contains(AppRoles.DeptManager);
var canBypass = actor.CanBypassReview;
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
var isBypassed = !isManager && canBypass;
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = fromPhase,
ApproverUserId = actorUserId,
Decision = decision,
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
var existing = await db.ContractDepartmentApprovals
.FirstOrDefaultAsync(a =>
a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == stage
&& a.InnerStepId == null, ct);
if (existing is null)
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
// All steps done — gen mã HĐ + DaPhatHanh
if (string.IsNullOrEmpty(contract.MaHopDong))
{
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
{
ContractId = contract.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = deptId,
Stage = stage,
ApproverUserId = actorUid,
ApproverRoleSnapshot = roleSnapshot,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
InnerStepId = null,
});
}
else
{
existing.ApproverUserId = actorUid;
existing.ApproverRoleSnapshot = roleSnapshot;
existing.Comment = comment;
existing.ApprovedAt = dateTime.UtcNow;
existing.IsBypassed = isBypassed;
}
var hasConfirm = stage == ApprovalStage.Confirm
|| await db.ContractDepartmentApprovals.AnyAsync(a =>
a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm
&& a.InnerStepId == null, ct);
if (!hasConfirm)
{
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = fromPhase,
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
string? reviewerName = actor.FullName ?? actor.Email;
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = fromPhase,
UserId = actorUid,
UserName = reviewerName ?? "Hệ thống",
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
ContextNote = comment,
});
// Notify TPB cùng dept để confirm. Best effort.
try
{
var managers = await db.Users.AsNoTracking()
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
.Select(u => u.Id)
.ToListAsync(ct);
foreach (var mgrId in managers)
{
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
if (mgr is null) continue;
var roles = await userManager.GetRolesAsync(mgr);
if (!roles.Contains(AppRoles.DeptManager)) continue;
await notifications.NotifyAsync(
mgrId,
NotificationType.ContractPhaseTransition,
title: $"HĐ {contract.MaHopDong ?? contract.TenHopDong ?? ""} chờ TPB confirm",
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
href: $"/contracts/{contract.Id}",
refId: contract.Id,
ct: ct);
}
}
catch { /* notification fail non-critical */ }
await db.SaveChangesAsync(ct);
return;
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
}
contract.Phase = ContractPhase.DaPhatHanh;
contract.CurrentWorkflowStepIndex = null;
contract.SlaDeadline = null;
await LogTransitionAsync(contract, fromPhase, ContractPhase.DaPhatHanh, actorUserId, decision, comment, ct);
}
else
{
contract.CurrentWorkflowStepIndex = nextIdx;
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(contract, fromPhase, fromPhase, actorUserId, decision,
$"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
}
await db.SaveChangesAsync(ct);
return;
}
// Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau.
// Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip.
// Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng
// path khác (vd seed/import) chưa set MaHopDong.
if (targetPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong))
// Admin manual override
if (isAdmin)
{
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
contract.Phase = targetPhase;
contract.SlaDeadline = targetPhase == ContractPhase.ChoDuyet
? dateTime.UtcNow.AddDays(7) : null;
await LogTransitionAsync(contract, fromPhase, targetPhase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
contract.SlaWarningSent = false;
contract.Phase = targetPhase;
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
}
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = actorUserId,
Decision = decision,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
});
// Log workflow transition vào unified Changelog (cho user xem trên tab Lịch sử)
await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, targetPhase, comment);
private async Task LogTransitionAsync(
Contract contract,
ContractPhase fromPhase,
ContractPhase toPhase,
Guid? actorUserId,
ApprovalDecision decision,
string? comment,
CancellationToken ct)
{
await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, toPhase, comment);
if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId)
{
var title = targetPhase switch
var (title, type) = toPhase switch
{
ContractPhase.DaPhatHanh => $"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
ContractPhase.TuChoi => $"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
_ => $"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển sang phase mới",
};
var type = targetPhase switch
{
ContractPhase.DaPhatHanh => NotificationType.ContractPublished,
ContractPhase.TuChoi => NotificationType.ContractRejected,
_ => NotificationType.ContractPhaseTransition,
ContractPhase.DaPhatHanh => ($"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
NotificationType.ContractPublished),
ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
NotificationType.ContractRejected),
ContractPhase.DangSoanThao when fromPhase == ContractPhase.ChoDuyet =>
($"HĐ {contract.TenHopDong ?? "của bạn"} bị trả lại — vui lòng sửa và trình lại",
NotificationType.ContractRejected),
_ => ($"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển phase mới",
NotificationType.ContractPhaseTransition),
};
await notifications.NotifyAsync(
drafterId,
type,
title,
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
drafterId, type, title,
description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
href: $"/contracts/{contract.Id}",
refId: contract.Id,
ct: ct);
}
await db.SaveChangesAsync(ct);
}
}

View File

@ -12,8 +12,11 @@ using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Services;
// Mirror ContractWorkflowService. Load policy từ pinned
// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry.
// PE Workflow Service — Session 16 drastic refactor (Mig 21):
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
// steps OrderBy Order, advance PE.CurrentWorkflowStepIndex per approve.
// Phase enum simplified: DangSoanThao → ChoDuyet (active workflow) → DaDuyet
// (terminal) / TuChoi (khoá). Trả lại = về DangSoanThao + save RejectedAtStepIndex.
public class PurchaseEvaluationWorkflowService(
IApplicationDbContext db,
IDateTime dateTime,
@ -21,7 +24,7 @@ public class PurchaseEvaluationWorkflowService(
UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
{
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase);
phase == PurchaseEvaluationPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;
public async Task TransitionAsync(
PurchaseEvaluation evaluation,
@ -32,345 +35,173 @@ public class PurchaseEvaluationWorkflowService(
string? comment,
CancellationToken ct = default)
{
if (evaluation.Phase == targetPhase)
throw new ConflictException("Phiếu đã ở phase đích.");
// ===== Smart reject + resume (Phase 9 — Migration 16) =====
var fromPhase = evaluation.Phase;
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
&& evaluation.RejectedFromPhase != null;
if (decision == ApprovalDecision.Reject)
{
// 2 loại Reject (Session 14):
// - target=TuChoi: "Từ chối hoàn toàn" — phiếu khoá vĩnh viễn (Phase=TuChoi
// → 17 handler Mig 16 lock edit). Drafter phải tạo phiếu mới. KHÔNG set
// RejectedFromPhase + KHÔNG clear N-stage (không resume).
// - target khác (thường = DangSoanThao): "Trả lại" — smart reject pattern
// Mig 16. Set RejectedFromPhase + force DangSoanThao + clear N-stage rows
// tại fromPhase → Drafter sửa rồi trình lại jump-back tới phase đã reject.
if (targetPhase != PurchaseEvaluationPhase.TuChoi)
{
evaluation.RejectedFromPhase = fromPhase;
targetPhase = PurchaseEvaluationPhase.DangSoanThao;
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)
{
targetPhase = evaluation.RejectedFromPhase!.Value;
evaluation.RejectedFromPhase = null;
}
PurchaseEvaluationPolicy policy;
PurchaseEvaluationWorkflowDefinition? definition = null;
if (evaluation.WorkflowDefinitionId is Guid wfId)
{
definition = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.Include(d => d.Steps)
.ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order))
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
policy = definition is not null
? PurchaseEvaluationPolicyRegistry.FromDefinition(definition)
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
}
else
{
policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
}
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
// Policy guard — bypass khi resume sau reject (target đã pinned).
if (!isAdmin && !isSystem && !isResumingAfterReject)
// ===== REJECT BRANCH =====
if (decision == ApprovalDecision.Reject)
{
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
throw new ForbiddenException(
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}.");
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
{
throw new ForbiddenException(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
}
else
{
// Trả lại — về DangSoanThao + save RejectedAtStepIndex (resume jump-back).
evaluation.RejectedFromPhase = fromPhase;
evaluation.RejectedAtStepIndex = evaluation.CurrentWorkflowStepIndex;
evaluation.Phase = PurchaseEvaluationPhase.DangSoanThao;
evaluation.CurrentWorkflowStepIndex = null;
}
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// ===== Department approval (N-stage Mig 18 hoặc Legacy 2-stage Mig 16) =====
// Active block khi: Approve + chuyển sang phase trung gian (không phải
// DangSoanThao/TuChoi) + KHÔNG admin/system + KHÔNG resume sau reject.
var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase);
var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0;
// ===== RESUME AFTER REJECT (Drafter trình lại) =====
var isResumingAfterReject = decision == ApprovalDecision.Approve
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
&& evaluation.RejectedAtStepIndex != null;
if (decision == ApprovalDecision.Approve
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
&& targetPhase != PurchaseEvaluationPhase.TuChoi
&& !isResumingAfterReject
&& !isAdmin && !isSystem
&& actorUserId is Guid actorUid)
if (isResumingAfterReject)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = evaluation.RejectedAtStepIndex;
evaluation.RejectedAtStepIndex = null;
evaluation.RejectedFromPhase = null;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
if (hasInnerSteps && currentStepDef is not null)
// ===== DRAFTER TRÌNH (DangSoanThao → ChoDuyet) =====
if (fromPhase == PurchaseEvaluationPhase.DangSoanThao
&& (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem))
{
// Drafter/DeptManager only (or Admin bypass).
if (!isAdmin && !isSystem
&& !actorRoles.Contains(AppRoles.Drafter)
&& !actorRoles.Contains(AppRoles.DeptManager))
{
// ===== 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(
$"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu.");
}
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
evaluation.CurrentWorkflowStepIndex = 0;
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// ===== 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)
{
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(
"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
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
}
else if (actor?.DepartmentId is Guid deptId)
// Log approval row
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
// ===== Legacy 2-stage logic (Mig 16) — fallback khi step KHÔNG có InnerSteps =====
var isManager = actorRoles.Contains(AppRoles.DeptManager);
var canBypass = actor.CanBypassReview;
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
var isBypassed = !isManager && canBypass;
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = fromPhase, // step advance — phase same
ApproverUserId = actorUserId,
Decision = decision,
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
// Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE filtered InnerStepId IS NULL.
var existing = await db.PurchaseEvaluationDepartmentApprovals
.FirstOrDefaultAsync(a =>
a.PurchaseEvaluationId == evaluation.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == stage
&& a.InnerStepId == null, ct);
if (existing is null)
{
db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval
{
PurchaseEvaluationId = evaluation.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = deptId,
Stage = stage,
ApproverUserId = actorUid,
ApproverRoleSnapshot = roleSnapshot,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
InnerStepId = null,
});
}
else
{
existing.ApproverUserId = actorUid;
existing.ApproverRoleSnapshot = roleSnapshot;
existing.Comment = comment;
existing.ApprovedAt = dateTime.UtcNow;
existing.IsBypassed = isBypassed;
}
// Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId)
var hasConfirm = stage == ApprovalStage.Confirm
|| await db.PurchaseEvaluationDepartmentApprovals.AnyAsync(a =>
a.PurchaseEvaluationId == evaluation.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm
&& a.InnerStepId == null, ct);
if (!hasConfirm)
{
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = fromPhase,
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
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} (NV) đã review phase {fromPhase}, chờ TPB confirm",
ContextNote = comment,
});
// Notify TPB cùng dept để confirm. Best effort — fail OK.
try
{
var managers = await db.Users.AsNoTracking()
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
.Select(u => u.Id)
.ToListAsync(ct);
if (managers.Count > 0)
{
foreach (var mgrId in managers)
{
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
if (mgr is null) continue;
var roles = await userManager.GetRolesAsync(mgr);
if (!roles.Contains(AppRoles.DeptManager)) continue;
await notifications.NotifyAsync(
mgrId,
NotificationType.ContractPhaseTransition,
title: $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} chờ TPB confirm",
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
href: $"/purchase-evaluations/{evaluation.Id}",
refId: evaluation.Id,
ct: ct);
}
}
}
catch { /* notification fail non-critical */ }
await db.SaveChangesAsync(ct);
return;
}
// 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
{
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 db.SaveChangesAsync(ct);
return;
}
evaluation.SlaWarningSent = false;
evaluation.Phase = targetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
// Admin manual override (vd test cứng phase)
if (isAdmin)
{
PurchaseEvaluationId = evaluation.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = actorUserId,
Decision = decision,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
});
evaluation.Phase = targetPhase;
evaluation.SlaDeadline = targetPhase == PurchaseEvaluationPhase.ChoDuyet
? dateTime.UtcNow.AddDays(7) : null;
await LogTransitionAsync(evaluation, fromPhase, targetPhase, actorUserId, decision, comment, ct);
await db.SaveChangesAsync(ct);
return;
}
// Resolve actor name for changelog
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
}
private async Task LogTransitionAsync(
PurchaseEvaluation evaluation,
PurchaseEvaluationPhase fromPhase,
PurchaseEvaluationPhase toPhase,
Guid? actorUserId,
ApprovalDecision decision,
string? comment,
CancellationToken ct)
{
// Save Approval history (đã làm trong main flow — chỉ log Changelog ở đây)
string? actorName = null;
if (actorUserId is Guid uid)
{
@ -383,36 +214,34 @@ public class PurchaseEvaluationWorkflowService(
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = targetPhase,
PhaseAtChange = toPhase,
UserId = actorUserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
Summary = $"Chuyển phase {fromPhase} → {toPhase}",
ContextNote = comment,
});
// Notify drafter
// Notify drafter on terminal states
if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId)
{
var title = targetPhase switch
var (title, type) = toPhase switch
{
PurchaseEvaluationPhase.DaDuyet => $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối",
_ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
};
var type = targetPhase switch
{
PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished,
PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected,
_ => NotificationType.ContractPhaseTransition,
PurchaseEvaluationPhase.DaDuyet => ($"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
NotificationType.ContractPublished),
PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối",
NotificationType.ContractRejected),
PurchaseEvaluationPhase.DangSoanThao when fromPhase == PurchaseEvaluationPhase.ChoDuyet =>
($"Phiếu {evaluation.TenGoiThau} bị trả lại — vui lòng sửa và trình lại",
NotificationType.ContractRejected),
_ => ($"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
NotificationType.ContractPhaseTransition),
};
await notifications.NotifyAsync(
drafterId, type, title,
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
href: $"/purchase-evaluations/{evaluation.Id}",
refId: evaluation.Id,
ct: ct);
}
await db.SaveChangesAsync(ct);
}
}