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

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

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

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

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

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

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

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

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

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

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

View File

@ -17,26 +17,19 @@ public record WorkflowStepApproverDto(
string AssignmentValue,
string? DisplayName); // resolved role label or user fullName
// Mig 20N-stage approval inner step level con (mirror PE Mig 18)
public record WorkflowStepInnerStepDto(
Guid Id,
int Order,
Guid DepartmentId,
string? DepartmentName,
int PositionLevel, // 1=NV, 2=PP, 3=TP
string? Name,
int? SlaDays,
bool IsRequired);
// Mig 21flat workflow step. Mỗi step = 1 (Phòng × Cấp + Approvers users)
// sequential. Service iterate theo Order, advance Contract.CurrentWorkflowStepIndex.
public record WorkflowStepDto(
Guid Id,
int Order,
int Phase,
int Phase, // [DEPRECATED post-Mig 21] dùng ChoDuyet=10 cho new
string PhaseLabel,
string Name,
string Name, // "Phòng A — Cấp 1"
int? SlaDays,
List<WorkflowStepApproverDto> Approvers,
List<WorkflowStepInnerStepDto> InnerSteps);
Guid? DepartmentId, // Mig 21
string? DepartmentName, // resolved display
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
List<WorkflowStepApproverDto> Approvers);
public record WorkflowDefinitionDto(
Guid Id,
@ -96,15 +89,15 @@ public class GetWorkflowAdminOverviewQueryHandler(
var definitions = 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))
.OrderByDescending(d => d.Version)
.ToListAsync(ct);
// Resolve dept names cho InnerStep.DepartmentName display (Mig 20)
// Resolve dept names cho step.DepartmentId display (Mig 21)
var deptIds = definitions
.SelectMany(d => d.Steps).SelectMany(s => s.InnerSteps)
.Select(i => i.DepartmentId).Distinct().ToList();
.SelectMany(d => d.Steps)
.Where(s => s.DepartmentId != null)
.Select(s => s.DepartmentId!.Value)
.Distinct().ToList();
var deptNames = deptIds.Count == 0
? new Dictionary<Guid, string>()
: await db.Departments.AsNoTracking()
@ -151,19 +144,13 @@ public class GetWorkflowAdminOverviewQueryHandler(
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
s.Name,
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(
(int)a.Kind,
a.AssignmentValue,
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()
ResolveDisplay(a, userNames))).ToList()
)).ToList());
var types = Enum.GetValues<ContractType>()
@ -193,23 +180,16 @@ public class GetWorkflowAdminOverviewQueryHandler(
public record CreateWorkflowStepApproverInput(int Kind, string AssignmentValue);
// Mig 20Inner step input cho designer N-stage. InnerSteps optional empty
// list → service fallback 2-stage Review/Confirm logic legacy Mig 16.
public record CreateWorkflowStepInnerStepInput(
int Order,
Guid DepartmentId,
int PositionLevel, // 1=NV, 2=PP, 3=TP
string? Name,
int? SlaDays,
bool IsRequired);
// Mig 21flat workflow step input. DeptId + PositionLevel = Phòng × Cấp.
// Phase auto-assign ChoDuyet=10 cho new definitions (legacy phase-specific deprecated).
public record CreateWorkflowStepInput(
int Order,
int Phase,
int Phase, // [DEPRECATED] caller có thể truyền 10=ChoDuyet hoặc legacy enum value
string Name,
int? SlaDays,
List<CreateWorkflowStepApproverInput> Approvers,
List<CreateWorkflowStepInnerStepInput>? InnerSteps = null);
Guid? DepartmentId, // Mig 21
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
List<CreateWorkflowStepApproverInput> Approvers);
public record CreateWorkflowDefinitionCommand(
ContractType ContractType,
@ -233,25 +213,18 @@ public class CreateWorkflowDefinitionCommandValidator : AbstractValidator<Create
RuleForEach(x => x.Steps).ChildRules(step =>
{
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.SlaDays).GreaterThanOrEqualTo(0)
.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 =>
{
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
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,
Name = s.Name,
SlaDays = s.SlaDays,
DepartmentId = s.DepartmentId,
PositionLevel = s.PositionLevel != null ? (PositionLevel)s.PositionLevel : null,
Approvers = s.Approvers.Select(a => new WorkflowStepApprover
{
Kind = (WorkflowApproverKind)a.Kind,
AssignmentValue = a.AssignmentValue,
}).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(),
};

View File

@ -18,27 +18,18 @@ public record PeWorkflowStepApproverDto(
string AssignmentValue,
string? DisplayName);
// Mig 18N-stage approval inner step level con. Cấu hình động trong cùng
// 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);
// Mig 21 — flat workflow step. Mỗi step = 1 (Phòng × Cấp + Approvers users).
public record PeWorkflowStepDto(
Guid Id,
int Order,
int Phase,
int Phase, // [DEPRECATED post-Mig 21] dùng ChoDuyet=10
string PhaseLabel,
string Name,
string Name, // "Phòng A — Cấp 1"
int? SlaDays,
List<PeWorkflowStepApproverDto> Approvers,
List<PeWorkflowStepInnerStepDto> InnerSteps);
Guid? DepartmentId, // Mig 21
string? DepartmentName,
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
List<PeWorkflowStepApproverDto> Approvers);
public record PeWorkflowDefinitionDto(
Guid Id,
@ -92,15 +83,15 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
var definitions = 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))
.OrderByDescending(d => d.Version)
.ToListAsync(ct);
// Resolve dept names cho InnerStep.DepartmentName display
// Resolve dept names cho step.DepartmentId display (Mig 21 flat)
var deptIds = definitions
.SelectMany(d => d.Steps).SelectMany(s => s.InnerSteps)
.Select(i => i.DepartmentId).Distinct().ToList();
.SelectMany(d => d.Steps)
.Where(s => s.DepartmentId != null)
.Select(s => s.DepartmentId!.Value)
.Distinct().ToList();
var deptNames = deptIds.Count == 0
? new Dictionary<Guid, string>()
: await db.Departments.AsNoTracking()
@ -147,19 +138,13 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
s.Name,
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(
(int)a.Kind,
a.AssignmentValue,
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()
ResolveDisplay(a, userNames))).ToList()
)).ToList());
var types = Enum.GetValues<PurchaseEvaluationType>()
@ -189,23 +174,15 @@ public class GetPeWorkflowAdminOverviewQueryHandler(
public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue);
// Mig 18Inner step input cho designer N-stage. InnerSteps optional empty
// 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);
// Mig 21 — flat workflow step input. DeptId + PositionLevel = Phòng × Cấp.
public record CreatePeWorkflowStepInput(
int Order,
int Phase,
int Phase, // [DEPRECATED] caller có thể truyền 10=ChoDuyet hoặc legacy
string Name,
int? SlaDays,
List<CreatePeWorkflowStepApproverInput> Approvers,
List<CreatePeWorkflowStepInnerStepInput>? InnerSteps = null);
Guid? DepartmentId, // Mig 21
int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP
List<CreatePeWorkflowStepApproverInput> Approvers);
public record CreatePeWorkflowDefinitionCommand(
PurchaseEvaluationType EvaluationType,
@ -240,16 +217,9 @@ public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator<Crea
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
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);
step.RuleFor(s => s.PositionLevel).InclusiveBetween(1, 3)
.When(s => s.PositionLevel != null)
.WithMessage("PositionLevel: 1=NV, 2=PP, 3=TP.");
});
}
}
@ -287,20 +257,13 @@ public class CreatePeWorkflowDefinitionCommandHandler(IApplicationDbContext db)
Phase = (PurchaseEvaluationPhase)s.Phase,
Name = s.Name,
SlaDays = s.SlaDays,
DepartmentId = s.DepartmentId,
PositionLevel = s.PositionLevel != null ? (PositionLevel)s.PositionLevel : null,
Approvers = s.Approvers.Select(a => new PurchaseEvaluationWorkflowStepApprover
{
Kind = (WorkflowApproverKind)a.Kind,
AssignmentValue = a.AssignmentValue,
}).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(),
};