[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
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:
@ -17,26 +17,19 @@ public record WorkflowStepApproverDto(
|
|||||||
string AssignmentValue,
|
string AssignmentValue,
|
||||||
string? DisplayName); // resolved role label or user fullName
|
string? DisplayName); // resolved role label or user fullName
|
||||||
|
|
||||||
// Mig 20 — N-stage approval inner step level con (mirror PE Mig 18)
|
// Mig 21 — flat workflow step. Mỗi step = 1 (Phòng × Cấp + Approvers users)
|
||||||
public record WorkflowStepInnerStepDto(
|
// sequential. Service iterate theo Order, advance Contract.CurrentWorkflowStepIndex.
|
||||||
Guid Id,
|
|
||||||
int Order,
|
|
||||||
Guid DepartmentId,
|
|
||||||
string? DepartmentName,
|
|
||||||
int PositionLevel, // 1=NV, 2=PP, 3=TP
|
|
||||||
string? Name,
|
|
||||||
int? SlaDays,
|
|
||||||
bool IsRequired);
|
|
||||||
|
|
||||||
public record WorkflowStepDto(
|
public record WorkflowStepDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
int Order,
|
int Order,
|
||||||
int Phase,
|
int Phase, // [DEPRECATED post-Mig 21] dùng ChoDuyet=10 cho new
|
||||||
string PhaseLabel,
|
string PhaseLabel,
|
||||||
string Name,
|
string Name, // "Phòng A — Cấp 1"
|
||||||
int? SlaDays,
|
int? SlaDays,
|
||||||
List<WorkflowStepApproverDto> Approvers,
|
Guid? DepartmentId, // Mig 21
|
||||||
List<WorkflowStepInnerStepDto> InnerSteps);
|
string? DepartmentName, // resolved display
|
||||||
|
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
|
||||||
|
List<WorkflowStepApproverDto> Approvers);
|
||||||
|
|
||||||
public record WorkflowDefinitionDto(
|
public record WorkflowDefinitionDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@ -96,15 +89,15 @@ public class GetWorkflowAdminOverviewQueryHandler(
|
|||||||
var definitions = await db.WorkflowDefinitions.AsNoTracking()
|
var definitions = await db.WorkflowDefinitions.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))
|
|
||||||
.OrderByDescending(d => d.Version)
|
.OrderByDescending(d => d.Version)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// Resolve dept names cho InnerStep.DepartmentName display (Mig 20)
|
// Resolve dept names cho step.DepartmentId display (Mig 21)
|
||||||
var deptIds = definitions
|
var deptIds = definitions
|
||||||
.SelectMany(d => d.Steps).SelectMany(s => s.InnerSteps)
|
.SelectMany(d => d.Steps)
|
||||||
.Select(i => i.DepartmentId).Distinct().ToList();
|
.Where(s => s.DepartmentId != null)
|
||||||
|
.Select(s => s.DepartmentId!.Value)
|
||||||
|
.Distinct().ToList();
|
||||||
var deptNames = deptIds.Count == 0
|
var deptNames = deptIds.Count == 0
|
||||||
? new Dictionary<Guid, string>()
|
? new Dictionary<Guid, string>()
|
||||||
: await db.Departments.AsNoTracking()
|
: await db.Departments.AsNoTracking()
|
||||||
@ -151,19 +144,13 @@ public class GetWorkflowAdminOverviewQueryHandler(
|
|||||||
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
|
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
|
||||||
s.Name,
|
s.Name,
|
||||||
s.SlaDays,
|
s.SlaDays,
|
||||||
|
s.DepartmentId,
|
||||||
|
s.DepartmentId != null ? deptNames.GetValueOrDefault(s.DepartmentId.Value) : null,
|
||||||
|
s.PositionLevel != null ? (int?)s.PositionLevel : null,
|
||||||
s.Approvers.Select(a => new WorkflowStepApproverDto(
|
s.Approvers.Select(a => new WorkflowStepApproverDto(
|
||||||
(int)a.Kind,
|
(int)a.Kind,
|
||||||
a.AssignmentValue,
|
a.AssignmentValue,
|
||||||
ResolveDisplay(a, userNames))).ToList(),
|
ResolveDisplay(a, userNames))).ToList()
|
||||||
s.InnerSteps.OrderBy(i => i.Order).Select(i => new WorkflowStepInnerStepDto(
|
|
||||||
i.Id,
|
|
||||||
i.Order,
|
|
||||||
i.DepartmentId,
|
|
||||||
deptNames.GetValueOrDefault(i.DepartmentId),
|
|
||||||
(int)i.PositionLevel,
|
|
||||||
i.Name,
|
|
||||||
i.SlaDays,
|
|
||||||
i.IsRequired)).ToList()
|
|
||||||
)).ToList());
|
)).ToList());
|
||||||
|
|
||||||
var types = Enum.GetValues<ContractType>()
|
var types = Enum.GetValues<ContractType>()
|
||||||
@ -193,23 +180,16 @@ public class GetWorkflowAdminOverviewQueryHandler(
|
|||||||
|
|
||||||
public record CreateWorkflowStepApproverInput(int Kind, string AssignmentValue);
|
public record CreateWorkflowStepApproverInput(int Kind, string AssignmentValue);
|
||||||
|
|
||||||
// Mig 20 — Inner step input cho designer N-stage. InnerSteps optional empty
|
// Mig 21 — flat workflow step input. DeptId + PositionLevel = Phòng × Cấp.
|
||||||
// list → service fallback 2-stage Review/Confirm logic legacy Mig 16.
|
// Phase auto-assign ChoDuyet=10 cho new definitions (legacy phase-specific deprecated).
|
||||||
public record CreateWorkflowStepInnerStepInput(
|
|
||||||
int Order,
|
|
||||||
Guid DepartmentId,
|
|
||||||
int PositionLevel, // 1=NV, 2=PP, 3=TP
|
|
||||||
string? Name,
|
|
||||||
int? SlaDays,
|
|
||||||
bool IsRequired);
|
|
||||||
|
|
||||||
public record CreateWorkflowStepInput(
|
public record CreateWorkflowStepInput(
|
||||||
int Order,
|
int Order,
|
||||||
int Phase,
|
int Phase, // [DEPRECATED] caller có thể truyền 10=ChoDuyet hoặc legacy enum value
|
||||||
string Name,
|
string Name,
|
||||||
int? SlaDays,
|
int? SlaDays,
|
||||||
List<CreateWorkflowStepApproverInput> Approvers,
|
Guid? DepartmentId, // Mig 21
|
||||||
List<CreateWorkflowStepInnerStepInput>? InnerSteps = null);
|
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
|
||||||
|
List<CreateWorkflowStepApproverInput> Approvers);
|
||||||
|
|
||||||
public record CreateWorkflowDefinitionCommand(
|
public record CreateWorkflowDefinitionCommand(
|
||||||
ContractType ContractType,
|
ContractType ContractType,
|
||||||
@ -233,25 +213,18 @@ public class CreateWorkflowDefinitionCommandValidator : AbstractValidator<Create
|
|||||||
RuleForEach(x => x.Steps).ChildRules(step =>
|
RuleForEach(x => x.Steps).ChildRules(step =>
|
||||||
{
|
{
|
||||||
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
|
||||||
step.RuleFor(s => s.Phase).InclusiveBetween(1, 9);
|
step.RuleFor(s => s.Phase).InclusiveBetween(1, 99); // Mig 21 accept ChoDuyet=10
|
||||||
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
|
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
|
||||||
step.RuleFor(s => s.SlaDays).GreaterThanOrEqualTo(0)
|
step.RuleFor(s => s.SlaDays).GreaterThanOrEqualTo(0)
|
||||||
.When(s => s.SlaDays != null);
|
.When(s => s.SlaDays != null);
|
||||||
|
step.RuleFor(s => s.PositionLevel).InclusiveBetween(1, 3)
|
||||||
|
.When(s => s.PositionLevel != null)
|
||||||
|
.WithMessage("PositionLevel: 1=NV, 2=PP, 3=TP.");
|
||||||
step.RuleForEach(s => s.Approvers).ChildRules(app =>
|
step.RuleForEach(s => s.Approvers).ChildRules(app =>
|
||||||
{
|
{
|
||||||
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
|
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
|
||||||
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
|
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
|
||||||
});
|
});
|
||||||
step.RuleForEach(s => s.InnerSteps!).ChildRules(inner =>
|
|
||||||
{
|
|
||||||
inner.RuleFor(i => i.Order).GreaterThanOrEqualTo(1);
|
|
||||||
inner.RuleFor(i => i.DepartmentId).NotEmpty();
|
|
||||||
inner.RuleFor(i => i.PositionLevel).InclusiveBetween(1, 3)
|
|
||||||
.WithMessage("PositionLevel: 1=NV, 2=PP, 3=TP.");
|
|
||||||
inner.RuleFor(i => i.Name).MaximumLength(200);
|
|
||||||
inner.RuleFor(i => i.SlaDays).GreaterThanOrEqualTo(0)
|
|
||||||
.When(i => i.SlaDays != null);
|
|
||||||
}).When(s => s.InnerSteps != null);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -291,20 +264,13 @@ public class CreateWorkflowDefinitionCommandHandler(IApplicationDbContext db)
|
|||||||
Phase = (ContractPhase)s.Phase,
|
Phase = (ContractPhase)s.Phase,
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
SlaDays = s.SlaDays,
|
SlaDays = s.SlaDays,
|
||||||
|
DepartmentId = s.DepartmentId,
|
||||||
|
PositionLevel = s.PositionLevel != null ? (PositionLevel)s.PositionLevel : null,
|
||||||
Approvers = s.Approvers.Select(a => new WorkflowStepApprover
|
Approvers = s.Approvers.Select(a => new WorkflowStepApprover
|
||||||
{
|
{
|
||||||
Kind = (WorkflowApproverKind)a.Kind,
|
Kind = (WorkflowApproverKind)a.Kind,
|
||||||
AssignmentValue = a.AssignmentValue,
|
AssignmentValue = a.AssignmentValue,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
InnerSteps = (s.InnerSteps ?? new()).OrderBy(i => i.Order).Select(i => new WorkflowStepInnerStep
|
|
||||||
{
|
|
||||||
Order = i.Order,
|
|
||||||
DepartmentId = i.DepartmentId,
|
|
||||||
PositionLevel = (PositionLevel)i.PositionLevel,
|
|
||||||
Name = i.Name,
|
|
||||||
SlaDays = i.SlaDays,
|
|
||||||
IsRequired = i.IsRequired,
|
|
||||||
}).ToList(),
|
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,27 +18,18 @@ public record PeWorkflowStepApproverDto(
|
|||||||
string AssignmentValue,
|
string AssignmentValue,
|
||||||
string? DisplayName);
|
string? DisplayName);
|
||||||
|
|
||||||
// Mig 18 — N-stage approval inner step level con. Cấu hình động trong cùng
|
// Mig 21 — flat workflow step. Mỗi step = 1 (Phòng × Cấp + Approvers users).
|
||||||
// 1 phase: NV.A → PP.A → TP.A → NV.B → ... theo Order asc.
|
|
||||||
public record PeWorkflowStepInnerStepDto(
|
|
||||||
Guid Id,
|
|
||||||
int Order,
|
|
||||||
Guid DepartmentId,
|
|
||||||
string? DepartmentName,
|
|
||||||
int PositionLevel, // 1=NV, 2=PP, 3=TP
|
|
||||||
string? Name,
|
|
||||||
int? SlaDays,
|
|
||||||
bool IsRequired);
|
|
||||||
|
|
||||||
public record PeWorkflowStepDto(
|
public record PeWorkflowStepDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
int Order,
|
int Order,
|
||||||
int Phase,
|
int Phase, // [DEPRECATED post-Mig 21] dùng ChoDuyet=10
|
||||||
string PhaseLabel,
|
string PhaseLabel,
|
||||||
string Name,
|
string Name, // "Phòng A — Cấp 1"
|
||||||
int? SlaDays,
|
int? SlaDays,
|
||||||
List<PeWorkflowStepApproverDto> Approvers,
|
Guid? DepartmentId, // Mig 21
|
||||||
List<PeWorkflowStepInnerStepDto> InnerSteps);
|
string? DepartmentName,
|
||||||
|
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
|
||||||
|
List<PeWorkflowStepApproverDto> Approvers);
|
||||||
|
|
||||||
public record PeWorkflowDefinitionDto(
|
public record PeWorkflowDefinitionDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@ -92,15 +83,15 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
|
|||||||
var definitions = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
var definitions = 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))
|
|
||||||
.OrderByDescending(d => d.Version)
|
.OrderByDescending(d => d.Version)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// Resolve dept names cho InnerStep.DepartmentName display
|
// Resolve dept names cho step.DepartmentId display (Mig 21 flat)
|
||||||
var deptIds = definitions
|
var deptIds = definitions
|
||||||
.SelectMany(d => d.Steps).SelectMany(s => s.InnerSteps)
|
.SelectMany(d => d.Steps)
|
||||||
.Select(i => i.DepartmentId).Distinct().ToList();
|
.Where(s => s.DepartmentId != null)
|
||||||
|
.Select(s => s.DepartmentId!.Value)
|
||||||
|
.Distinct().ToList();
|
||||||
var deptNames = deptIds.Count == 0
|
var deptNames = deptIds.Count == 0
|
||||||
? new Dictionary<Guid, string>()
|
? new Dictionary<Guid, string>()
|
||||||
: await db.Departments.AsNoTracking()
|
: await db.Departments.AsNoTracking()
|
||||||
@ -147,19 +138,13 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
|
|||||||
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
|
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
|
||||||
s.Name,
|
s.Name,
|
||||||
s.SlaDays,
|
s.SlaDays,
|
||||||
|
s.DepartmentId,
|
||||||
|
s.DepartmentId != null ? deptNames.GetValueOrDefault(s.DepartmentId.Value) : null,
|
||||||
|
s.PositionLevel != null ? (int?)s.PositionLevel : null,
|
||||||
s.Approvers.Select(a => new PeWorkflowStepApproverDto(
|
s.Approvers.Select(a => new PeWorkflowStepApproverDto(
|
||||||
(int)a.Kind,
|
(int)a.Kind,
|
||||||
a.AssignmentValue,
|
a.AssignmentValue,
|
||||||
ResolveDisplay(a, userNames))).ToList(),
|
ResolveDisplay(a, userNames))).ToList()
|
||||||
s.InnerSteps.OrderBy(i => i.Order).Select(i => new PeWorkflowStepInnerStepDto(
|
|
||||||
i.Id,
|
|
||||||
i.Order,
|
|
||||||
i.DepartmentId,
|
|
||||||
deptNames.GetValueOrDefault(i.DepartmentId),
|
|
||||||
(int)i.PositionLevel,
|
|
||||||
i.Name,
|
|
||||||
i.SlaDays,
|
|
||||||
i.IsRequired)).ToList()
|
|
||||||
)).ToList());
|
)).ToList());
|
||||||
|
|
||||||
var types = Enum.GetValues<PurchaseEvaluationType>()
|
var types = Enum.GetValues<PurchaseEvaluationType>()
|
||||||
@ -189,23 +174,15 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
|
|||||||
|
|
||||||
public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue);
|
public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue);
|
||||||
|
|
||||||
// Mig 18 — Inner step input cho designer N-stage. InnerSteps optional empty
|
// Mig 21 — flat workflow step input. DeptId + PositionLevel = Phòng × Cấp.
|
||||||
// list → service fallback 2-stage Review/Confirm logic legacy Mig 16.
|
|
||||||
public record CreatePeWorkflowStepInnerStepInput(
|
|
||||||
int Order,
|
|
||||||
Guid DepartmentId,
|
|
||||||
int PositionLevel, // 1=NV, 2=PP, 3=TP
|
|
||||||
string? Name,
|
|
||||||
int? SlaDays,
|
|
||||||
bool IsRequired);
|
|
||||||
|
|
||||||
public record CreatePeWorkflowStepInput(
|
public record CreatePeWorkflowStepInput(
|
||||||
int Order,
|
int Order,
|
||||||
int Phase,
|
int Phase, // [DEPRECATED] caller có thể truyền 10=ChoDuyet hoặc legacy
|
||||||
string Name,
|
string Name,
|
||||||
int? SlaDays,
|
int? SlaDays,
|
||||||
List<CreatePeWorkflowStepApproverInput> Approvers,
|
Guid? DepartmentId, // Mig 21
|
||||||
List<CreatePeWorkflowStepInnerStepInput>? InnerSteps = null);
|
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
|
||||||
|
List<CreatePeWorkflowStepApproverInput> Approvers);
|
||||||
|
|
||||||
public record CreatePeWorkflowDefinitionCommand(
|
public record CreatePeWorkflowDefinitionCommand(
|
||||||
PurchaseEvaluationType EvaluationType,
|
PurchaseEvaluationType EvaluationType,
|
||||||
@ -240,16 +217,9 @@ public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator<Crea
|
|||||||
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
|
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
|
||||||
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
|
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
|
||||||
});
|
});
|
||||||
step.RuleForEach(s => s.InnerSteps!).ChildRules(inner =>
|
step.RuleFor(s => s.PositionLevel).InclusiveBetween(1, 3)
|
||||||
{
|
.When(s => s.PositionLevel != null)
|
||||||
inner.RuleFor(i => i.Order).GreaterThanOrEqualTo(1);
|
|
||||||
inner.RuleFor(i => i.DepartmentId).NotEmpty();
|
|
||||||
inner.RuleFor(i => i.PositionLevel).InclusiveBetween(1, 3)
|
|
||||||
.WithMessage("PositionLevel: 1=NV, 2=PP, 3=TP.");
|
.WithMessage("PositionLevel: 1=NV, 2=PP, 3=TP.");
|
||||||
inner.RuleFor(i => i.Name).MaximumLength(200);
|
|
||||||
inner.RuleFor(i => i.SlaDays).GreaterThanOrEqualTo(0)
|
|
||||||
.When(i => i.SlaDays != null);
|
|
||||||
}).When(s => s.InnerSteps != null);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -287,20 +257,13 @@ public class CreatePeWorkflowDefinitionCommandHandler(IApplicationDbContext db)
|
|||||||
Phase = (PurchaseEvaluationPhase)s.Phase,
|
Phase = (PurchaseEvaluationPhase)s.Phase,
|
||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
SlaDays = s.SlaDays,
|
SlaDays = s.SlaDays,
|
||||||
|
DepartmentId = s.DepartmentId,
|
||||||
|
PositionLevel = s.PositionLevel != null ? (PositionLevel)s.PositionLevel : null,
|
||||||
Approvers = s.Approvers.Select(a => new PurchaseEvaluationWorkflowStepApprover
|
Approvers = s.Approvers.Select(a => new PurchaseEvaluationWorkflowStepApprover
|
||||||
{
|
{
|
||||||
Kind = (WorkflowApproverKind)a.Kind,
|
Kind = (WorkflowApproverKind)a.Kind,
|
||||||
AssignmentValue = a.AssignmentValue,
|
AssignmentValue = a.AssignmentValue,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
InnerSteps = (s.InnerSteps ?? new()).OrderBy(i => i.Order).Select(i => new PurchaseEvaluationWorkflowStepInnerStep
|
|
||||||
{
|
|
||||||
Order = i.Order,
|
|
||||||
DepartmentId = i.DepartmentId,
|
|
||||||
PositionLevel = (PositionLevel)i.PositionLevel,
|
|
||||||
Name = i.Name,
|
|
||||||
SlaDays = i.SlaDays,
|
|
||||||
IsRequired = i.IsRequired,
|
|
||||||
}).ToList(),
|
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -30,11 +30,15 @@ public class Contract : AuditableEntity
|
|||||||
public string? BudgetManualName { get; set; } // Tên tham chiếu
|
public string? BudgetManualName { get; set; } // Tên tham chiếu
|
||||||
public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ)
|
public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ)
|
||||||
|
|
||||||
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
|
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject.
|
||||||
// sửa lại + trình lại → quay về RejectedFromPhase thay vì DangSoanThao
|
|
||||||
// tuần tự lại từ đầu. Null khi chưa từng reject hoặc đã trình lại xong.
|
|
||||||
public ContractPhase? RejectedFromPhase { get; set; }
|
public ContractPhase? RejectedFromPhase { get; set; }
|
||||||
|
|
||||||
|
// Flat workflow tracking (Session 16 — Migration 21):
|
||||||
|
// - CurrentWorkflowStepIndex: 0-based pointer step đang chờ approver
|
||||||
|
// - RejectedAtStepIndex: snapshot khi Trả lại, restore khi resume
|
||||||
|
public int? CurrentWorkflowStepIndex { get; set; }
|
||||||
|
public int? RejectedAtStepIndex { get; set; }
|
||||||
|
|
||||||
public List<ContractApproval> Approvals { get; set; } = new();
|
public List<ContractApproval> Approvals { get; set; } = new();
|
||||||
public List<ContractComment> Comments { get; set; } = new();
|
public List<ContractComment> Comments { get; set; } = new();
|
||||||
public List<ContractAttachment> Attachments { get; set; } = new();
|
public List<ContractAttachment> Attachments { get; set; } = new();
|
||||||
|
|||||||
@ -23,9 +23,5 @@ public class ContractDepartmentApproval : AuditableEntity
|
|||||||
public DateTime ApprovedAt { get; set; }
|
public DateTime ApprovedAt { get; set; }
|
||||||
public bool IsBypassed { get; set; } // true nếu NV bypass (User.CanBypassReview=true)
|
public bool IsBypassed { get; set; } // true nếu NV bypass (User.CanBypassReview=true)
|
||||||
|
|
||||||
// N-stage inner step link (Mig 20) — null cho data legacy 2-stage Review/Confirm.
|
|
||||||
// Có giá trị khi step cha có InnerSteps configured. Mirror PE Mig 18 pattern.
|
|
||||||
public Guid? InnerStepId { get; set; }
|
|
||||||
|
|
||||||
public Contract? Contract { get; set; }
|
public Contract? Contract { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,25 @@
|
|||||||
namespace SolutionErp.Domain.Contracts;
|
namespace SolutionErp.Domain.Contracts;
|
||||||
|
|
||||||
// 9 phase state machine — xem docs/workflow-contract.md
|
// State machine HĐ — Session 16 drastic refactor (Mig 21):
|
||||||
|
// DangSoanThao → ChoDuyet (Drafter trình, init CurrentWorkflowStepIndex=0)
|
||||||
|
// ChoDuyet → ChoDuyet (advance step pointer per approve)
|
||||||
|
// ChoDuyet → DaPhatHanh (last step done — terminal)
|
||||||
|
// ChoDuyet → DangSoanThao (Trả lại — save RejectedAtStepIndex, Drafter sửa)
|
||||||
|
// ChoDuyet → TuChoi (Từ chối — terminal khoá)
|
||||||
|
//
|
||||||
|
// LEGACY values (DangChon, DangGopY, DangDamPhan, DangInKy, DangKiemTraCCM,
|
||||||
|
// DangTrinhKy, DangDongDau) deprecated post-Mig 21 — giữ enum cho data cũ.
|
||||||
public enum ContractPhase
|
public enum ContractPhase
|
||||||
{
|
{
|
||||||
DangChon = 1,
|
DangChon = 1, // [LEGACY]
|
||||||
DangSoanThao = 2,
|
DangSoanThao = 2,
|
||||||
DangGopY = 3,
|
DangGopY = 3, // [LEGACY]
|
||||||
DangDamPhan = 4,
|
DangDamPhan = 4, // [LEGACY]
|
||||||
DangInKy = 5,
|
DangInKy = 5, // [LEGACY]
|
||||||
DangKiemTraCCM = 6,
|
DangKiemTraCCM = 6, // [LEGACY]
|
||||||
DangTrinhKy = 7,
|
DangTrinhKy = 7, // [LEGACY]
|
||||||
DangDongDau = 8,
|
DangDongDau = 8, // [LEGACY]
|
||||||
DaPhatHanh = 9,
|
DaPhatHanh = 9, // terminal thành công (= DaDuyet cho HĐ)
|
||||||
TuChoi = 99,
|
ChoDuyet = 10, // [Mig 21] generic intermediate, dùng CurrentWorkflowStepIndex tracking
|
||||||
|
TuChoi = 99, // terminal khoá
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,21 +23,28 @@ public class WorkflowDefinition : BaseEntity
|
|||||||
public List<WorkflowStep> Steps { get; set; } = new();
|
public List<WorkflowStep> Steps { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workflow Step (Session 16 — Mig 21 drastic refactor):
|
||||||
|
// Mỗi step = 1 (Phòng × Cấp + Approvers users). Sequential per Order.
|
||||||
|
// Service iterate steps OrderBy(Order), advance Contract.CurrentWorkflowStepIndex
|
||||||
|
// per approve. Phase column deprecated post-Mig 21 (set ChoDuyet=10 cho new
|
||||||
|
// definitions, giữ giá trị cũ cho backward compat data).
|
||||||
public class WorkflowStep : BaseEntity
|
public class WorkflowStep : BaseEntity
|
||||||
{
|
{
|
||||||
public Guid WorkflowDefinitionId { get; set; }
|
public Guid WorkflowDefinitionId { get; set; }
|
||||||
public int Order { get; set; } // 1-based sequence
|
public int Order { get; set; } // 1-based sequence
|
||||||
public ContractPhase Phase { get; set; } // which ContractPhase this step represents
|
public ContractPhase Phase { get; set; } // [DEPRECATED post-Mig 21] dùng ChoDuyet=10 cho new
|
||||||
public string Name { get; set; } = string.Empty; // display, can differ from Phase label
|
public string Name { get; set; } = string.Empty; // display "Phòng A — Cấp 1"
|
||||||
public int? SlaDays { get; set; } // null = no SLA for this step
|
public int? SlaDays { get; set; } // null = no SLA
|
||||||
|
|
||||||
|
// Mig 21 — Phòng × Cấp (flat workflow). Approver match: actor.DepartmentId
|
||||||
|
// == step.DepartmentId AND actor.PositionLevel == step.PositionLevel
|
||||||
|
// (OR-of-many cùng cấp+phòng). Bypass: actor.PositionLevel cao hơn cùng dept
|
||||||
|
// + CanBypassReview → skip cấp dưới.
|
||||||
|
public Guid? DepartmentId { get; set; }
|
||||||
|
public PositionLevel? PositionLevel { get; set; }
|
||||||
|
|
||||||
public WorkflowDefinition? WorkflowDefinition { get; set; }
|
public WorkflowDefinition? WorkflowDefinition { get; set; }
|
||||||
public List<WorkflowStepApprover> Approvers { get; set; } = new();
|
public List<WorkflowStepApprover> Approvers { get; set; } = new();
|
||||||
|
|
||||||
// Inner steps (Mig 20) — N-stage approval Phòng × PositionLevel sequential.
|
|
||||||
// Mirror PE pattern (Mig 18). Empty list → service fallback logic 2-stage
|
|
||||||
// Review/Confirm legacy (Mig 16) per dept.
|
|
||||||
public List<WorkflowStepInnerStep> InnerSteps { get; set; } = new();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WorkflowApproverKind
|
public enum WorkflowApproverKind
|
||||||
@ -54,23 +61,3 @@ public class WorkflowStepApprover : BaseEntity
|
|||||||
|
|
||||||
public WorkflowStep? Step { get; set; }
|
public WorkflowStep? Step { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner step (Mig 20 — Phase 9+) — sub-step level con cấu hình bên trong 1
|
|
||||||
// WorkflowStep cha (= 1 phase). Mirror PurchaseEvaluationWorkflowStepInnerStep
|
|
||||||
// pattern (Mig 18). Cho phép admin định nghĩa thứ tự duyệt N-stage theo
|
|
||||||
// Department × PositionLevel: NV.A → PP.A → TP.A → NV.B → PP.B → TP.B → ...
|
|
||||||
//
|
|
||||||
// User khớp DepartmentId + PositionLevel + Order tiếp theo chưa duyệt = approver
|
|
||||||
// hợp lệ. CanBypassReview ở User cho TP skip NV+PP cùng dept (audit IsBypassed).
|
|
||||||
public class WorkflowStepInnerStep : BaseEntity
|
|
||||||
{
|
|
||||||
public Guid WorkflowStepId { get; set; }
|
|
||||||
public int Order { get; set; }
|
|
||||||
public Guid DepartmentId { get; set; }
|
|
||||||
public PositionLevel PositionLevel { get; set; } // NV / PP / TP
|
|
||||||
public string? Name { get; set; } // hiển thị FE — vd "NV.PRO duyệt"
|
|
||||||
public int? SlaDays { get; set; }
|
|
||||||
public bool IsRequired { get; set; } = true;
|
|
||||||
|
|
||||||
public WorkflowStep? Step { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -40,6 +40,14 @@ public class PurchaseEvaluation : AuditableEntity
|
|||||||
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.
|
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.
|
||||||
public PurchaseEvaluationPhase? RejectedFromPhase { get; set; }
|
public PurchaseEvaluationPhase? RejectedFromPhase { get; set; }
|
||||||
|
|
||||||
|
// Flat workflow tracking (Session 16 — Migration 21):
|
||||||
|
// - CurrentWorkflowStepIndex: 0-based index của step đang chờ approver
|
||||||
|
// (khi Phase=ChoDuyet). Null khi DangSoanThao/DaDuyet/TuChoi.
|
||||||
|
// - RejectedAtStepIndex: snapshot CurrentWorkflowStepIndex tại Trả lại.
|
||||||
|
// Drafter resume → restore CurrentWorkflowStepIndex (jump-back).
|
||||||
|
public int? CurrentWorkflowStepIndex { get; set; }
|
||||||
|
public int? RejectedAtStepIndex { get; set; }
|
||||||
|
|
||||||
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
|
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
|
||||||
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
|
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
|
||||||
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
||||||
|
|||||||
@ -22,11 +22,5 @@ public class PurchaseEvaluationDepartmentApproval : AuditableEntity
|
|||||||
public DateTime ApprovedAt { get; set; }
|
public DateTime ApprovedAt { get; set; }
|
||||||
public bool IsBypassed { get; set; }
|
public bool IsBypassed { get; set; }
|
||||||
|
|
||||||
// N-stage inner step link (Mig 18) — null cho data legacy 2-stage Review/Confirm.
|
|
||||||
// Có giá trị khi step cha có InnerSteps configured → mỗi sub-step approve =
|
|
||||||
// 1 row riêng với InnerStepId set. Cùng Stage=Confirm (legacy field giữ nguyên
|
|
||||||
// cho backward compat — N-stage không dùng Review/Confirm semantics).
|
|
||||||
public Guid? InnerStepId { get; set; }
|
|
||||||
|
|
||||||
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
|
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
namespace SolutionErp.Domain.PurchaseEvaluations;
|
namespace SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
// State machine cho phiếu Duyệt NCC (tiền-HĐ). 2 workflow khác nhau cùng
|
// State machine PE — Session 16 drastic refactor (Mig 21):
|
||||||
// share state space này — A (DuyetNcc) dùng subset, B (DuyetNccPhuongAn)
|
// DangSoanThao → ChoDuyet (Drafter trình, init CurrentWorkflowStepIndex=0)
|
||||||
// dùng full.
|
// ChoDuyet → ChoDuyet (advance step pointer mỗi lần approve)
|
||||||
|
// ChoDuyet → DaDuyet (last step done — terminal thành công)
|
||||||
|
// ChoDuyet → DangSoanThao (Trả lại — save RejectedAtStepIndex, Drafter sửa)
|
||||||
|
// ChoDuyet → TuChoi (Từ chối — terminal khoá phiếu)
|
||||||
|
// DangSoanThao → TuChoi (Drafter huỷ trước trình)
|
||||||
//
|
//
|
||||||
// A: DangSoanThao → ChoPurchasing → ChoCCM → ChoCEODuyetNCC → DaDuyet
|
// LEGACY values 2-6 + 98 deprecated post-Mig 21 (data cũ vẫn đọc OK,
|
||||||
// B: DangSoanThao → ChoPurchasing → ChoDuAn → ChoCCM → ChoCEODuyetPA → ChoCEODuyetNCC → DaDuyet
|
// new workflow definitions chỉ dùng DangSoanThao/ChoDuyet/DaDuyet/TuChoi).
|
||||||
// Cả 2: từ DangSoanThao có thể → TuChoi; từ mọi phase duyệt reject → DangSoanThao.
|
|
||||||
public enum PurchaseEvaluationPhase
|
public enum PurchaseEvaluationPhase
|
||||||
{
|
{
|
||||||
DangSoanThao = 1,
|
DangSoanThao = 1,
|
||||||
ChoPurchasing = 2,
|
ChoPurchasing = 2, // [LEGACY] deprecated
|
||||||
ChoDuAn = 3, // chỉ B
|
ChoDuAn = 3, // [LEGACY] deprecated
|
||||||
ChoCCM = 4,
|
ChoCCM = 4, // [LEGACY] deprecated
|
||||||
ChoCEODuyetPA = 5, // chỉ B (duyệt phương án trước)
|
ChoCEODuyetPA = 5, // [LEGACY] deprecated
|
||||||
ChoCEODuyetNCC = 6, // chung cả A & B — duyệt chọn đơn vị
|
ChoCEODuyetNCC = 6, // [LEGACY] deprecated
|
||||||
DaDuyet = 7, // terminal thành công
|
DaDuyet = 7, // terminal thành công
|
||||||
TraLai = 98, // approver trả về cho Drafter sửa (vẫn cho edit, khác TuChoi)
|
ChoDuyet = 10, // [Mig 21] generic intermediate, dùng CurrentWorkflowStepIndex tracking
|
||||||
|
TraLai = 98, // [LEGACY] deprecated — Session 14 chốt thay bằng Trả lại = về DangSoanThao
|
||||||
TuChoi = 99, // terminal từ chối — KHÔNG cho edit/thao tác
|
TuChoi = 99, // terminal từ chối — KHÔNG cho edit/thao tác
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,17 +4,12 @@ using SolutionErp.Domain.Identity; // reuse PositionLevel
|
|||||||
|
|
||||||
namespace SolutionErp.Domain.PurchaseEvaluations;
|
namespace SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
// Versioned workflow definition cho module Duyệt NCC — pattern giống HĐ
|
// Versioned workflow definition cho module Duyệt NCC — pattern giống HĐ.
|
||||||
// nhưng tách table riêng vì Phase là PurchaseEvaluationPhase enum (không
|
// Invariant: AT MOST ONE IsActive=true per PurchaseEvaluationType.
|
||||||
// phải ContractPhase). Admin có UI /system/pe-workflows/:typeCode tương
|
// PurchaseEvaluation.WorkflowDefinitionId pin tại create.
|
||||||
// tự /system/workflows/:typeCode.
|
|
||||||
//
|
|
||||||
// Invariant: AT MOST ONE IsActive=true per PurchaseEvaluationType tại 1
|
|
||||||
// thời điểm. PurchaseEvaluation.WorkflowDefinitionId pin tại create →
|
|
||||||
// phiếu cũ không bị ảnh hưởng khi admin active version mới.
|
|
||||||
public class PurchaseEvaluationWorkflowDefinition : BaseEntity
|
public class PurchaseEvaluationWorkflowDefinition : BaseEntity
|
||||||
{
|
{
|
||||||
public string Code { get; set; } = string.Empty; // "QT-DN-A" / "QT-DN-B" default
|
public string Code { get; set; } = string.Empty;
|
||||||
public int Version { get; set; }
|
public int Version { get; set; }
|
||||||
public PurchaseEvaluationType EvaluationType { get; set; }
|
public PurchaseEvaluationType EvaluationType { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
@ -25,22 +20,23 @@ public class PurchaseEvaluationWorkflowDefinition : BaseEntity
|
|||||||
public List<PurchaseEvaluationWorkflowStep> Steps { get; set; } = new();
|
public List<PurchaseEvaluationWorkflowStep> Steps { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workflow Step PE (Session 16 — Mig 21 drastic refactor):
|
||||||
|
// Mỗi step = 1 (Phòng × Cấp + Approvers users). Sequential per Order.
|
||||||
|
// Service iterate steps OrderBy(Order), advance PE.CurrentWorkflowStepIndex.
|
||||||
public class PurchaseEvaluationWorkflowStep : BaseEntity
|
public class PurchaseEvaluationWorkflowStep : BaseEntity
|
||||||
{
|
{
|
||||||
public Guid PurchaseEvaluationWorkflowDefinitionId { get; set; }
|
public Guid PurchaseEvaluationWorkflowDefinitionId { get; set; }
|
||||||
public int Order { get; set; }
|
public int Order { get; set; }
|
||||||
public PurchaseEvaluationPhase Phase { get; set; }
|
public PurchaseEvaluationPhase Phase { get; set; } // [DEPRECATED] dùng ChoDuyet=10 cho new
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty; // "Phòng A — Cấp 1"
|
||||||
public int? SlaDays { get; set; }
|
public int? SlaDays { get; set; }
|
||||||
|
|
||||||
|
// Mig 21 — Phòng × Cấp. Approver match Dept + PositionLevel (OR cùng cấp+phòng).
|
||||||
|
public Guid? DepartmentId { get; set; }
|
||||||
|
public PositionLevel? PositionLevel { get; set; }
|
||||||
|
|
||||||
public PurchaseEvaluationWorkflowDefinition? Definition { get; set; }
|
public PurchaseEvaluationWorkflowDefinition? Definition { get; set; }
|
||||||
public List<PurchaseEvaluationWorkflowStepApprover> Approvers { get; set; } = new();
|
public List<PurchaseEvaluationWorkflowStepApprover> Approvers { get; set; } = new();
|
||||||
|
|
||||||
// Inner steps (Mig 18) — N-stage approval cấu hình động trong cùng 1 phase.
|
|
||||||
// Empty list → fallback logic 2-stage Review/Confirm legacy (Mig 16) per dept.
|
|
||||||
// Có item → service loop theo Order: user khớp Department × PositionLevel
|
|
||||||
// duyệt sub-step. Tất cả required InnerSteps Done → cho phase transition.
|
|
||||||
public List<PurchaseEvaluationWorkflowStepInnerStep> InnerSteps { get; set; } = new();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PurchaseEvaluationWorkflowStepApprover : BaseEntity
|
public class PurchaseEvaluationWorkflowStepApprover : BaseEntity
|
||||||
@ -51,24 +47,3 @@ public class PurchaseEvaluationWorkflowStepApprover : BaseEntity
|
|||||||
|
|
||||||
public PurchaseEvaluationWorkflowStep? Step { get; set; }
|
public PurchaseEvaluationWorkflowStep? Step { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner step (Mig 18 — Phase 9+) — sub-step level con cấu hình bên trong 1
|
|
||||||
// WorkflowStep cha (= 1 phase). Cho phép admin định nghĩa thứ tự duyệt N-stage
|
|
||||||
// theo Department × PositionLevel: NV.A → PP.A → TP.A → NV.B → PP.B → TP.B → ...
|
|
||||||
//
|
|
||||||
// User khớp DepartmentId + PositionLevel + Order tiếp theo chưa duyệt = approver
|
|
||||||
// hợp lệ. CanBypassReview ở User cho TP skip NV+PP cùng dept (audit IsBypassed).
|
|
||||||
//
|
|
||||||
// IsRequired=false → cho phép skip không cần row approval (vd "PP optional").
|
|
||||||
public class PurchaseEvaluationWorkflowStepInnerStep : BaseEntity
|
|
||||||
{
|
|
||||||
public Guid PurchaseEvaluationWorkflowStepId { get; set; }
|
|
||||||
public int Order { get; set; } // thứ tự sequential trong cùng step cha
|
|
||||||
public Guid DepartmentId { get; set; }
|
|
||||||
public PositionLevel PositionLevel { get; set; } // NV / PP / TP
|
|
||||||
public string? Name { get; set; } // hiển thị FE — vd "NV.PRO duyệt"
|
|
||||||
public int? SlaDays { get; set; } // override step.SlaDays nếu set
|
|
||||||
public bool IsRequired { get; set; } = true; // false → optional skip không cần row
|
|
||||||
|
|
||||||
public PurchaseEvaluationWorkflowStep? Step { get; set; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -41,7 +41,6 @@ public class ApplicationDbContext
|
|||||||
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
|
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
|
||||||
public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>();
|
public DbSet<WorkflowStep> WorkflowSteps => Set<WorkflowStep>();
|
||||||
public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>();
|
public DbSet<WorkflowStepApprover> WorkflowStepApprovers => Set<WorkflowStepApprover>();
|
||||||
public DbSet<WorkflowStepInnerStep> WorkflowStepInnerSteps => Set<WorkflowStepInnerStep>();
|
|
||||||
public DbSet<ThauPhuDetail> ThauPhuDetails => Set<ThauPhuDetail>();
|
public DbSet<ThauPhuDetail> ThauPhuDetails => Set<ThauPhuDetail>();
|
||||||
public DbSet<GiaoKhoanDetail> GiaoKhoanDetails => Set<GiaoKhoanDetail>();
|
public DbSet<GiaoKhoanDetail> GiaoKhoanDetails => Set<GiaoKhoanDetail>();
|
||||||
public DbSet<NhaCungCapDetail> NhaCungCapDetails => Set<NhaCungCapDetail>();
|
public DbSet<NhaCungCapDetail> NhaCungCapDetails => Set<NhaCungCapDetail>();
|
||||||
@ -60,7 +59,6 @@ public class ApplicationDbContext
|
|||||||
public DbSet<PurchaseEvaluationWorkflowDefinition> PurchaseEvaluationWorkflowDefinitions => Set<PurchaseEvaluationWorkflowDefinition>();
|
public DbSet<PurchaseEvaluationWorkflowDefinition> PurchaseEvaluationWorkflowDefinitions => Set<PurchaseEvaluationWorkflowDefinition>();
|
||||||
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
|
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
|
||||||
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
|
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
|
||||||
public DbSet<PurchaseEvaluationWorkflowStepInnerStep> PurchaseEvaluationWorkflowStepInnerSteps => Set<PurchaseEvaluationWorkflowStepInnerStep>();
|
|
||||||
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
|
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
|
||||||
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
|
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
|
||||||
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
|
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
|
||||||
|
|||||||
@ -27,19 +27,11 @@ public class ContractDepartmentApprovalConfiguration
|
|||||||
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 chỉ áp khi InnerStepId IS NULL
|
// Mig 21 — drop InnerStepId column + restore simple unique non-filtered.
|
||||||
// (Mig 20 mirror PE Mig 19 filtered split).
|
|
||||||
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
|
b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("[InnerStepId] IS NULL")
|
|
||||||
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
|
.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.ContractId);
|
||||||
b.HasIndex(x => x.DepartmentId);
|
b.HasIndex(x => x.DepartmentId);
|
||||||
b.HasIndex(x => x.ApproverUserId);
|
b.HasIndex(x => x.ApproverUserId);
|
||||||
@ -48,12 +40,6 @@ public class ContractDepartmentApprovalConfiguration
|
|||||||
.WithMany(c => c.DepartmentApprovals)
|
.WithMany(c => c.DepartmentApprovals)
|
||||||
.HasForeignKey(x => x.ContractId)
|
.HasForeignKey(x => x.ContractId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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.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
|
// Mig 21 — drop InnerStepId column + restore simple unique non-filtered.
|
||||||
// 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);
|
||||||
@ -92,13 +68,6 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration
|
|||||||
.WithMany(c => c.DepartmentApprovals)
|
.WithMany(c => c.DepartmentApprovals)
|
||||||
.HasForeignKey(x => x.PurchaseEvaluationId)
|
.HasForeignKey(x => x.PurchaseEvaluationId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -184,13 +184,21 @@ public class PurchaseEvaluationWorkflowStepConfiguration : IEntityTypeConfigurat
|
|||||||
e.ToTable("PurchaseEvaluationWorkflowSteps");
|
e.ToTable("PurchaseEvaluationWorkflowSteps");
|
||||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
e.Property(x => x.Phase).HasConversion<int>();
|
e.Property(x => x.Phase).HasConversion<int>();
|
||||||
|
e.Property(x => x.PositionLevel).HasConversion<int?>(); // Mig 21
|
||||||
|
|
||||||
e.HasOne(x => x.Definition)
|
e.HasOne(x => x.Definition)
|
||||||
.WithMany(d => d.Steps)
|
.WithMany(d => d.Steps)
|
||||||
.HasForeignKey(x => x.PurchaseEvaluationWorkflowDefinitionId)
|
.HasForeignKey(x => x.PurchaseEvaluationWorkflowDefinitionId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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 => 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
|
// Mirror ContractCodeSequenceConfiguration — Prefix là PK, atomic UPDATE qua
|
||||||
// SERIALIZABLE transaction trong PurchaseEvaluationCodeGenerator.
|
// SERIALIZABLE transaction trong PurchaseEvaluationCodeGenerator.
|
||||||
public class PurchaseEvaluationCodeSequenceConfiguration
|
public class PurchaseEvaluationCodeSequenceConfiguration
|
||||||
|
|||||||
@ -28,13 +28,21 @@ public class WorkflowStepConfiguration : IEntityTypeConfiguration<WorkflowStep>
|
|||||||
e.ToTable("WorkflowSteps");
|
e.ToTable("WorkflowSteps");
|
||||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
e.Property(x => x.Phase).HasConversion<int>();
|
e.Property(x => x.Phase).HasConversion<int>();
|
||||||
|
e.Property(x => x.PositionLevel).HasConversion<int?>(); // Mig 21
|
||||||
|
|
||||||
e.HasOne(x => x.WorkflowDefinition)
|
e.HasOne(x => x.WorkflowDefinition)
|
||||||
.WithMany(d => d.Steps)
|
.WithMany(d => d.Steps)
|
||||||
.HasForeignKey(x => x.WorkflowDefinitionId)
|
.HasForeignKey(x => x.WorkflowDefinitionId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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 => new { x.WorkflowDefinitionId, x.Order });
|
||||||
|
e.HasIndex(x => x.DepartmentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,31 +60,3 @@ public class WorkflowStepApproverConfiguration : IEntityTypeConfiguration<Workfl
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -484,6 +484,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid?>("CreatedBy")
|
b.Property<Guid?>("CreatedBy")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("CurrentWorkflowStepIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@ -520,6 +523,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid>("ProjectId")
|
b.Property<Guid>("ProjectId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedAtStepIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("RejectedFromPhase")
|
b.Property<int?>("RejectedFromPhase")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@ -824,9 +830,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid>("DepartmentId")
|
b.Property<Guid>("DepartmentId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<Guid?>("InnerStepId")
|
|
||||||
.HasColumnType("uniqueidentifier");
|
|
||||||
|
|
||||||
b.Property<bool>("IsBypassed")
|
b.Property<bool>("IsBypassed")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@ -853,17 +856,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("DepartmentId");
|
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")
|
b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage")
|
.HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage");
|
||||||
.HasFilter("[InnerStepId] IS NULL");
|
|
||||||
|
|
||||||
b.ToTable("ContractDepartmentApprovals", (string)null);
|
b.ToTable("ContractDepartmentApprovals", (string)null);
|
||||||
});
|
});
|
||||||
@ -1422,6 +1417,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid?>("CreatedBy")
|
b.Property<Guid?>("CreatedBy")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@ -1433,6 +1431,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<int>("Phase")
|
b.Property<int>("Phase")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("PositionLevel")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("SlaDays")
|
b.Property<int?>("SlaDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@ -1447,6 +1448,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DepartmentId");
|
||||||
|
|
||||||
b.HasIndex("WorkflowDefinitionId", "Order");
|
b.HasIndex("WorkflowDefinitionId", "Order");
|
||||||
|
|
||||||
b.ToTable("WorkflowSteps", (string)null);
|
b.ToTable("WorkflowSteps", (string)null);
|
||||||
@ -1488,55 +1491,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("WorkflowStepApprovers", (string)null);
|
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 =>
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -2416,6 +2370,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid?>("CreatedBy")
|
b.Property<Guid?>("CreatedBy")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("CurrentWorkflowStepIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@ -2452,6 +2409,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid>("ProjectId")
|
b.Property<Guid>("ProjectId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("RejectedAtStepIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("RejectedFromPhase")
|
b.Property<int?>("RejectedFromPhase")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@ -2719,9 +2679,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid>("DepartmentId")
|
b.Property<Guid>("DepartmentId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<Guid?>("InnerStepId")
|
|
||||||
.HasColumnType("uniqueidentifier");
|
|
||||||
|
|
||||||
b.Property<bool>("IsBypassed")
|
b.Property<bool>("IsBypassed")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@ -2749,19 +2706,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasIndex("DepartmentId");
|
b.HasIndex("DepartmentId");
|
||||||
|
|
||||||
b.HasIndex("InnerStepId");
|
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
@ -3080,6 +3029,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid?>("CreatedBy")
|
b.Property<Guid?>("CreatedBy")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@ -3091,6 +3043,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<int>("Phase")
|
b.Property<int>("Phase")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("PositionLevel")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<Guid>("PurchaseEvaluationWorkflowDefinitionId")
|
b.Property<Guid>("PurchaseEvaluationWorkflowDefinitionId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
@ -3105,6 +3060,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DepartmentId");
|
||||||
|
|
||||||
b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order");
|
b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order");
|
||||||
|
|
||||||
b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null);
|
b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null);
|
||||||
@ -3146,55 +3103,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null);
|
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 =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
b.HasOne("SolutionErp.Domain.Identity.Role", null)
|
||||||
@ -3342,11 +3250,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("InnerStepId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.Navigation("Contract");
|
b.Navigation("Contract");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -3429,6 +3332,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
|
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")
|
b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition")
|
||||||
.WithMany("Steps")
|
.WithMany("Steps")
|
||||||
.HasForeignKey("WorkflowDefinitionId")
|
.HasForeignKey("WorkflowDefinitionId")
|
||||||
@ -3449,23 +3357,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Step");
|
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 =>
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
|
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
|
||||||
@ -3538,11 +3429,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b =>
|
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")
|
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
|
||||||
.WithMany("DepartmentApprovals")
|
.WithMany("DepartmentApprovals")
|
||||||
.HasForeignKey("PurchaseEvaluationId")
|
.HasForeignKey("PurchaseEvaluationId")
|
||||||
@ -3610,6 +3496,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
|
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")
|
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition")
|
||||||
.WithMany("Steps")
|
.WithMany("Steps")
|
||||||
.HasForeignKey("PurchaseEvaluationWorkflowDefinitionId")
|
.HasForeignKey("PurchaseEvaluationWorkflowDefinitionId")
|
||||||
@ -3630,23 +3521,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Step");
|
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 =>
|
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Approvals");
|
b.Navigation("Approvals");
|
||||||
@ -3693,8 +3567,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Approvers");
|
b.Navigation("Approvers");
|
||||||
|
|
||||||
b.Navigation("InnerSteps");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
|
||||||
@ -3736,8 +3608,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Approvers");
|
b.Navigation("Approvers");
|
||||||
|
|
||||||
b.Navigation("InnerSteps");
|
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,9 +11,10 @@ using SolutionErp.Domain.Notifications;
|
|||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Services;
|
namespace SolutionErp.Infrastructure.Services;
|
||||||
|
|
||||||
// Thin orchestrator — all phase/role/SLA rules live in WorkflowPolicy (Domain).
|
// Contract Workflow Service — Session 16 drastic refactor (Mig 21):
|
||||||
// This class is responsible only for *applying* transitions: DB writes, code
|
// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate
|
||||||
// generation at DangDongDau, SLA deadline computation, notification dispatch.
|
// steps OrderBy Order, advance Contract.CurrentWorkflowStepIndex per approve.
|
||||||
|
// Phase enum simplified: DangSoanThao → ChoDuyet → DaPhatHanh / TuChoi.
|
||||||
public class ContractWorkflowService(
|
public class ContractWorkflowService(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
IContractCodeGenerator codeGenerator,
|
IContractCodeGenerator codeGenerator,
|
||||||
@ -22,11 +23,8 @@ public class ContractWorkflowService(
|
|||||||
IChangelogService changelog,
|
IChangelogService changelog,
|
||||||
UserManager<User> userManager) : IContractWorkflowService
|
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) =>
|
public TimeSpan? GetPhaseSla(ContractPhase phase) =>
|
||||||
WorkflowPolicies.Standard.PhaseSla.GetValueOrDefault(phase);
|
phase == ContractPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;
|
||||||
|
|
||||||
public async Task TransitionAsync(
|
public async Task TransitionAsync(
|
||||||
Contract contract,
|
Contract contract,
|
||||||
@ -37,326 +35,128 @@ public class ContractWorkflowService(
|
|||||||
string? comment,
|
string? comment,
|
||||||
CancellationToken ct = default)
|
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 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 isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||||
|
|
||||||
// Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi
|
// ===== REJECT BRANCH =====
|
||||||
// sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase).
|
if (decision == ApprovalDecision.Reject)
|
||||||
if (!isAdmin && !isSystem && !isResumingAfterReject)
|
|
||||||
{
|
{
|
||||||
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
|
if (targetPhase == ContractPhase.TuChoi)
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
var userExtra = policy.UserTransitions is not null
|
contract.Phase = ContractPhase.TuChoi;
|
||||||
&& 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}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 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;
|
|
||||||
|
|
||||||
if (decision == ApprovalDecision.Approve
|
|
||||||
&& targetPhase != ContractPhase.DangSoanThao
|
|
||||||
&& targetPhase != ContractPhase.TuChoi
|
|
||||||
&& !isResumingAfterReject
|
|
||||||
&& !isAdmin && !isSystem
|
|
||||||
&& actorUserId is Guid actorUid)
|
|
||||||
{
|
|
||||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
|
||||||
|
|
||||||
if (hasInnerSteps && currentStepDef is not null)
|
|
||||||
{
|
|
||||||
// ===== N-stage logic (Mig 20) — mirror PE Mig 18 =====
|
|
||||||
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.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
|
else
|
||||||
{
|
{
|
||||||
// Bypass cùng dept: upsert tất cả pending inner trong dept actor
|
contract.RejectedFromPhase = fromPhase;
|
||||||
// có level từ firstPending.PositionLevel đến actorPos (inclusive)
|
contract.RejectedAtStepIndex = contract.CurrentWorkflowStepIndex;
|
||||||
foreach (var inner in inners
|
contract.Phase = ContractPhase.DangSoanThao;
|
||||||
.Where(i => i.DepartmentId == actorDept
|
contract.CurrentWorkflowStepIndex = null;
|
||||||
&& (int)i.PositionLevel >= (int)firstPending.PositionLevel
|
|
||||||
&& (int)i.PositionLevel <= (int)actorPos
|
|
||||||
&& !doneInnerIds.Contains(i.Id)))
|
|
||||||
{
|
|
||||||
rowsToCreate.Add((inner, inner.PositionLevel != actorPos));
|
|
||||||
}
|
}
|
||||||
|
contract.SlaDeadline = null;
|
||||||
|
await LogTransitionAsync(contract, fromPhase, contract.Phase, actorUserId, decision, comment, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nowUtc = dateTime.UtcNow;
|
// ===== RESUME AFTER REJECT =====
|
||||||
foreach (var (inner, bypassed) in rowsToCreate)
|
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||||
|
&& fromPhase == ContractPhase.DangSoanThao
|
||||||
|
&& contract.RejectedAtStepIndex != null;
|
||||||
|
|
||||||
|
if (isResumingAfterReject)
|
||||||
{
|
{
|
||||||
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
|
contract.Phase = ContractPhase.ChoDuyet;
|
||||||
{
|
contract.CurrentWorkflowStepIndex = contract.RejectedAtStepIndex;
|
||||||
ContractId = contract.Id,
|
contract.RejectedAtStepIndex = null;
|
||||||
PhaseAtApproval = (int)fromPhase,
|
contract.RejectedFromPhase = null;
|
||||||
DepartmentId = inner.DepartmentId,
|
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
Stage = ApprovalStage.Confirm,
|
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||||
ApproverUserId = actorUid,
|
await db.SaveChangesAsync(ct);
|
||||||
ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}",
|
return;
|
||||||
Comment = comment,
|
|
||||||
ApprovedAt = nowUtc,
|
|
||||||
IsBypassed = bypassed,
|
|
||||||
InnerStepId = inner.Id,
|
|
||||||
});
|
|
||||||
doneInnerIds.Add(inner.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id));
|
// ===== DRAFTER TRÌNH =====
|
||||||
if (stillPending)
|
if (fromPhase == ContractPhase.DangSoanThao
|
||||||
|
&& (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem)))
|
||||||
{
|
{
|
||||||
|
if (!isAdmin && !isSystem
|
||||||
|
&& !actorRoles.Contains(AppRoles.Drafter)
|
||||||
|
&& !actorRoles.Contains(AppRoles.DeptManager))
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
|
||||||
|
}
|
||||||
|
|
||||||
db.ContractApprovals.Add(new ContractApproval
|
db.ContractApprovals.Add(new ContractApproval
|
||||||
{
|
{
|
||||||
ContractId = contract.Id,
|
ContractId = contract.Id,
|
||||||
FromPhase = fromPhase,
|
FromPhase = fromPhase,
|
||||||
ToPhase = fromPhase,
|
ToPhase = fromPhase,
|
||||||
ApproverUserId = actorUid,
|
ApproverUserId = actorUserId,
|
||||||
Decision = ApprovalDecision.Approve,
|
Decision = decision,
|
||||||
Comment = $"[Inner step duyệt {actorPos}] {comment ?? ""}",
|
Comment = $"[Step {currentIdx + 1}] {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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 canBypass = actor.CanBypassReview;
|
|
||||||
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
|
||||||
var isBypassed = !isManager && canBypass;
|
|
||||||
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
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,
|
ApprovedAt = dateTime.UtcNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
string? reviewerName = actor.FullName ?? actor.Email;
|
var nextIdx = currentIdx + 1;
|
||||||
db.ContractChangelogs.Add(new ContractChangelog
|
if (nextIdx >= steps.Count)
|
||||||
{
|
{
|
||||||
ContractId = contract.Id,
|
// All steps done — gen mã HĐ + DaPhatHanh
|
||||||
EntityType = ChangelogEntityType.Workflow,
|
if (string.IsNullOrEmpty(contract.MaHopDong))
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
{
|
{
|
||||||
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
||||||
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
||||||
@ -364,51 +164,67 @@ public class ContractWorkflowService(
|
|||||||
?? throw new NotFoundException("Project", contract.ProjectId);
|
?? throw new NotFoundException("Project", contract.ProjectId);
|
||||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||||
}
|
}
|
||||||
|
contract.Phase = ContractPhase.DaPhatHanh;
|
||||||
contract.SlaWarningSent = false;
|
contract.CurrentWorkflowStepIndex = null;
|
||||||
contract.Phase = targetPhase;
|
contract.SlaDeadline = null;
|
||||||
|
await LogTransitionAsync(contract, fromPhase, ContractPhase.DaPhatHanh, actorUserId, decision, comment, ct);
|
||||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
}
|
||||||
contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
|
else
|
||||||
|
|
||||||
db.ContractApprovals.Add(new ContractApproval
|
|
||||||
{
|
{
|
||||||
ContractId = contract.Id,
|
contract.CurrentWorkflowStepIndex = nextIdx;
|
||||||
FromPhase = fromPhase,
|
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
ToPhase = targetPhase,
|
await LogTransitionAsync(contract, fromPhase, fromPhase, actorUserId, decision,
|
||||||
ApproverUserId = actorUserId,
|
$"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct);
|
||||||
Decision = decision,
|
}
|
||||||
Comment = comment,
|
await db.SaveChangesAsync(ct);
|
||||||
ApprovedAt = dateTime.UtcNow,
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
// Log workflow transition vào unified Changelog (cho user xem trên tab Lịch sử)
|
// Admin manual override
|
||||||
await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, targetPhase, comment);
|
if (isAdmin)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
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.DaPhatHanh => ($"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành",
|
||||||
ContractPhase.TuChoi => $"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
|
NotificationType.ContractPublished),
|
||||||
_ => $"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển sang phase mới",
|
ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối",
|
||||||
};
|
NotificationType.ContractRejected),
|
||||||
var type = targetPhase switch
|
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",
|
||||||
ContractPhase.DaPhatHanh => NotificationType.ContractPublished,
|
NotificationType.ContractRejected),
|
||||||
ContractPhase.TuChoi => NotificationType.ContractRejected,
|
_ => ($"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển phase mới",
|
||||||
_ => NotificationType.ContractPhaseTransition,
|
NotificationType.ContractPhaseTransition),
|
||||||
};
|
};
|
||||||
await notifications.NotifyAsync(
|
await notifications.NotifyAsync(
|
||||||
drafterId,
|
drafterId, type, title,
|
||||||
type,
|
description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||||
title,
|
|
||||||
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
|
||||||
href: $"/contracts/{contract.Id}",
|
href: $"/contracts/{contract.Id}",
|
||||||
refId: contract.Id,
|
refId: contract.Id,
|
||||||
ct: ct);
|
ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,11 @@ using SolutionErp.Domain.PurchaseEvaluations;
|
|||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Services;
|
namespace SolutionErp.Infrastructure.Services;
|
||||||
|
|
||||||
// Mirror ContractWorkflowService. Load policy từ pinned
|
// PE Workflow Service — Session 16 drastic refactor (Mig 21):
|
||||||
// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry.
|
// 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(
|
public class PurchaseEvaluationWorkflowService(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
IDateTime dateTime,
|
IDateTime dateTime,
|
||||||
@ -21,7 +24,7 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
|
UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
|
||||||
{
|
{
|
||||||
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
|
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
|
||||||
PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase);
|
phase == PurchaseEvaluationPhase.ChoDuyet ? TimeSpan.FromDays(7) : null;
|
||||||
|
|
||||||
public async Task TransitionAsync(
|
public async Task TransitionAsync(
|
||||||
PurchaseEvaluation evaluation,
|
PurchaseEvaluation evaluation,
|
||||||
@ -32,345 +35,173 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
string? comment,
|
string? comment,
|
||||||
CancellationToken ct = default)
|
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 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 isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||||
|
|
||||||
// Policy guard — bypass khi resume sau reject (target đã pinned).
|
// ===== REJECT BRANCH =====
|
||||||
if (!isAdmin && !isSystem && !isResumingAfterReject)
|
if (decision == ApprovalDecision.Reject)
|
||||||
{
|
{
|
||||||
if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles))
|
if (targetPhase == PurchaseEvaluationPhase.TuChoi)
|
||||||
throw new ForbiddenException(
|
|
||||||
$"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}.");
|
|
||||||
|
|
||||||
if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId))
|
|
||||||
{
|
{
|
||||||
throw new ForbiddenException(
|
// Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16).
|
||||||
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " +
|
evaluation.Phase = PurchaseEvaluationPhase.TuChoi;
|
||||||
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 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;
|
|
||||||
|
|
||||||
if (decision == ApprovalDecision.Approve
|
|
||||||
&& targetPhase != PurchaseEvaluationPhase.DangSoanThao
|
|
||||||
&& targetPhase != PurchaseEvaluationPhase.TuChoi
|
|
||||||
&& !isResumingAfterReject
|
|
||||||
&& !isAdmin && !isSystem
|
|
||||||
&& actorUserId is Guid actorUid)
|
|
||||||
{
|
|
||||||
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
|
||||||
|
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
// Bypass cùng dept: upsert tất cả pending inner trong dept actor có level
|
// Trả lại — về DangSoanThao + save RejectedAtStepIndex (resume jump-back).
|
||||||
// từ firstPending.PositionLevel đến actorPos (inclusive)
|
evaluation.RejectedFromPhase = fromPhase;
|
||||||
foreach (var inner in inners
|
evaluation.RejectedAtStepIndex = evaluation.CurrentWorkflowStepIndex;
|
||||||
.Where(i => i.DepartmentId == actorDept
|
evaluation.Phase = PurchaseEvaluationPhase.DangSoanThao;
|
||||||
&& (int)i.PositionLevel >= (int)firstPending.PositionLevel
|
evaluation.CurrentWorkflowStepIndex = null;
|
||||||
&& (int)i.PositionLevel <= (int)actorPos
|
|
||||||
&& !doneInnerIds.Contains(i.Id)))
|
|
||||||
{
|
|
||||||
rowsToCreate.Add((inner, inner.PositionLevel != actorPos));
|
|
||||||
}
|
}
|
||||||
}
|
evaluation.SlaDeadline = null;
|
||||||
|
await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct);
|
||||||
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);
|
await db.SaveChangesAsync(ct);
|
||||||
return;
|
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 canBypass = actor.CanBypassReview;
|
|
||||||
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
|
||||||
var isBypassed = !isManager && canBypass;
|
|
||||||
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
|
||||||
|
|
||||||
// Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE filtered InnerStepId IS NULL.
|
// ===== RESUME AFTER REJECT (Drafter trình lại) =====
|
||||||
var existing = await db.PurchaseEvaluationDepartmentApprovals
|
var isResumingAfterReject = decision == ApprovalDecision.Approve
|
||||||
.FirstOrDefaultAsync(a =>
|
&& fromPhase == PurchaseEvaluationPhase.DangSoanThao
|
||||||
a.PurchaseEvaluationId == evaluation.Id
|
&& evaluation.RejectedAtStepIndex != null;
|
||||||
&& 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)
|
if (isResumingAfterReject)
|
||||||
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.
|
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
evaluation.CurrentWorkflowStepIndex = evaluation.RejectedAtStepIndex;
|
||||||
{
|
evaluation.RejectedAtStepIndex = null;
|
||||||
PurchaseEvaluationId = evaluation.Id,
|
evaluation.RejectedFromPhase = null;
|
||||||
FromPhase = fromPhase,
|
evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
ToPhase = fromPhase,
|
await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||||
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);
|
await db.SaveChangesAsync(ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 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))
|
||||||
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluation.SlaWarningSent = false;
|
// ===== APPROVE STEP (advance pointer trong ChoDuyet) =====
|
||||||
evaluation.Phase = targetPhase;
|
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;
|
||||||
|
|
||||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
if (def == null || def.Steps.Count == 0)
|
||||||
evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
|
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(
|
||||||
|
$"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log approval row
|
||||||
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||||
{
|
{
|
||||||
PurchaseEvaluationId = evaluation.Id,
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
FromPhase = fromPhase,
|
FromPhase = fromPhase,
|
||||||
ToPhase = targetPhase,
|
ToPhase = fromPhase, // step advance — phase same
|
||||||
ApproverUserId = actorUserId,
|
ApproverUserId = actorUserId,
|
||||||
Decision = decision,
|
Decision = decision,
|
||||||
Comment = comment,
|
Comment = $"[Step {currentIdx + 1}] {comment ?? ""}",
|
||||||
ApprovedAt = dateTime.UtcNow,
|
ApprovedAt = dateTime.UtcNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve actor name for changelog
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin manual override (vd test cứng phase)
|
||||||
|
if (isAdmin)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
string? actorName = null;
|
||||||
if (actorUserId is Guid uid)
|
if (actorUserId is Guid uid)
|
||||||
{
|
{
|
||||||
@ -383,36 +214,34 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
PurchaseEvaluationId = evaluation.Id,
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||||
Action = ChangelogAction.Transition,
|
Action = ChangelogAction.Transition,
|
||||||
PhaseAtChange = targetPhase,
|
PhaseAtChange = toPhase,
|
||||||
UserId = actorUserId,
|
UserId = actorUserId,
|
||||||
UserName = actorName ?? "Hệ thống",
|
UserName = actorName ?? "Hệ thống",
|
||||||
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
|
Summary = $"Chuyển phase {fromPhase} → {toPhase}",
|
||||||
ContextNote = comment,
|
ContextNote = comment,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify drafter
|
// Notify drafter on terminal states
|
||||||
if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId)
|
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.DaDuyet => ($"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
|
||||||
PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối",
|
NotificationType.ContractPublished),
|
||||||
_ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
|
PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối",
|
||||||
};
|
NotificationType.ContractRejected),
|
||||||
var type = targetPhase switch
|
PurchaseEvaluationPhase.DangSoanThao when fromPhase == PurchaseEvaluationPhase.ChoDuyet =>
|
||||||
{
|
($"Phiếu {evaluation.TenGoiThau} bị trả lại — vui lòng sửa và trình lại",
|
||||||
PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished,
|
NotificationType.ContractRejected),
|
||||||
PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected,
|
_ => ($"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
|
||||||
_ => NotificationType.ContractPhaseTransition,
|
NotificationType.ContractPhaseTransition),
|
||||||
};
|
};
|
||||||
await notifications.NotifyAsync(
|
await notifications.NotifyAsync(
|
||||||
drafterId, type, title,
|
drafterId, type, title,
|
||||||
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||||
href: $"/purchase-evaluations/{evaluation.Id}",
|
href: $"/purchase-evaluations/{evaluation.Id}",
|
||||||
refId: evaluation.Id,
|
refId: evaluation.Id,
|
||||||
ct: ct);
|
ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,14 @@ public class CreatePeWorkflowDefinitionCommandHandlerTests
|
|||||||
Description: null,
|
Description: null,
|
||||||
Steps: new List<CreatePeWorkflowStepInput>
|
Steps: new List<CreatePeWorkflowStepInput>
|
||||||
{
|
{
|
||||||
new(Order: 1, Phase: (int)PurchaseEvaluationPhase.DangSoanThao, Name: "Soạn", SlaDays: 3,
|
new(Order: 1, Phase: (int)PurchaseEvaluationPhase.ChoDuyet, Name: "Soạn",
|
||||||
|
SlaDays: 3, DepartmentId: null, PositionLevel: null,
|
||||||
Approvers: new List<CreatePeWorkflowStepApproverInput>
|
Approvers: new List<CreatePeWorkflowStepApproverInput>
|
||||||
{
|
{
|
||||||
new(Kind: (int)WorkflowApproverKind.Role, AssignmentValue: AppRoles.Drafter),
|
new(Kind: (int)WorkflowApproverKind.Role, AssignmentValue: AppRoles.Drafter),
|
||||||
}),
|
}),
|
||||||
new(Order: 2, Phase: (int)PurchaseEvaluationPhase.ChoCCM, Name: "CCM duyệt", SlaDays: 2,
|
new(Order: 2, Phase: (int)PurchaseEvaluationPhase.ChoDuyet, Name: "CCM duyệt",
|
||||||
|
SlaDays: 2, DepartmentId: null, PositionLevel: null,
|
||||||
Approvers: new List<CreatePeWorkflowStepApproverInput>
|
Approvers: new List<CreatePeWorkflowStepApproverInput>
|
||||||
{
|
{
|
||||||
new(Kind: (int)WorkflowApproverKind.Role, AssignmentValue: AppRoles.CostControl),
|
new(Kind: (int)WorkflowApproverKind.Role, AssignmentValue: AppRoles.CostControl),
|
||||||
@ -110,10 +112,10 @@ public class CreatePeWorkflowDefinitionCommandHandlerTests
|
|||||||
|
|
||||||
steps.Should().HaveCount(2);
|
steps.Should().HaveCount(2);
|
||||||
steps[0].Order.Should().Be(1);
|
steps[0].Order.Should().Be(1);
|
||||||
steps[0].Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
steps[0].Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||||
steps[0].Name.Should().Be("Soạn");
|
steps[0].Name.Should().Be("Soạn");
|
||||||
steps[1].Order.Should().Be(2);
|
steps[1].Order.Should().Be(2);
|
||||||
steps[1].Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
steps[1].Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@ -1,350 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using SolutionErp.Application.Common.Exceptions;
|
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
|
||||||
using SolutionErp.Application.Contracts.Services;
|
|
||||||
using SolutionErp.Application.Notifications;
|
|
||||||
using SolutionErp.Domain.Common;
|
|
||||||
using SolutionErp.Domain.Contracts;
|
|
||||||
using SolutionErp.Domain.Identity;
|
|
||||||
using SolutionErp.Domain.Notifications;
|
|
||||||
using SolutionErp.Infrastructure.Services;
|
|
||||||
using SolutionErp.Infrastructure.Tests.Common;
|
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
|
||||||
|
|
||||||
// Tests cho N-stage department approval logic ở ContractWorkflowService (Mig 20).
|
|
||||||
// Mirror PeNStageApprovalTests pattern. Cover Phòng × PositionLevel sequential
|
|
||||||
// trong cùng phase + bypass cùng dept + reject reset + legacy fallback.
|
|
||||||
public class ContractNStageApprovalTests : IClassFixture<IdentityFixture>
|
|
||||||
{
|
|
||||||
private readonly IdentityFixture _fx;
|
|
||||||
private readonly TestApplicationDbContext _db;
|
|
||||||
private readonly UserManager<User> _userManager;
|
|
||||||
private readonly ContractWorkflowService _service;
|
|
||||||
private readonly Guid _deptPro;
|
|
||||||
private readonly Guid _deptCcm;
|
|
||||||
|
|
||||||
public ContractNStageApprovalTests(IdentityFixture fx)
|
|
||||||
{
|
|
||||||
_fx = fx;
|
|
||||||
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
|
|
||||||
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
|
|
||||||
|
|
||||||
_deptPro = SeedDept("PRO-CTR-NS", "Phòng Cung ứng (CTR-NS)");
|
|
||||||
_deptCcm = SeedDept("CCM-CTR-NS", "Phòng Kiểm soát (CTR-NS)");
|
|
||||||
|
|
||||||
var clock = new FixedDateTime(new DateTime(2026, 5, 7, 11, 0, 0, DateTimeKind.Utc));
|
|
||||||
var fakeNotifications = new FakeNotificationService();
|
|
||||||
var fakeChangelog = new FakeChangelogService();
|
|
||||||
var fakeCodeGen = new FakeContractCodeGenerator();
|
|
||||||
|
|
||||||
_service = new ContractWorkflowService(
|
|
||||||
_db, fakeCodeGen, clock, fakeNotifications, fakeChangelog, _userManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Guid SeedDept(string code, string name)
|
|
||||||
{
|
|
||||||
var existing = _db.Departments.FirstOrDefault(d => d.Code == code);
|
|
||||||
if (existing is not null) return existing.Id;
|
|
||||||
var d = new SolutionErp.Domain.Master.Department { Id = Guid.NewGuid(), Code = code, Name = name };
|
|
||||||
_db.Departments.Add(d);
|
|
||||||
_db.SaveChanges();
|
|
||||||
return d.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Guid> SeedWorkflowDefinitionAsync(
|
|
||||||
params (Guid deptId, PositionLevel level)[] innerSteps)
|
|
||||||
{
|
|
||||||
// 2 step adjacent: DangGopY (current, có inner steps) → DangDamPhan (next).
|
|
||||||
// FromDefinition build transition (DangGopY → DangDamPhan) từ step[1].Approvers role.
|
|
||||||
var def = new WorkflowDefinition
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Code = $"NS-CTR-{Guid.NewGuid():N}".Substring(0, 18),
|
|
||||||
Version = 1,
|
|
||||||
ContractType = ContractType.HopDongThauPhu,
|
|
||||||
Name = "N-stage Contract test workflow",
|
|
||||||
IsActive = true,
|
|
||||||
ActivatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
var step1 = new WorkflowStep
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Order = 1,
|
|
||||||
Phase = ContractPhase.DangGopY,
|
|
||||||
Name = "Góp ý",
|
|
||||||
Approvers =
|
|
||||||
{
|
|
||||||
new WorkflowStepApprover
|
|
||||||
{
|
|
||||||
Kind = WorkflowApproverKind.Role,
|
|
||||||
AssignmentValue = "Procurement",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
for (int i = 0; i < innerSteps.Length; i++)
|
|
||||||
{
|
|
||||||
step1.InnerSteps.Add(new WorkflowStepInnerStep
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Order = i + 1,
|
|
||||||
DepartmentId = innerSteps[i].deptId,
|
|
||||||
PositionLevel = innerSteps[i].level,
|
|
||||||
IsRequired = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
def.Steps.Add(step1);
|
|
||||||
// Step 2 — chỉ để FromDefinition build transition (DangGopY → DangDamPhan).
|
|
||||||
def.Steps.Add(new WorkflowStep
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Order = 2,
|
|
||||||
Phase = ContractPhase.DangDamPhan,
|
|
||||||
Name = "Đàm phán",
|
|
||||||
Approvers =
|
|
||||||
{
|
|
||||||
new WorkflowStepApprover
|
|
||||||
{
|
|
||||||
Kind = WorkflowApproverKind.Role,
|
|
||||||
AssignmentValue = "Procurement",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
_db.WorkflowDefinitions.Add(def);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return def.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Contract> SeedContractAsync(
|
|
||||||
ContractPhase phase,
|
|
||||||
Guid? workflowDefinitionId = null)
|
|
||||||
{
|
|
||||||
var pid = Guid.NewGuid();
|
|
||||||
if (!_db.Projects.Any(p => p.Id == pid))
|
|
||||||
{
|
|
||||||
_db.Projects.Add(new SolutionErp.Domain.Master.Project
|
|
||||||
{
|
|
||||||
Id = pid,
|
|
||||||
Code = $"PRJ-CTR-{Random.Shared.Next(10000):D4}",
|
|
||||||
Name = "Test project CTR-NS",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var sid = Guid.NewGuid();
|
|
||||||
if (!_db.Suppliers.Any(s => s.Id == sid))
|
|
||||||
{
|
|
||||||
_db.Suppliers.Add(new SolutionErp.Domain.Master.Supplier
|
|
||||||
{
|
|
||||||
Id = sid,
|
|
||||||
Code = $"NCC-{Random.Shared.Next(10000):D4}",
|
|
||||||
Name = "Test supplier",
|
|
||||||
Type = SolutionErp.Domain.Master.SupplierType.NhaCungCap,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var contract = new Contract
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Type = ContractType.HopDongThauPhu,
|
|
||||||
Phase = phase,
|
|
||||||
TenHopDong = "Test HĐ N-stage",
|
|
||||||
ProjectId = pid,
|
|
||||||
SupplierId = sid,
|
|
||||||
WorkflowDefinitionId = workflowDefinitionId,
|
|
||||||
};
|
|
||||||
_db.Contracts.Add(contract);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return contract;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_FirstInner_NV_Approve_Blocks_Phase_Transition()
|
|
||||||
{
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong),
|
|
||||||
(_deptPro, PositionLevel.TruongPhong));
|
|
||||||
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-pro-ctr-{Guid.NewGuid():N}@test", "NV PRO Contract",
|
|
||||||
_deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
contract, ContractPhase.DangDamPhan, nv.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "duyệt NV");
|
|
||||||
|
|
||||||
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
|
||||||
fresh.Phase.Should().Be(ContractPhase.DangGopY);
|
|
||||||
|
|
||||||
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
|
||||||
rows.Should().HaveCount(1);
|
|
||||||
rows[0].InnerStepId.Should().NotBeNull();
|
|
||||||
rows[0].IsBypassed.Should().BeFalse();
|
|
||||||
rows[0].ApproverRoleSnapshot.Should().Contain("NhanVien");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_All_3_Levels_Sequential_Pass_Allow_Phase_Transition()
|
|
||||||
{
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong),
|
|
||||||
(_deptPro, PositionLevel.TruongPhong));
|
|
||||||
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync($"nv-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
var pp = await _fx.CreateUserAsync($"pp-{Guid.NewGuid():N}@test", "PP", _deptPro, ["Procurement"], positionLevel: PositionLevel.PhoPhong);
|
|
||||||
var tp = await _fx.CreateUserAsync($"tp-{Guid.NewGuid():N}@test", "TP", _deptPro, ["Procurement"], positionLevel: PositionLevel.TruongPhong);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV");
|
|
||||||
contract = await _db.Contracts.FirstAsync(x => x.Id == contract.Id);
|
|
||||||
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, pp.Id, ["Procurement"], ApprovalDecision.Approve, "PP");
|
|
||||||
contract = await _db.Contracts.FirstAsync(x => x.Id == contract.Id);
|
|
||||||
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, tp.Id, ["Procurement"], ApprovalDecision.Approve, "TP");
|
|
||||||
|
|
||||||
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
|
||||||
fresh.Phase.Should().Be(ContractPhase.DangDamPhan);
|
|
||||||
|
|
||||||
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null).ToListAsync();
|
|
||||||
rows.Should().HaveCount(3);
|
|
||||||
rows.All(r => !r.IsBypassed).Should().BeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_TP_Bypass_Skips_Lower_Levels_Same_Dept()
|
|
||||||
{
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong),
|
|
||||||
(_deptPro, PositionLevel.TruongPhong));
|
|
||||||
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
|
||||||
|
|
||||||
var tp = await _fx.CreateUserAsync(
|
|
||||||
$"tp-bypass-{Guid.NewGuid():N}@test", "TP bypass",
|
|
||||||
_deptPro, ["Procurement"],
|
|
||||||
canBypassReview: true, positionLevel: PositionLevel.TruongPhong);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
contract, ContractPhase.DangDamPhan, tp.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "TP bypass");
|
|
||||||
|
|
||||||
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
|
||||||
fresh.Phase.Should().Be(ContractPhase.DangDamPhan);
|
|
||||||
|
|
||||||
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null)
|
|
||||||
.ToListAsync();
|
|
||||||
rows.Should().HaveCount(3);
|
|
||||||
rows.Count(r => r.IsBypassed).Should().Be(2);
|
|
||||||
rows.Count(r => !r.IsBypassed).Should().Be(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_Wrong_Department_Throws_Forbidden()
|
|
||||||
{
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien));
|
|
||||||
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
|
||||||
|
|
||||||
var ccmActor = await _fx.CreateUserAsync(
|
|
||||||
$"ccm-wrong-{Guid.NewGuid():N}@test", "CCM wrong",
|
|
||||||
_deptCcm, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
|
|
||||||
var act = async () => await _service.TransitionAsync(
|
|
||||||
contract, ContractPhase.DangDamPhan, ccmActor.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "wrong dept");
|
|
||||||
await act.Should().ThrowAsync<ForbiddenException>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_Reject_Clears_InnerStep_Rows_At_Phase()
|
|
||||||
{
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong));
|
|
||||||
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync($"nv-rej-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV");
|
|
||||||
|
|
||||||
var rowsBefore = await _db.ContractDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null).CountAsync();
|
|
||||||
rowsBefore.Should().Be(1);
|
|
||||||
|
|
||||||
contract = await _db.Contracts.FirstAsync(x => x.Id == contract.Id);
|
|
||||||
|
|
||||||
// Admin reject (skip dept block guard).
|
|
||||||
var admin = await _fx.CreateUserAsync($"adm-rej-{Guid.NewGuid():N}@test", "Admin", null, ["Admin"]);
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
contract, ContractPhase.TuChoi, admin.Id, ["Admin"],
|
|
||||||
ApprovalDecision.Reject, "reject test");
|
|
||||||
|
|
||||||
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
|
||||||
fresh.Phase.Should().Be(ContractPhase.DangSoanThao);
|
|
||||||
fresh.RejectedFromPhase.Should().Be(ContractPhase.DangGopY);
|
|
||||||
|
|
||||||
var rowsAfter = await _db.ContractDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null).CountAsync();
|
|
||||||
rowsAfter.Should().Be(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LegacyFallback_NoInnerSteps_Uses_2Stage_Logic()
|
|
||||||
{
|
|
||||||
// Không pin WorkflowDefinitionId → service fallback hardcoded Standard
|
|
||||||
// policy → no inner steps → legacy 2-stage logic kick in.
|
|
||||||
// Phase pair DangKiemTraCCM → DangTrinhKy yêu cầu role CostControl
|
|
||||||
// (Standard.Transitions). NV.CCM (role CostControl, KHÔNG DeptManager)
|
|
||||||
// → Stage=Review block.
|
|
||||||
var contract = await SeedContractAsync(ContractPhase.DangKiemTraCCM, workflowDefinitionId: null);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-legacy-ctr-{Guid.NewGuid():N}@test", "NV legacy CTR",
|
|
||||||
_deptCcm, ["CostControl"]);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
contract, ContractPhase.DangTrinhKy, nv.Id, ["CostControl"],
|
|
||||||
ApprovalDecision.Approve, "legacy review");
|
|
||||||
|
|
||||||
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
|
||||||
fresh.Phase.Should().Be(ContractPhase.DangKiemTraCCM);
|
|
||||||
|
|
||||||
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
|
||||||
rows.Should().HaveCount(1);
|
|
||||||
rows[0].InnerStepId.Should().BeNull();
|
|
||||||
rows[0].Stage.Should().Be(ApprovalStage.Review);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stub services — Contract workflow tests không cần verify changelog/codegen
|
|
||||||
// (best effort try/catch ở service đã cover fail case).
|
|
||||||
internal class FakeChangelogService : IChangelogService
|
|
||||||
{
|
|
||||||
public Task LogContractChangeAsync(Guid contractId, ChangelogAction action,
|
|
||||||
string? summary = null, string? fieldChangesJson = null, string? contextNote = null,
|
|
||||||
ContractPhase? phaseAtChange = null, CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task LogDetailChangeAsync(Guid contractId, Guid detailId, ChangelogAction action,
|
|
||||||
string? summary = null, string? fieldChangesJson = null,
|
|
||||||
ContractPhase? phaseAtChange = null, CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task LogWorkflowTransitionAsync(Guid contractId, ContractPhase fromPhase,
|
|
||||||
ContractPhase toPhase, string? comment, CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task LogCommentAddedAsync(Guid contractId, string content, ContractPhase phase,
|
|
||||||
CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task LogAttachmentAsync(Guid contractId, Guid attachmentId, ChangelogAction action,
|
|
||||||
string fileName, ContractPhase phase, CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class FakeContractCodeGenerator : IContractCodeGenerator
|
|
||||||
{
|
|
||||||
public Task<string> GenerateAsync(Contract contract, string projectCode, string supplierCode,
|
|
||||||
CancellationToken ct = default) => Task.FromResult($"FAKE-{projectCode}-{supplierCode}-001");
|
|
||||||
}
|
|
||||||
@ -1,324 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using SolutionErp.Application.Common.Exceptions;
|
|
||||||
using SolutionErp.Domain.Common;
|
|
||||||
using SolutionErp.Domain.Contracts;
|
|
||||||
using SolutionErp.Domain.Identity;
|
|
||||||
using SolutionErp.Domain.PurchaseEvaluations;
|
|
||||||
using SolutionErp.Infrastructure.Services;
|
|
||||||
using SolutionErp.Infrastructure.Tests.Common;
|
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
|
||||||
|
|
||||||
// Tests cho N-stage department approval logic (Mig 18) ở
|
|
||||||
// PurchaseEvaluationWorkflowService. Cover chuỗi inner step Order asc theo
|
|
||||||
// Department × PositionLevel. Bypass cùng dept (TP có CanBypassReview).
|
|
||||||
//
|
|
||||||
// Pattern: dùng IdentityFixture + seed WorkflowDefinition pinned to PE.
|
|
||||||
// Reuse FakeNotificationService + FixedDateTime từ PeTwoStageApprovalTests.cs.
|
|
||||||
public class PeNStageApprovalTests : IClassFixture<IdentityFixture>
|
|
||||||
{
|
|
||||||
private readonly IdentityFixture _fx;
|
|
||||||
private readonly TestApplicationDbContext _db;
|
|
||||||
private readonly UserManager<User> _userManager;
|
|
||||||
private readonly PurchaseEvaluationWorkflowService _service;
|
|
||||||
private readonly Guid _deptPro;
|
|
||||||
private readonly Guid _deptCcm;
|
|
||||||
|
|
||||||
public PeNStageApprovalTests(IdentityFixture fx)
|
|
||||||
{
|
|
||||||
_fx = fx;
|
|
||||||
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
|
|
||||||
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
|
|
||||||
|
|
||||||
_deptPro = SeedDept("PRO-NS", "Phòng Cung ứng (NS)");
|
|
||||||
_deptCcm = SeedDept("CCM-NS", "Phòng Kiểm soát (NS)");
|
|
||||||
|
|
||||||
var clock = new FixedDateTime(new DateTime(2026, 5, 7, 10, 0, 0, DateTimeKind.Utc));
|
|
||||||
var fakeNotifications = new FakeNotificationService();
|
|
||||||
|
|
||||||
_service = new PurchaseEvaluationWorkflowService(
|
|
||||||
_db, clock, fakeNotifications, _userManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Guid SeedDept(string code, string name)
|
|
||||||
{
|
|
||||||
var existing = _db.Departments.FirstOrDefault(d => d.Code == code);
|
|
||||||
if (existing is not null) return existing.Id;
|
|
||||||
var d = new SolutionErp.Domain.Master.Department { Id = Guid.NewGuid(), Code = code, Name = name };
|
|
||||||
_db.Departments.Add(d);
|
|
||||||
_db.SaveChanges();
|
|
||||||
return d.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Guid> SeedWorkflowDefinitionAsync(
|
|
||||||
params (Guid deptId, PositionLevel level)[] innerSteps)
|
|
||||||
{
|
|
||||||
// 2 step adjacent: ChoPurchasing (current, có inner steps) → ChoCCM (next).
|
|
||||||
// FromDefinition build transition (ChoPurchasing → ChoCCM) từ step[1].Approvers role.
|
|
||||||
var def = new PurchaseEvaluationWorkflowDefinition
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Code = $"NS-TEST-{Guid.NewGuid():N}".Substring(0, 20),
|
|
||||||
Version = 1,
|
|
||||||
EvaluationType = PurchaseEvaluationType.DuyetNcc,
|
|
||||||
Name = "N-stage test workflow",
|
|
||||||
IsActive = true,
|
|
||||||
ActivatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
var step1 = new PurchaseEvaluationWorkflowStep
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Order = 1,
|
|
||||||
Phase = PurchaseEvaluationPhase.ChoPurchasing,
|
|
||||||
Name = "Duyệt Purchasing",
|
|
||||||
Approvers =
|
|
||||||
{
|
|
||||||
new PurchaseEvaluationWorkflowStepApprover
|
|
||||||
{
|
|
||||||
Kind = WorkflowApproverKind.Role,
|
|
||||||
AssignmentValue = "Procurement",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
for (int i = 0; i < innerSteps.Length; i++)
|
|
||||||
{
|
|
||||||
step1.InnerSteps.Add(new PurchaseEvaluationWorkflowStepInnerStep
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Order = i + 1,
|
|
||||||
DepartmentId = innerSteps[i].deptId,
|
|
||||||
PositionLevel = innerSteps[i].level,
|
|
||||||
IsRequired = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
def.Steps.Add(step1);
|
|
||||||
// Step 2 — chỉ để FromDefinition build transition (ChoPurchasing → ChoCCM).
|
|
||||||
// KHÔNG có inner steps → nếu PE chuyển tiếp tới phase này, sẽ fallback legacy
|
|
||||||
// hoặc admin bypass (test scope chỉ chuyển tới đây 1 lần).
|
|
||||||
def.Steps.Add(new PurchaseEvaluationWorkflowStep
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Order = 2,
|
|
||||||
Phase = PurchaseEvaluationPhase.ChoCCM,
|
|
||||||
Name = "Duyệt CCM",
|
|
||||||
Approvers =
|
|
||||||
{
|
|
||||||
new PurchaseEvaluationWorkflowStepApprover
|
|
||||||
{
|
|
||||||
Kind = WorkflowApproverKind.Role,
|
|
||||||
AssignmentValue = "Procurement", // mirror step 1 để policy guard accept actor cùng role
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
_db.PurchaseEvaluationWorkflowDefinitions.Add(def);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return def.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<PurchaseEvaluation> SeedPeAsync(
|
|
||||||
PurchaseEvaluationPhase phase,
|
|
||||||
Guid? workflowDefinitionId = null,
|
|
||||||
Guid? projectId = null)
|
|
||||||
{
|
|
||||||
var pid = projectId ?? Guid.NewGuid();
|
|
||||||
if (!_db.Projects.Any(p => p.Id == pid))
|
|
||||||
{
|
|
||||||
_db.Projects.Add(new SolutionErp.Domain.Master.Project
|
|
||||||
{
|
|
||||||
Id = pid,
|
|
||||||
Code = $"PRJ-NS-{Random.Shared.Next(10000):D4}",
|
|
||||||
Name = "Test project NS",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var pe = new PurchaseEvaluation
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Type = PurchaseEvaluationType.DuyetNcc,
|
|
||||||
Phase = phase,
|
|
||||||
TenGoiThau = "Test gói thầu NS",
|
|
||||||
ProjectId = pid,
|
|
||||||
WorkflowDefinitionId = workflowDefinitionId,
|
|
||||||
};
|
|
||||||
_db.PurchaseEvaluations.Add(pe);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return pe;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_FirstInner_NV_Approve_Blocks_Phase_Transition()
|
|
||||||
{
|
|
||||||
// Arrange: 1 dept (PRO) × 3 cấp.
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong),
|
|
||||||
(_deptPro, PositionLevel.TruongPhong));
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-pro-ns-{Guid.NewGuid():N}@test", "NV PRO NS",
|
|
||||||
_deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
|
|
||||||
// Act: NV.PRO duyệt cấp 1 (NV).
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "duyệt NV");
|
|
||||||
|
|
||||||
// Assert: phase chưa đổi (còn 2 cấp PP+TP), 1 row InnerStepId set.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
rows.Should().HaveCount(1);
|
|
||||||
rows[0].InnerStepId.Should().NotBeNull();
|
|
||||||
rows[0].IsBypassed.Should().BeFalse();
|
|
||||||
rows[0].ApproverRoleSnapshot.Should().Contain("NhanVien");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_All_3_Levels_Sequential_Pass_Allow_Phase_Transition()
|
|
||||||
{
|
|
||||||
// Arrange: 1 dept × 3 cấp.
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong),
|
|
||||||
(_deptPro, PositionLevel.TruongPhong));
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync($"nv-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
var pp = await _fx.CreateUserAsync($"pp-{Guid.NewGuid():N}@test", "PP", _deptPro, ["Procurement"], positionLevel: PositionLevel.PhoPhong);
|
|
||||||
var tp = await _fx.CreateUserAsync($"tp-{Guid.NewGuid():N}@test", "TP", _deptPro, ["Procurement"], positionLevel: PositionLevel.TruongPhong);
|
|
||||||
|
|
||||||
// Act: lần lượt NV → PP → TP.
|
|
||||||
await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV");
|
|
||||||
pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id);
|
|
||||||
await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, pp.Id, ["Procurement"], ApprovalDecision.Approve, "PP");
|
|
||||||
pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id);
|
|
||||||
await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, tp.Id, ["Procurement"], ApprovalDecision.Approve, "TP");
|
|
||||||
|
|
||||||
// Assert: phase chuyển + 3 rows + KHÔNG bypass.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null).ToListAsync();
|
|
||||||
rows.Should().HaveCount(3);
|
|
||||||
rows.All(r => !r.IsBypassed).Should().BeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_TP_Bypass_Skips_Lower_Levels_Same_Dept()
|
|
||||||
{
|
|
||||||
// Arrange: 1 dept × 3 cấp. TP có CanBypassReview=true.
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong),
|
|
||||||
(_deptPro, PositionLevel.TruongPhong));
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId);
|
|
||||||
|
|
||||||
var tp = await _fx.CreateUserAsync(
|
|
||||||
$"tp-bypass-{Guid.NewGuid():N}@test", "TP bypass",
|
|
||||||
_deptPro, ["Procurement"],
|
|
||||||
canBypassReview: true, positionLevel: PositionLevel.TruongPhong);
|
|
||||||
|
|
||||||
// Act: TP bypass approve trực tiếp (skip NV+PP cùng dept).
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, tp.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "TP bypass");
|
|
||||||
|
|
||||||
// Assert: phase chuyển, 3 rows (NV+PP=bypass true, TP=bypass false).
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null)
|
|
||||||
.ToListAsync();
|
|
||||||
rows.Should().HaveCount(3);
|
|
||||||
rows.Count(r => r.IsBypassed).Should().Be(2); // NV + PP bypassed
|
|
||||||
rows.Count(r => !r.IsBypassed).Should().Be(1); // TP exact match
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_Wrong_Department_Throws_Forbidden()
|
|
||||||
{
|
|
||||||
// Arrange: inner step yêu cầu dept PRO. Actor thuộc CCM.
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien));
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId);
|
|
||||||
|
|
||||||
var ccmActor = await _fx.CreateUserAsync(
|
|
||||||
$"ccm-wrong-{Guid.NewGuid():N}@test", "CCM wrong",
|
|
||||||
_deptCcm, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
|
|
||||||
// Act + Assert.
|
|
||||||
var act = async () => await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, ccmActor.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "wrong dept");
|
|
||||||
await act.Should().ThrowAsync<ForbiddenException>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NStage_Reject_Clears_InnerStep_Rows_At_Phase()
|
|
||||||
{
|
|
||||||
// Arrange: NV approve trước → 1 row N-stage. Sau đó reject.
|
|
||||||
var defId = await SeedWorkflowDefinitionAsync(
|
|
||||||
(_deptPro, PositionLevel.NhanVien),
|
|
||||||
(_deptPro, PositionLevel.PhoPhong));
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync($"nv-rej-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
|
||||||
await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV");
|
|
||||||
|
|
||||||
var rowsBeforeReject = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null).CountAsync();
|
|
||||||
rowsBeforeReject.Should().Be(1);
|
|
||||||
|
|
||||||
pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id);
|
|
||||||
|
|
||||||
// Act: admin "Trả lại" (target=DangSoanThao + decision=Reject Session 14).
|
|
||||||
var admin = await _fx.CreateUserAsync($"adm-rej-{Guid.NewGuid():N}@test", "Admin", null, ["Admin"]);
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.DangSoanThao, admin.Id, ["Admin"],
|
|
||||||
ApprovalDecision.Reject, "trả lại test");
|
|
||||||
|
|
||||||
// Assert: phase = DangSoanThao, RejectedFromPhase = ChoPurchasing,
|
|
||||||
// N-stage rows tại ChoPurchasing đã clear.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
|
||||||
fresh.RejectedFromPhase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
var rowsAfterReject = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null).CountAsync();
|
|
||||||
rowsAfterReject.Should().Be(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LegacyFallback_NoInnerSteps_Uses_2Stage_Logic()
|
|
||||||
{
|
|
||||||
// Arrange: KHÔNG pin WorkflowDefinitionId → service fallback hardcoded
|
|
||||||
// policy → no inner steps → legacy 2-stage logic kick in.
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, workflowDefinitionId: null);
|
|
||||||
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-legacy-{Guid.NewGuid():N}@test", "NV legacy",
|
|
||||||
_deptPro, ["Procurement"]); // KHÔNG có positionLevel — legacy không cần
|
|
||||||
|
|
||||||
// Act: NV approve → legacy 2-stage Stage=Review row.
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "legacy review");
|
|
||||||
|
|
||||||
// Assert: phase chưa đổi (NV chỉ Review), 1 row InnerStepId=NULL (legacy).
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
rows.Should().HaveCount(1);
|
|
||||||
rows[0].InnerStepId.Should().BeNull();
|
|
||||||
rows[0].Stage.Should().Be(ApprovalStage.Review);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,274 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using SolutionErp.Application.Notifications;
|
|
||||||
using SolutionErp.Domain.Common;
|
|
||||||
using SolutionErp.Domain.Contracts;
|
|
||||||
using SolutionErp.Domain.Identity;
|
|
||||||
using SolutionErp.Domain.Notifications;
|
|
||||||
using SolutionErp.Domain.PurchaseEvaluations;
|
|
||||||
using SolutionErp.Infrastructure.Services;
|
|
||||||
using SolutionErp.Infrastructure.Tests.Common;
|
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
|
||||||
|
|
||||||
// Tests cho 2-stage department approval logic ở PurchaseEvaluationWorkflowService.
|
|
||||||
// Cover bug fix anh Kiệt: NV.PRO duyệt phase ChoPurchasing → BLOCK transition.
|
|
||||||
// TPB.PRO confirm → ALLOW transition.
|
|
||||||
//
|
|
||||||
// Pattern: dùng IdentityFixture (Identity stack + DbContext SQLite) để
|
|
||||||
// test thật end-to-end service thay vì mock.
|
|
||||||
public class PeTwoStageApprovalTests : IClassFixture<IdentityFixture>
|
|
||||||
{
|
|
||||||
private readonly IdentityFixture _fx;
|
|
||||||
private readonly TestApplicationDbContext _db;
|
|
||||||
private readonly UserManager<User> _userManager;
|
|
||||||
private readonly PurchaseEvaluationWorkflowService _service;
|
|
||||||
private readonly Guid _deptPro;
|
|
||||||
private readonly Guid _deptCcm;
|
|
||||||
|
|
||||||
public PeTwoStageApprovalTests(IdentityFixture fx)
|
|
||||||
{
|
|
||||||
_fx = fx;
|
|
||||||
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
|
|
||||||
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
|
|
||||||
|
|
||||||
// Seed 2 departments (idempotent — check trước khi insert vì fixture
|
|
||||||
// shared across tests trong class).
|
|
||||||
_deptPro = SeedDept("PRO", "Phòng Cung ứng");
|
|
||||||
_deptCcm = SeedDept("CCM", "Phòng Kiểm soát chi phí");
|
|
||||||
|
|
||||||
var clock = new FixedDateTime(new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc));
|
|
||||||
var fakeNotifications = new FakeNotificationService();
|
|
||||||
|
|
||||||
_service = new PurchaseEvaluationWorkflowService(
|
|
||||||
_db,
|
|
||||||
clock,
|
|
||||||
fakeNotifications,
|
|
||||||
_userManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Guid SeedDept(string code, string name)
|
|
||||||
{
|
|
||||||
var existing = _db.Departments.FirstOrDefault(d => d.Code == code);
|
|
||||||
if (existing is not null) return existing.Id;
|
|
||||||
var d = new SolutionErp.Domain.Master.Department { Id = Guid.NewGuid(), Code = code, Name = name };
|
|
||||||
_db.Departments.Add(d);
|
|
||||||
_db.SaveChanges();
|
|
||||||
return d.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<PurchaseEvaluation> SeedPeAsync(PurchaseEvaluationPhase phase, Guid? projectId = null)
|
|
||||||
{
|
|
||||||
// Project required by FK constraint.
|
|
||||||
var pid = projectId ?? Guid.NewGuid();
|
|
||||||
if (!_db.Projects.Any(p => p.Id == pid))
|
|
||||||
{
|
|
||||||
_db.Projects.Add(new SolutionErp.Domain.Master.Project
|
|
||||||
{
|
|
||||||
Id = pid,
|
|
||||||
Code = $"PRJ-{Random.Shared.Next(10000):D4}",
|
|
||||||
Name = "Test project",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var pe = new PurchaseEvaluation
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Type = PurchaseEvaluationType.DuyetNcc,
|
|
||||||
Phase = phase,
|
|
||||||
TenGoiThau = "Test gói thầu",
|
|
||||||
ProjectId = pid,
|
|
||||||
};
|
|
||||||
_db.PurchaseEvaluations.Add(pe);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return pe;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NV_Review_Blocks_Phase_Transition()
|
|
||||||
{
|
|
||||||
// Arrange: NV.PRO (role Procurement, dept PRO, NOT DeptManager).
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
// Act: NV approve to ChoCCM.
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "review");
|
|
||||||
|
|
||||||
// Assert: phase KHÔNG đổi, có 1 row Stage=Review.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
deptApprovals.Should().HaveCount(1);
|
|
||||||
deptApprovals[0].Stage.Should().Be(ApprovalStage.Review);
|
|
||||||
deptApprovals[0].DepartmentId.Should().Be(_deptPro);
|
|
||||||
deptApprovals[0].IsBypassed.Should().BeFalse();
|
|
||||||
|
|
||||||
var approvals = await _db.PurchaseEvaluationApprovals
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
approvals.Should().HaveCount(1);
|
|
||||||
approvals[0].Comment.Should().StartWith("[Review NV]");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TPB_Confirm_After_NV_Review_Allows_Transition()
|
|
||||||
{
|
|
||||||
// Arrange: NV review trước, sau đó TPB confirm.
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]);
|
|
||||||
var tpb = await _fx.CreateUserAsync(
|
|
||||||
$"tpb-{Guid.NewGuid():N}@test", "TPB PRO", _deptPro, ["DeptManager", "Procurement"]);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "review NV");
|
|
||||||
|
|
||||||
// Re-fetch tracked entity (service modifies state ở Phase prior).
|
|
||||||
pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id);
|
|
||||||
|
|
||||||
// Act: TPB confirm.
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, tpb.Id, ["DeptManager", "Procurement"],
|
|
||||||
ApprovalDecision.Approve, "confirm TPB");
|
|
||||||
|
|
||||||
// Assert: phase đổi, có 2 row (Review + Confirm).
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
deptApprovals.Should().HaveCount(2);
|
|
||||||
deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Review && a.ApproverUserId == nv.Id);
|
|
||||||
deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Confirm && a.ApproverUserId == tpb.Id && !a.IsBypassed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task NV_With_BypassReview_Allows_Transition_With_IsBypassed_True()
|
|
||||||
{
|
|
||||||
// Arrange: NV CanBypassReview=true.
|
|
||||||
var nv = await _fx.CreateUserAsync(
|
|
||||||
$"nv-bypass-{Guid.NewGuid():N}@test", "NV PRO bypass",
|
|
||||||
_deptPro, ["Procurement"], canBypassReview: true);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
// Act: bypass user approve → đẩy thẳng Stage=Confirm.
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"],
|
|
||||||
ApprovalDecision.Approve, "bypass approve");
|
|
||||||
|
|
||||||
// Assert: phase đổi, có 1 row Stage=Confirm + IsBypassed=true.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
deptApprovals.Should().HaveCount(1);
|
|
||||||
deptApprovals[0].Stage.Should().Be(ApprovalStage.Confirm);
|
|
||||||
deptApprovals[0].IsBypassed.Should().BeTrue();
|
|
||||||
deptApprovals[0].ApproverRoleSnapshot.Should().Be("NV(bypass)");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Admin_Skips_TwoStage_Logic_Entirely()
|
|
||||||
{
|
|
||||||
// Arrange: Admin role.
|
|
||||||
var admin = await _fx.CreateUserAsync(
|
|
||||||
$"admin-{Guid.NewGuid():N}@test", "Admin user", null, ["Admin"]);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing);
|
|
||||||
|
|
||||||
// Act: Admin approve.
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoCCM, admin.Id, ["Admin"],
|
|
||||||
ApprovalDecision.Approve, "admin force");
|
|
||||||
|
|
||||||
// Assert: phase đổi, KHÔNG có DepartmentApproval row.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals
|
|
||||||
.Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync();
|
|
||||||
deptApprovals.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reject_To_DangSoanThao_Sets_RejectedFromPhase_TraLai()
|
|
||||||
{
|
|
||||||
// Session 14: "Trả lại" semantic — target=DangSoanThao + decision=Reject.
|
|
||||||
// Service set RejectedFromPhase + force về DangSoanThao + Drafter resume jump-back.
|
|
||||||
var actor = await _fx.CreateUserAsync(
|
|
||||||
$"ccm-{Guid.NewGuid():N}@test", "CCM TPB", _deptCcm, ["DeptManager", "CostControl"]);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.DangSoanThao, actor.Id, ["DeptManager", "CostControl"],
|
|
||||||
ApprovalDecision.Reject, "trả lại Drafter sửa");
|
|
||||||
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
|
||||||
fresh.RejectedFromPhase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reject_To_TuChoi_Locks_Permanently_No_RejectedFromPhase()
|
|
||||||
{
|
|
||||||
// Session 14: "Từ chối" semantic — target=TuChoi + decision=Reject.
|
|
||||||
// Service KHÔNG override target + KHÔNG set RejectedFromPhase (phiếu khoá vĩnh viễn).
|
|
||||||
var actor = await _fx.CreateUserAsync(
|
|
||||||
$"ccm-{Guid.NewGuid():N}@test", "CCM TPB cancel", _deptCcm, ["DeptManager", "CostControl"]);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.TuChoi, actor.Id, ["DeptManager", "CostControl"],
|
|
||||||
ApprovalDecision.Reject, "từ chối hoàn toàn");
|
|
||||||
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.TuChoi);
|
|
||||||
fresh.RejectedFromPhase.Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Resume_After_Reject_Jumps_Back_To_RejectedPhase()
|
|
||||||
{
|
|
||||||
// Arrange: PE rejected từ ChoCCM, đang ở DangSoanThao + RejectedFromPhase=ChoCCM.
|
|
||||||
var drafter = await _fx.CreateUserAsync(
|
|
||||||
$"drafter-{Guid.NewGuid():N}@test", "Drafter", _deptPro, ["Drafter"]);
|
|
||||||
var pe = await SeedPeAsync(PurchaseEvaluationPhase.DangSoanThao);
|
|
||||||
pe.RejectedFromPhase = PurchaseEvaluationPhase.ChoCCM;
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Act: drafter trình lại từ DangSoanThao → ChoPurchasing (target không
|
|
||||||
// quan trọng vì resume sẽ override = RejectedFromPhase). Note: service
|
|
||||||
// jump tới ChoCCM, nhưng nếu actor có dept thì sẽ hit 2-stage logic.
|
|
||||||
// Simpler: dùng admin để bypass 2-stage gate khi resume cũng OK.
|
|
||||||
var admin = await _fx.CreateUserAsync(
|
|
||||||
$"admin-resume-{Guid.NewGuid():N}@test", "Admin resume", null, ["Admin"]);
|
|
||||||
await _service.TransitionAsync(
|
|
||||||
pe, PurchaseEvaluationPhase.ChoPurchasing, admin.Id, ["Admin"],
|
|
||||||
ApprovalDecision.Approve, "drafter resume");
|
|
||||||
|
|
||||||
// Assert: phase jump tới ChoCCM (không phải ChoPurchasing target),
|
|
||||||
// RejectedFromPhase=null.
|
|
||||||
var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
|
||||||
fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM);
|
|
||||||
fresh.RejectedFromPhase.Should().BeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stub notification service — tests không cần verify notification path
|
|
||||||
// (best effort try/catch ở service đã cover fail case).
|
|
||||||
internal class FakeNotificationService : INotificationService
|
|
||||||
{
|
|
||||||
public Task NotifyAsync(Guid userId, NotificationType type, string title,
|
|
||||||
string? description = null, string? href = null, Guid? refId = null,
|
|
||||||
CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task NotifyManyAsync(IEnumerable<Guid> userIds, NotificationType type,
|
|
||||||
string title, string? description = null, string? href = null,
|
|
||||||
Guid? refId = null, CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user