From dbb0089e286aa1976e0d14cdec82be15563f0ca6 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 8 May 2026 12:04:51 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Drastic=20refactor:=20flat=20workflo?= =?UTF-8?q?w=20Ph=C3=B2ng=20=C3=97=20C=E1=BA=A5p=20+=20Migration=2021=20(C?= =?UTF-8?q?hunk=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Contracts/WorkflowAdminFeatures.cs | 92 +- .../PeWorkflowAdminFeatures.cs | 89 +- .../SolutionErp.Domain/Contracts/Contract.cs | 10 +- .../Contracts/ContractDepartmentApproval.cs | 4 - .../Contracts/ContractPhase.cs | 29 +- .../Contracts/WorkflowDefinition.cs | 43 +- .../PurchaseEvaluations/PurchaseEvaluation.cs | 8 + .../PurchaseEvaluationDepartmentApproval.cs | 6 - .../PurchaseEvaluationPhase.cs | 28 +- .../PurchaseEvaluationWorkflowDefinition.cs | 51 +- .../Persistence/ApplicationDbContext.cs | 2 - .../DepartmentApprovalsConfiguration.cs | 35 +- .../PurchaseEvaluationConfiguration.cs | 39 +- .../WorkflowDefinitionConfiguration.cs | 36 +- ...43_RefactorWorkflowToFlatModel.Designer.cs | 3618 +++++++++++++++++ ...60508050243_RefactorWorkflowToFlatModel.cs | 361 ++ .../ApplicationDbContextModelSnapshot.cs | 210 +- .../Services/ContractWorkflowService.cs | 504 +-- .../PurchaseEvaluationWorkflowService.cs | 501 +-- .../Application/PeWorkflowAdminTests.cs | 10 +- .../Services/ContractNStageApprovalTests.cs | 350 -- .../Services/PeNStageApprovalTests.cs | 324 -- .../Services/PeTwoStageApprovalTests.cs | 274 -- 23 files changed, 4501 insertions(+), 2123 deletions(-) create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.cs delete mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs delete mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs delete mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs diff --git a/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs index 2a3725f..f5ba935 100644 --- a/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs @@ -17,26 +17,19 @@ public record WorkflowStepApproverDto( string AssignmentValue, string? DisplayName); // resolved role label or user fullName -// Mig 20 — N-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 21 — flat 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 Approvers, - List InnerSteps); + Guid? DepartmentId, // Mig 21 + string? DepartmentName, // resolved display + int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP + List 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() : 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() @@ -193,23 +180,16 @@ public class GetWorkflowAdminOverviewQueryHandler( public record CreateWorkflowStepApproverInput(int Kind, string AssignmentValue); -// Mig 20 — Inner 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 21 — flat 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 Approvers, - List? InnerSteps = null); + Guid? DepartmentId, // Mig 21 + int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP + List Approvers); public record CreateWorkflowDefinitionCommand( ContractType ContractType, @@ -233,25 +213,18 @@ public class CreateWorkflowDefinitionCommandValidator : AbstractValidator 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(), }; diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs index 1454e61..9e9143c 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs @@ -18,27 +18,18 @@ public record PeWorkflowStepApproverDto( string AssignmentValue, string? DisplayName); -// Mig 18 — N-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 Approvers, - List InnerSteps); + Guid? DepartmentId, // Mig 21 + string? DepartmentName, + int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP + List 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() : 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() @@ -189,23 +174,15 @@ public class GetPeWorkflowAdminOverviewQueryHandler( public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue); -// Mig 18 — Inner 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 Approvers, - List? InnerSteps = null); + Guid? DepartmentId, // Mig 21 + int? PositionLevel, // Mig 21 — 1=NV, 2=PP, 3=TP + List Approvers); public record CreatePeWorkflowDefinitionCommand( PurchaseEvaluationType EvaluationType, @@ -240,16 +217,9 @@ public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator 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(), }; diff --git a/src/Backend/SolutionErp.Domain/Contracts/Contract.cs b/src/Backend/SolutionErp.Domain/Contracts/Contract.cs index b015e8f..b9bcfa8 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/Contract.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/Contract.cs @@ -30,11 +30,15 @@ public class Contract : AuditableEntity public string? BudgetManualName { get; set; } // Tên tham chiếu 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 - // 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. + // Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. 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 Approvals { get; set; } = new(); public List Comments { get; set; } = new(); public List Attachments { get; set; } = new(); diff --git a/src/Backend/SolutionErp.Domain/Contracts/ContractDepartmentApproval.cs b/src/Backend/SolutionErp.Domain/Contracts/ContractDepartmentApproval.cs index 0895fea..9b6bcd6 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/ContractDepartmentApproval.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/ContractDepartmentApproval.cs @@ -23,9 +23,5 @@ public class ContractDepartmentApproval : AuditableEntity public DateTime ApprovedAt { get; set; } 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; } } diff --git a/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs b/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs index bff95f3..4f9f288 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs @@ -1,16 +1,25 @@ 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 { - DangChon = 1, + DangChon = 1, // [LEGACY] DangSoanThao = 2, - DangGopY = 3, - DangDamPhan = 4, - DangInKy = 5, - DangKiemTraCCM = 6, - DangTrinhKy = 7, - DangDongDau = 8, - DaPhatHanh = 9, - TuChoi = 99, + DangGopY = 3, // [LEGACY] + DangDamPhan = 4, // [LEGACY] + DangInKy = 5, // [LEGACY] + DangKiemTraCCM = 6, // [LEGACY] + DangTrinhKy = 7, // [LEGACY] + DangDongDau = 8, // [LEGACY] + DaPhatHanh = 9, // terminal thành công (= DaDuyet cho HĐ) + ChoDuyet = 10, // [Mig 21] generic intermediate, dùng CurrentWorkflowStepIndex tracking + TuChoi = 99, // terminal khoá } diff --git a/src/Backend/SolutionErp.Domain/Contracts/WorkflowDefinition.cs b/src/Backend/SolutionErp.Domain/Contracts/WorkflowDefinition.cs index ccd0a72..eb6d51c 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/WorkflowDefinition.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/WorkflowDefinition.cs @@ -23,21 +23,28 @@ public class WorkflowDefinition : BaseEntity public List 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 Guid WorkflowDefinitionId { get; set; } public int Order { get; set; } // 1-based sequence - public ContractPhase Phase { get; set; } // which ContractPhase this step represents - public string Name { get; set; } = string.Empty; // display, can differ from Phase label - public int? SlaDays { get; set; } // null = no SLA for this step + public ContractPhase Phase { get; set; } // [DEPRECATED post-Mig 21] dùng ChoDuyet=10 cho new + public string Name { get; set; } = string.Empty; // display "Phòng A — Cấp 1" + 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 List 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 InnerSteps { get; set; } = new(); } public enum WorkflowApproverKind @@ -54,23 +61,3 @@ public class WorkflowStepApprover : BaseEntity 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; } -} diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs index 450e215..17cd4d9 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs @@ -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ự. 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 Suppliers { get; set; } = new(); public List Details { get; set; } = new(); public List Quotes { get; set; } = new(); diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentApproval.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentApproval.cs index 90e4db3..4036db5 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentApproval.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentApproval.cs @@ -22,11 +22,5 @@ public class PurchaseEvaluationDepartmentApproval : AuditableEntity public DateTime ApprovedAt { 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; } } diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs index f18dd54..c69241b 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs @@ -1,21 +1,25 @@ namespace SolutionErp.Domain.PurchaseEvaluations; -// State machine cho phiếu Duyệt NCC (tiền-HĐ). 2 workflow khác nhau cùng -// share state space này — A (DuyetNcc) dùng subset, B (DuyetNccPhuongAn) -// dùng full. +// State machine PE — Session 16 drastic refactor (Mig 21): +// DangSoanThao → ChoDuyet (Drafter trình, init CurrentWorkflowStepIndex=0) +// 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 -// B: DangSoanThao → ChoPurchasing → ChoDuAn → ChoCCM → ChoCEODuyetPA → ChoCEODuyetNCC → DaDuyet -// Cả 2: từ DangSoanThao có thể → TuChoi; từ mọi phase duyệt reject → DangSoanThao. +// LEGACY values 2-6 + 98 deprecated post-Mig 21 (data cũ vẫn đọc OK, +// new workflow definitions chỉ dùng DangSoanThao/ChoDuyet/DaDuyet/TuChoi). public enum PurchaseEvaluationPhase { DangSoanThao = 1, - ChoPurchasing = 2, - ChoDuAn = 3, // chỉ B - ChoCCM = 4, - ChoCEODuyetPA = 5, // chỉ B (duyệt phương án trước) - ChoCEODuyetNCC = 6, // chung cả A & B — duyệt chọn đơn vị + ChoPurchasing = 2, // [LEGACY] deprecated + ChoDuAn = 3, // [LEGACY] deprecated + ChoCCM = 4, // [LEGACY] deprecated + ChoCEODuyetPA = 5, // [LEGACY] deprecated + ChoCEODuyetNCC = 6, // [LEGACY] deprecated 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 } diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationWorkflowDefinition.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationWorkflowDefinition.cs index ba97469..8c3ef11 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationWorkflowDefinition.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationWorkflowDefinition.cs @@ -4,17 +4,12 @@ using SolutionErp.Domain.Identity; // reuse PositionLevel namespace SolutionErp.Domain.PurchaseEvaluations; -// 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 -// phải ContractPhase). Admin có UI /system/pe-workflows/:typeCode tương -// 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. +// Versioned workflow definition cho module Duyệt NCC — pattern giống HĐ. +// Invariant: AT MOST ONE IsActive=true per PurchaseEvaluationType. +// PurchaseEvaluation.WorkflowDefinitionId pin tại create. 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 PurchaseEvaluationType EvaluationType { get; set; } public string Name { get; set; } = string.Empty; @@ -25,22 +20,23 @@ public class PurchaseEvaluationWorkflowDefinition : BaseEntity public List 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 Guid PurchaseEvaluationWorkflowDefinitionId { get; set; } public int Order { get; set; } - public PurchaseEvaluationPhase Phase { get; set; } - public string Name { get; set; } = string.Empty; + public PurchaseEvaluationPhase Phase { get; set; } // [DEPRECATED] dùng ChoDuyet=10 cho new + public string Name { get; set; } = string.Empty; // "Phòng A — Cấp 1" 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 List 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 InnerSteps { get; set; } = new(); } public class PurchaseEvaluationWorkflowStepApprover : BaseEntity @@ -51,24 +47,3 @@ public class PurchaseEvaluationWorkflowStepApprover : BaseEntity 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; } -} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs index 0c71dc8..0619055 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs @@ -41,7 +41,6 @@ public class ApplicationDbContext public DbSet WorkflowDefinitions => Set(); public DbSet WorkflowSteps => Set(); public DbSet WorkflowStepApprovers => Set(); - public DbSet WorkflowStepInnerSteps => Set(); public DbSet ThauPhuDetails => Set(); public DbSet GiaoKhoanDetails => Set(); public DbSet NhaCungCapDetails => Set(); @@ -60,7 +59,6 @@ public class ApplicationDbContext public DbSet PurchaseEvaluationWorkflowDefinitions => Set(); public DbSet PurchaseEvaluationWorkflowSteps => Set(); public DbSet PurchaseEvaluationWorkflowStepApprovers => Set(); - public DbSet PurchaseEvaluationWorkflowStepInnerSteps => Set(); public DbSet PurchaseEvaluationCodeSequences => Set(); public DbSet PurchaseEvaluationDepartmentOpinions => Set(); public DbSet PurchaseEvaluationDepartmentApprovals => Set(); diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/DepartmentApprovalsConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/DepartmentApprovalsConfiguration.cs index 950f86a..7ecff74 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/DepartmentApprovalsConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/DepartmentApprovalsConfiguration.cs @@ -27,19 +27,11 @@ public class ContractDepartmentApprovalConfiguration b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100); b.Property(x => x.Comment).HasMaxLength(1000); - // Legacy 2-stage rows (Mig 16): UNIQUE chỉ áp khi InnerStepId IS NULL - // (Mig 20 mirror PE Mig 19 filtered split). + // Mig 21 — drop InnerStepId column + restore simple unique non-filtered. b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.DepartmentId, x.Stage }) .IsUnique() - .HasFilter("[InnerStepId] IS NULL") .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); - // N-stage rows (Mig 20): UNIQUE 1 row per (phase × inner step). - b.HasIndex(x => new { x.ContractId, x.PhaseAtApproval, x.InnerStepId }) - .IsUnique() - .HasFilter("[InnerStepId] IS NOT NULL") - .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_InnerStep"); - b.HasIndex(x => x.ContractId); b.HasIndex(x => x.DepartmentId); b.HasIndex(x => x.ApproverUserId); @@ -48,12 +40,6 @@ public class ContractDepartmentApprovalConfiguration .WithMany(c => c.DepartmentApprovals) .HasForeignKey(x => x.ContractId) .OnDelete(DeleteBehavior.Cascade); - - // FK InnerStepId nullable — Restrict (không xóa InnerStep nếu còn approval row) - b.HasOne() - .WithMany() - .HasForeignKey(x => x.InnerStepId) - .OnDelete(DeleteBehavior.Restrict); } } @@ -69,21 +55,11 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration b.Property(x => x.ApproverRoleSnapshot).HasMaxLength(100); b.Property(x => x.Comment).HasMaxLength(1000); - // Legacy 2-stage rows (Mig 16): UNIQUE (PEId, Phase, Dept, Stage) chỉ áp - // khi InnerStepId IS NULL — tránh conflict với N-stage rows nhiều InnerStep - // cùng dept cùng Stage=Confirm. + // Mig 21 — drop InnerStepId column + restore simple unique non-filtered. b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.DepartmentId, x.Stage }) .IsUnique() - .HasFilter("[InnerStepId] IS NULL") .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage"); - // N-stage rows (Mig 18+): UNIQUE (PEId, Phase, InnerStepId) — 1 approval row - // per (phase × inner step) khi InnerStepId IS NOT NULL. - b.HasIndex(x => new { x.PurchaseEvaluationId, x.PhaseAtApproval, x.InnerStepId }) - .IsUnique() - .HasFilter("[InnerStepId] IS NOT NULL") - .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_InnerStep"); - b.HasIndex(x => x.PurchaseEvaluationId); b.HasIndex(x => x.DepartmentId); b.HasIndex(x => x.ApproverUserId); @@ -92,13 +68,6 @@ public class PurchaseEvaluationDepartmentApprovalConfiguration .WithMany(c => c.DepartmentApprovals) .HasForeignKey(x => x.PurchaseEvaluationId) .OnDelete(DeleteBehavior.Cascade); - - // FK InnerStepId nullable — Restrict (không xóa InnerStep nếu còn approval row). - // Cấu hình không nav để giữ nhẹ entity (1 chiều, query qua join nếu cần). - b.HasOne() - .WithMany() - .HasForeignKey(x => x.InnerStepId) - .OnDelete(DeleteBehavior.Restrict); } } diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs index 80d31b5..f99bddc 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs @@ -184,13 +184,21 @@ public class PurchaseEvaluationWorkflowStepConfiguration : IEntityTypeConfigurat e.ToTable("PurchaseEvaluationWorkflowSteps"); e.Property(x => x.Name).HasMaxLength(200).IsRequired(); e.Property(x => x.Phase).HasConversion(); + e.Property(x => x.PositionLevel).HasConversion(); // Mig 21 e.HasOne(x => x.Definition) .WithMany(d => d.Steps) .HasForeignKey(x => x.PurchaseEvaluationWorkflowDefinitionId) .OnDelete(DeleteBehavior.Cascade); + // Mig 21 — FK Department Restrict. + e.HasOne() + .WithMany() + .HasForeignKey(x => x.DepartmentId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(x => new { x.PurchaseEvaluationWorkflowDefinitionId, x.Order }); + e.HasIndex(x => x.DepartmentId); } } @@ -209,37 +217,6 @@ public class PurchaseEvaluationWorkflowStepApproverConfiguration : IEntityTypeCo } } -// Inner step (Mig 18) — N-stage approval cấu hình động trong cùng 1 phase. -// FK Cascade từ Step cha. Index theo (StepId, Order) cho query ordered list. -// Index riêng DepartmentId để lookup khi service compute next pending sub-step. -public class PurchaseEvaluationWorkflowStepInnerStepConfiguration - : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder e) - { - e.ToTable("PurchaseEvaluationWorkflowStepInnerSteps"); - e.HasKey(x => x.Id); - - e.Property(x => x.PositionLevel).HasConversion(); - 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() - .WithMany() - .HasForeignKey(x => x.DepartmentId) - .OnDelete(DeleteBehavior.Restrict); - - e.HasIndex(x => new { x.PurchaseEvaluationWorkflowStepId, x.Order }); - e.HasIndex(x => x.DepartmentId); - } -} - // Mirror ContractCodeSequenceConfiguration — Prefix là PK, atomic UPDATE qua // SERIALIZABLE transaction trong PurchaseEvaluationCodeGenerator. public class PurchaseEvaluationCodeSequenceConfiguration diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowDefinitionConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowDefinitionConfiguration.cs index e1a7ef9..056db06 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowDefinitionConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowDefinitionConfiguration.cs @@ -28,13 +28,21 @@ public class WorkflowStepConfiguration : IEntityTypeConfiguration e.ToTable("WorkflowSteps"); e.Property(x => x.Name).HasMaxLength(200).IsRequired(); e.Property(x => x.Phase).HasConversion(); + e.Property(x => x.PositionLevel).HasConversion(); // Mig 21 e.HasOne(x => x.WorkflowDefinition) .WithMany(d => d.Steps) .HasForeignKey(x => x.WorkflowDefinitionId) .OnDelete(DeleteBehavior.Cascade); + // Mig 21 — FK Department Restrict (không xóa dept nếu còn step assigned). + e.HasOne() + .WithMany() + .HasForeignKey(x => x.DepartmentId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(x => new { x.WorkflowDefinitionId, x.Order }); + e.HasIndex(x => x.DepartmentId); } } @@ -52,31 +60,3 @@ public class WorkflowStepApproverConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder e) - { - e.ToTable("WorkflowStepInnerSteps"); - e.HasKey(x => x.Id); - - e.Property(x => x.PositionLevel).HasConversion(); - 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() - .WithMany() - .HasForeignKey(x => x.DepartmentId) - .OnDelete(DeleteBehavior.Restrict); - - e.HasIndex(x => new { x.WorkflowStepId, x.Order }); - e.HasIndex(x => x.DepartmentId); - } -} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.Designer.cs new file mode 100644 index 0000000..4e58cb9 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.Designer.cs @@ -0,0 +1,3618 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SolutionErp.Infrastructure.Persistence; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260508050243_RefactorWorkflowToFlatModel")] + partial class RefactorWorkflowToFlatModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaNganSach") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NamNganSach") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("TenNganSach") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TongNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaNganSach") + .IsUnique() + .HasFilter("[MaNganSach] IS NOT NULL"); + + b.HasIndex("NamNganSach"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("Budgets", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId", "ApprovedAt"); + + b.ToTable("BudgetApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("BudgetId") + .HasColumnType("uniqueidentifier"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId", "CreatedAt"); + + b.HasIndex("BudgetId", "EntityType"); + + b.ToTable("BudgetChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("BudgetId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("BudgetId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_BudgetDeptApprovals_Budget_Phase_Dept_Stage"); + + b.ToTable("BudgetDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("GroupCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NoiDung") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId", "Order"); + + b.ToTable("BudgetDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetId") + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetManualAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("BudgetManualName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BypassProcurementAndCCM") + .HasColumnType("bit"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DraftData") + .HasColumnType("nvarchar(max)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("GiaTri") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaHopDong") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NoiDung") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenHopDong") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("MaHopDong") + .IsUnique() + .HasFilter("[MaHopDong] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("SupplierId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("Contracts", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "ApprovedAt"); + + b.ToTable("ContractApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId"); + + b.ToTable("ContractAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.HasIndex("ContractId", "EntityType"); + + b.ToTable("ContractChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("ContractCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.ToTable("ContractComments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("ContractId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); + + b.ToTable("ContractDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DenNgay") + .HasColumnType("datetime2"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaDichVu") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGian") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TuNgay") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("DichVuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MaCongViec") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenCongViec") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("YeuCauKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("GiaoKhoanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThueVAT") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("MuaBanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LoaiDichVu") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PhamViDichVu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("SLA") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacDvDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DieuKienGiaoHang") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DieuKienThanhToan") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("NhomSP") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacNccDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianGiao") + .HasColumnType("datetime2"); + + b.Property("ThongSoKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NhaCungCapDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("HangMuc") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("ThauPhuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("ContractType", "IsActive"); + + b.ToTable("WorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("WorkflowDefinitionId", "Order"); + + b.ToTable("WorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowStepId"); + + b.ToTable("WorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType") + .IsUnique(); + + b.ToTable("WorkflowTypeAssignments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ContractClauses", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FieldSpec") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FormCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType"); + + b.HasIndex("FormCode") + .IsUnique(); + + b.ToTable("ContractTemplates", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Property("Key") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ParentKey") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Key"); + + b.HasIndex("ParentKey"); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanCreate") + .HasColumnType("bit"); + + b.Property("CanDelete") + .HasColumnType("bit"); + + b.Property("CanRead") + .HasColumnType("bit"); + + b.Property("CanUpdate") + .HasColumnType("bit"); + + b.Property("MenuKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MenuKey"); + + b.HasIndex("RoleId", "MenuKey") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ShortName") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CanBypassReview") + .HasColumnType("bit"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Position") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("RefreshToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OriginCountry") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Specification") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("MaterialItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ServiceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ServiceItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("UnitsOfMeasure", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("WorkItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Departments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactPerson") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TaxCode") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Href") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime2"); + + b.Property("RefId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId", "ReadAt"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetId") + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetManualAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("BudgetManualName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DiaDiem") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaPhieu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PaymentTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SelectedSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("TenGoiThau") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("ContractId"); + + b.HasIndex("MaPhieu") + .IsUnique() + .HasFilter("[MaPhieu] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("WorkflowDefinitionId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("PurchaseEvaluations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "ApprovedAt"); + + b.ToTable("PurchaseEvaluationApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.ToTable("PurchaseEvaluationAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "CreatedAt"); + + b.HasIndex("PurchaseEvaluationId", "EntityType"); + + b.ToTable("PurchaseEvaluationChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("PurchaseEvaluationCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage"); + + b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("Opinion") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Kind") + .IsUnique(); + + b.ToTable("PurchaseEvaluationDepartmentOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("GroupCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("KhoiLuongNganSach") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("KhoiLuongThiCong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NoiDung") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTienNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Order"); + + b.ToTable("PurchaseEvaluationDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BgVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ChuaVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsSelected") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationDetailId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.HasIndex("PurchaseEvaluationDetailId", "PurchaseEvaluationSupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationQuotes", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContactEmail") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PaymentTermText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.HasIndex("PurchaseEvaluationId", "SupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationSuppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("EvaluationType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("EvaluationType", "IsActive"); + + b.ToTable("PurchaseEvaluationWorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order"); + + b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationWorkflowStepId"); + + b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b => + { + b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget") + .WithMany("Approvals") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b => + { + b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget") + .WithMany("Changelogs") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget") + .WithMany("DepartmentApprovals") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b => + { + b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget") + .WithMany("Details") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Approvals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Attachments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Changelogs") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Comments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DepartmentApprovals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DichVuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("GiaoKhoanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("MuaBanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacDvDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacNccDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NhaCungCapDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("ThauPhuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition") + .WithMany("Steps") + .HasForeignKey("WorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkflowDefinition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("WorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") + .WithMany("Children") + .HasForeignKey("ParentKey") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu") + .WithMany("Permissions") + .HasForeignKey("MenuKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Approvals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Attachments") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Changelogs") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("DepartmentApprovals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("DepartmentOpinions") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Details") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail") + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", null) + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationId"); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", "Supplier") + .WithMany() + .HasForeignKey("PurchaseEvaluationSupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Detail"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Suppliers") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition") + .WithMany("Steps") + .HasForeignKey("PurchaseEvaluationWorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("PurchaseEvaluationWorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b => + { + b.Navigation("Approvals"); + + b.Navigation("Changelogs"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("Details"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("Comments"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("DichVuDetails"); + + b.Navigation("GiaoKhoanDetails"); + + b.Navigation("MuaBanDetails"); + + b.Navigation("NguyenTacDvDetails"); + + b.Navigation("NguyenTacNccDetails"); + + b.Navigation("NhaCungCapDetails"); + + b.Navigation("ThauPhuDetails"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Navigation("Approvers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Navigation("Children"); + + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("DepartmentOpinions"); + + b.Navigation("Details"); + + b.Navigation("Quotes"); + + b.Navigation("Suppliers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Navigation("Approvers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.cs new file mode 100644 index 0000000..384c187 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260508050243_RefactorWorkflowToFlatModel.cs @@ -0,0 +1,361 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + /// + public partial class RefactorWorkflowToFlatModel : Migration + { + /// + 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( + name: "DepartmentId", + table: "WorkflowSteps", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "PositionLevel", + table: "WorkflowSteps", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "DepartmentId", + table: "PurchaseEvaluationWorkflowSteps", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "PositionLevel", + table: "PurchaseEvaluationWorkflowSteps", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CurrentWorkflowStepIndex", + table: "PurchaseEvaluations", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "RejectedAtStepIndex", + table: "PurchaseEvaluations", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CurrentWorkflowStepIndex", + table: "Contracts", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + 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); + } + + /// + 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( + name: "InnerStepId", + table: "PurchaseEvaluationDepartmentApprovals", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "InnerStepId", + table: "ContractDepartmentApprovals", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "PurchaseEvaluationWorkflowStepInnerSteps", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + PurchaseEvaluationWorkflowStepId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + CreatedBy = table.Column(type: "uniqueidentifier", nullable: true), + DepartmentId = table.Column(type: "uniqueidentifier", nullable: false), + IsRequired = table.Column(type: "bit", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Order = table.Column(type: "int", nullable: false), + PositionLevel = table.Column(type: "int", nullable: false), + SlaDays = table.Column(type: "int", nullable: true), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + UpdatedBy = table.Column(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(type: "uniqueidentifier", nullable: false), + WorkflowStepId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + CreatedBy = table.Column(type: "uniqueidentifier", nullable: true), + DepartmentId = table.Column(type: "uniqueidentifier", nullable: false), + IsRequired = table.Column(type: "bit", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Order = table.Column(type: "int", nullable: false), + PositionLevel = table.Column(type: "int", nullable: false), + SlaDays = table.Column(type: "int", nullable: true), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + UpdatedBy = table.Column(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); + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 99b36d9..359ad90 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -484,6 +484,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("CreatedBy") .HasColumnType("uniqueidentifier"); + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + b.Property("DeletedAt") .HasColumnType("datetime2"); @@ -520,6 +523,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("ProjectId") .HasColumnType("uniqueidentifier"); + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + b.Property("RejectedFromPhase") .HasColumnType("int"); @@ -824,9 +830,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("DepartmentId") .HasColumnType("uniqueidentifier"); - b.Property("InnerStepId") - .HasColumnType("uniqueidentifier"); - b.Property("IsBypassed") .HasColumnType("bit"); @@ -853,17 +856,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.HasIndex("DepartmentId"); - b.HasIndex("InnerStepId"); - - b.HasIndex("ContractId", "PhaseAtApproval", "InnerStepId") - .IsUnique() - .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_InnerStep") - .HasFilter("[InnerStepId] IS NOT NULL"); - b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage") .IsUnique() - .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage") - .HasFilter("[InnerStepId] IS NULL"); + .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); b.ToTable("ContractDepartmentApprovals", (string)null); }); @@ -1422,6 +1417,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("CreatedBy") .HasColumnType("uniqueidentifier"); + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -1433,6 +1431,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("Phase") .HasColumnType("int"); + b.Property("PositionLevel") + .HasColumnType("int"); + b.Property("SlaDays") .HasColumnType("int"); @@ -1447,6 +1448,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.HasKey("Id"); + b.HasIndex("DepartmentId"); + b.HasIndex("WorkflowDefinitionId", "Order"); b.ToTable("WorkflowSteps", (string)null); @@ -1488,55 +1491,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.ToTable("WorkflowStepApprovers", (string)null); }); - modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("DepartmentId") - .HasColumnType("uniqueidentifier"); - - b.Property("IsRequired") - .HasColumnType("bit"); - - b.Property("Name") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Order") - .HasColumnType("int"); - - b.Property("PositionLevel") - .HasColumnType("int"); - - b.Property("SlaDays") - .HasColumnType("int"); - - b.Property("UpdatedAt") - .HasColumnType("datetime2"); - - b.Property("UpdatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("WorkflowStepId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.HasIndex("DepartmentId"); - - b.HasIndex("WorkflowStepId", "Order"); - - b.ToTable("WorkflowStepInnerSteps", (string)null); - }); - modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b => { b.Property("Id") @@ -2416,6 +2370,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("CreatedBy") .HasColumnType("uniqueidentifier"); + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + b.Property("DeletedAt") .HasColumnType("datetime2"); @@ -2452,6 +2409,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("ProjectId") .HasColumnType("uniqueidentifier"); + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + b.Property("RejectedFromPhase") .HasColumnType("int"); @@ -2719,9 +2679,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("DepartmentId") .HasColumnType("uniqueidentifier"); - b.Property("InnerStepId") - .HasColumnType("uniqueidentifier"); - b.Property("IsBypassed") .HasColumnType("bit"); @@ -2749,19 +2706,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.HasIndex("DepartmentId"); - b.HasIndex("InnerStepId"); - b.HasIndex("PurchaseEvaluationId"); - b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "InnerStepId") - .IsUnique() - .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_InnerStep") - .HasFilter("[InnerStepId] IS NOT NULL"); - b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage") .IsUnique() - .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage") - .HasFilter("[InnerStepId] IS NULL"); + .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage"); b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null); }); @@ -3080,6 +3029,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("CreatedBy") .HasColumnType("uniqueidentifier"); + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -3091,6 +3043,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Property("Phase") .HasColumnType("int"); + b.Property("PositionLevel") + .HasColumnType("int"); + b.Property("PurchaseEvaluationWorkflowDefinitionId") .HasColumnType("uniqueidentifier"); @@ -3105,6 +3060,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.HasKey("Id"); + b.HasIndex("DepartmentId"); + b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order"); b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null); @@ -3146,55 +3103,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null); }); - modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepInnerStep", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("CreatedAt") - .HasColumnType("datetime2"); - - b.Property("CreatedBy") - .HasColumnType("uniqueidentifier"); - - b.Property("DepartmentId") - .HasColumnType("uniqueidentifier"); - - b.Property("IsRequired") - .HasColumnType("bit"); - - b.Property("Name") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Order") - .HasColumnType("int"); - - b.Property("PositionLevel") - .HasColumnType("int"); - - b.Property("PurchaseEvaluationWorkflowStepId") - .HasColumnType("uniqueidentifier"); - - b.Property("SlaDays") - .HasColumnType("int"); - - b.Property("UpdatedAt") - .HasColumnType("datetime2"); - - b.Property("UpdatedBy") - .HasColumnType("uniqueidentifier"); - - b.HasKey("Id"); - - b.HasIndex("DepartmentId"); - - b.HasIndex("PurchaseEvaluationWorkflowStepId", "Order"); - - b.ToTable("PurchaseEvaluationWorkflowStepInnerSteps", (string)null); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("SolutionErp.Domain.Identity.Role", null) @@ -3342,11 +3250,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", null) - .WithMany() - .HasForeignKey("InnerStepId") - .OnDelete(DeleteBehavior.Restrict); - b.Navigation("Contract"); }); @@ -3429,6 +3332,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition") .WithMany("Steps") .HasForeignKey("WorkflowDefinitionId") @@ -3449,23 +3357,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Navigation("Step"); }); - modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepInnerStep", b => - { - b.HasOne("SolutionErp.Domain.Master.Department", null) - .WithMany() - .HasForeignKey("DepartmentId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step") - .WithMany("InnerSteps") - .HasForeignKey("WorkflowStepId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Step"); - }); - modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => { b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") @@ -3538,11 +3429,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => { - b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepInnerStep", null) - .WithMany() - .HasForeignKey("InnerStepId") - .OnDelete(DeleteBehavior.Restrict); - b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") .WithMany("DepartmentApprovals") .HasForeignKey("PurchaseEvaluationId") @@ -3610,6 +3496,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition") .WithMany("Steps") .HasForeignKey("PurchaseEvaluationWorkflowDefinitionId") @@ -3630,23 +3521,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations b.Navigation("Step"); }); - modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepInnerStep", b => - { - b.HasOne("SolutionErp.Domain.Master.Department", null) - .WithMany() - .HasForeignKey("DepartmentId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step") - .WithMany("InnerSteps") - .HasForeignKey("PurchaseEvaluationWorkflowStepId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Step"); - }); - modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b => { b.Navigation("Approvals"); @@ -3693,8 +3567,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => { b.Navigation("Approvers"); - - b.Navigation("InnerSteps"); }); modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => @@ -3736,8 +3608,6 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => { b.Navigation("Approvers"); - - b.Navigation("InnerSteps"); }); #pragma warning restore 612, 618 } diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 315e7b6..b2addf9 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -11,9 +11,10 @@ using SolutionErp.Domain.Notifications; namespace SolutionErp.Infrastructure.Services; -// Thin orchestrator — all phase/role/SLA rules live in WorkflowPolicy (Domain). -// This class is responsible only for *applying* transitions: DB writes, code -// generation at DangDongDau, SLA deadline computation, notification dispatch. +// Contract Workflow Service — Session 16 drastic refactor (Mig 21): +// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate +// steps OrderBy Order, advance Contract.CurrentWorkflowStepIndex per approve. +// Phase enum simplified: DangSoanThao → ChoDuyet → DaPhatHanh / TuChoi. public class ContractWorkflowService( IApplicationDbContext db, IContractCodeGenerator codeGenerator, @@ -22,11 +23,8 @@ public class ContractWorkflowService( IChangelogService changelog, UserManager userManager) : IContractWorkflowService { - // Expose per-policy SLA via the contract — accepts optional contract so the - // caller (CreateContractCommand) can ask for a specific type's SLA even - // before the contract exists. public TimeSpan? GetPhaseSla(ContractPhase phase) => - WorkflowPolicies.Standard.PhaseSla.GetValueOrDefault(phase); + phase == ContractPhase.ChoDuyet ? TimeSpan.FromDays(7) : null; public async Task TransitionAsync( Contract contract, @@ -37,378 +35,196 @@ public class ContractWorkflowService( string? comment, CancellationToken ct = default) { - if (contract.Phase == targetPhase) - throw new ConflictException("HĐ đã ở phase đích."); - - // ===== Smart reject + resume (Phase 9 — Migration 16) ===== - // Reject: override target = DangSoanThao + lưu phase gốc → Drafter sửa. - // Resume sau reject: Drafter trình từ DangSoanThao + RejectedFromPhase - // != null → jump straight tới phase đã reject, bypass phase trung gian. var fromPhase = contract.Phase; - var isResumingAfterReject = decision == ApprovalDecision.Approve - && fromPhase == ContractPhase.DangSoanThao - && contract.RejectedFromPhase != null; - - if (decision == ApprovalDecision.Reject) - { - contract.RejectedFromPhase = fromPhase; - targetPhase = ContractPhase.DangSoanThao; - - // N-stage state reset (Mig 20): clear inner step approval rows tại - // fromPhase. User resume sẽ approve lại từ inner step đầu. - var staleNStageRows = await db.ContractDepartmentApprovals - .Where(a => a.ContractId == contract.Id - && a.PhaseAtApproval == (int)fromPhase - && a.InnerStepId != null) - .ToListAsync(ct); - foreach (var r in staleNStageRows) db.ContractDepartmentApprovals.Remove(r); - } - else if (isResumingAfterReject) - { - targetPhase = contract.RejectedFromPhase!.Value; - contract.RejectedFromPhase = null; - } - - // Resolve the workflow: prefer the pinned WorkflowDefinition (new - // versioned system), else fall back to the static/override registry - // (legacy path for contracts created before versioning rolled out). - WorkflowPolicy policy; - WorkflowDefinition? definition = null; - if (contract.WorkflowDefinitionId is Guid wfId) - { - definition = await db.WorkflowDefinitions.AsNoTracking() - .Include(d => d.Steps.OrderBy(s => s.Order)) - .ThenInclude(s => s.Approvers) - .Include(d => d.Steps) - .ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order)) - .FirstOrDefaultAsync(d => d.Id == wfId, ct); - policy = definition is not null - ? WorkflowPolicyRegistry.FromDefinition(definition) - : WorkflowPolicyRegistry.ForContract(contract); - } - else - { - var overrides = await db.WorkflowTypeAssignments.AsNoTracking() - .ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct); - policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides); - } var isAdmin = actorRoles.Contains(AppRoles.Admin); var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; - // Policy guard — bypass cho resume (Drafter có quyền trình lại sau khi - // sửa, không cần policy check vì target đã pinned bởi RejectedFromPhase). - if (!isAdmin && !isSystem && !isResumingAfterReject) + // ===== REJECT BRANCH ===== + if (decision == ApprovalDecision.Reject) { - if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles)) - throw new ForbiddenException( - $"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}. " + - $"Kiểm tra ContractType hoặc BypassProcurementAndCCM."); - - // Sử dụng IsTransitionAllowed — check Role + User-kind fallback. - // User-kind chỉ áp dụng khi WorkflowDefinition pinned có - // WorkflowStepApprover Kind=User cho step này. - if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId)) + if (targetPhase == ContractPhase.TuChoi) { - var userExtra = policy.UserTransitions is not null - && policy.UserTransitions.TryGetValue((fromPhase, targetPhase), out var userIds) - && userIds.Length > 0 - ? $" hoặc {userIds.Length} user explicit" - : ""; - throw new ForbiddenException( - $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " + - $"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}{userExtra}."); + contract.Phase = ContractPhase.TuChoi; } + else + { + contract.RejectedFromPhase = fromPhase; + contract.RejectedAtStepIndex = contract.CurrentWorkflowStepIndex; + contract.Phase = ContractPhase.DangSoanThao; + contract.CurrentWorkflowStepIndex = null; + } + contract.SlaDeadline = null; + await LogTransitionAsync(contract, fromPhase, contract.Phase, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; } - // ===== Department approval (N-stage Mig 20 hoặc Legacy 2-stage Mig 16) ===== - // Mirror PE workflow service. Step có InnerSteps → N-stage logic - // (Phòng × PositionLevel sequential). Else fallback legacy 2-stage - // (NV.Review/TPB.Confirm). Skip với reject + resume + admin + system. - var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase); - var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0; + // ===== RESUME AFTER REJECT ===== + var isResumingAfterReject = decision == ApprovalDecision.Approve + && fromPhase == ContractPhase.DangSoanThao + && contract.RejectedAtStepIndex != null; - if (decision == ApprovalDecision.Approve - && targetPhase != ContractPhase.DangSoanThao - && targetPhase != ContractPhase.TuChoi - && !isResumingAfterReject - && !isAdmin && !isSystem - && actorUserId is Guid actorUid) + if (isResumingAfterReject) { - var actor = await userManager.FindByIdAsync(actorUid.ToString()); + contract.Phase = ContractPhase.ChoDuyet; + contract.CurrentWorkflowStepIndex = contract.RejectedAtStepIndex; + contract.RejectedAtStepIndex = null; + contract.RejectedFromPhase = null; + contract.SlaDeadline = dateTime.UtcNow.AddDays(7); + await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; + } - if (hasInnerSteps && currentStepDef is not null) + // ===== DRAFTER TRÌNH ===== + if (fromPhase == ContractPhase.DangSoanThao + && (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem))) + { + if (!isAdmin && !isSystem + && !actorRoles.Contains(AppRoles.Drafter) + && !actorRoles.Contains(AppRoles.DeptManager)) { - // ===== N-stage logic (Mig 20) — mirror PE Mig 18 ===== - if (actor?.DepartmentId is null || actor.PositionLevel is null) - { + throw new ForbiddenException( + $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ."); + } + contract.Phase = ContractPhase.ChoDuyet; + contract.CurrentWorkflowStepIndex = 0; + contract.SlaDeadline = dateTime.UtcNow.AddDays(7); + await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; + } + + // ===== APPROVE STEP ===== + if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve) + { + var def = contract.WorkflowDefinitionId is Guid wfId + ? await db.WorkflowDefinitions.AsNoTracking() + .Include(d => d.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Approvers) + .FirstOrDefaultAsync(d => d.Id == wfId, ct) + : null; + + if (def == null || def.Steps.Count == 0) + throw new ConflictException("HĐ chưa pin workflow definition hoặc workflow không có step."); + + var steps = def.Steps.OrderBy(s => s.Order).ToList(); + var currentIdx = contract.CurrentWorkflowStepIndex ?? 0; + if (currentIdx < 0 || currentIdx >= steps.Count) + throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ."); + + var currentStep = steps[currentIdx]; + + if (!isAdmin && !isSystem) + { + var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null; + if (actor == null) + throw new ForbiddenException("Không xác định được approver."); + + var matchByDeptLevel = currentStep.DepartmentId != null + && currentStep.PositionLevel != null + && actor.DepartmentId == currentStep.DepartmentId + && actor.PositionLevel != null + && (int)actor.PositionLevel >= (int)currentStep.PositionLevel; + + var matchByExplicitUser = currentStep.Approvers.Any(a => + a.Kind == WorkflowApproverKind.User + && Guid.TryParse(a.AssignmentValue, out var auid) + && auid == actor.Id); + + var matchByRole = currentStep.Approvers.Any(a => + a.Kind == WorkflowApproverKind.Role + && actorRoles.Contains(a.AssignmentValue)); + + if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole) throw new ForbiddenException( - "User phải có Phòng + Cấp chức danh (NV/PP/TP) để duyệt N-stage workflow."); - } - - var actorDept = actor.DepartmentId.Value; - var actorPos = actor.PositionLevel.Value; - var canBypass = actor.CanBypassReview; - - var inners = currentStepDef.InnerSteps.OrderBy(i => i.Order).ToList(); - var innerIds = inners.Select(i => i.Id).ToList(); - - var existingApprovals = await db.ContractDepartmentApprovals - .Where(a => a.ContractId == contract.Id - && a.PhaseAtApproval == (int)fromPhase - && a.InnerStepId != null - && innerIds.Contains(a.InnerStepId!.Value)) - .ToListAsync(ct); - var doneInnerIds = existingApprovals.Select(a => a.InnerStepId!.Value).ToHashSet(); - - var pendingRequired = inners.Where(i => i.IsRequired && !doneInnerIds.Contains(i.Id)).ToList(); - if (pendingRequired.Count > 0) - { - var firstPending = pendingRequired[0]; - - var levelOk = actorPos == firstPending.PositionLevel - || (canBypass && (int)actorPos >= (int)firstPending.PositionLevel); - if (actorDept != firstPending.DepartmentId || !levelOk) - { - throw new ForbiddenException( - $"Cấp duyệt tiếp theo: phòng {firstPending.DepartmentId} cấp {firstPending.PositionLevel}. " + - $"Bạn (phòng {actorDept} cấp {actorPos}{(canBypass ? "+bypass" : "")}) không khớp."); - } - - var rowsToCreate = new List<(WorkflowStepInnerStep i, bool bypassed)>(); - if (actorPos == firstPending.PositionLevel) - { - rowsToCreate.Add((firstPending, false)); - } - else - { - // Bypass cùng dept: upsert tất cả pending inner trong dept actor - // có level từ firstPending.PositionLevel đến actorPos (inclusive) - foreach (var inner in inners - .Where(i => i.DepartmentId == actorDept - && (int)i.PositionLevel >= (int)firstPending.PositionLevel - && (int)i.PositionLevel <= (int)actorPos - && !doneInnerIds.Contains(i.Id))) - { - rowsToCreate.Add((inner, inner.PositionLevel != actorPos)); - } - } - - var nowUtc = dateTime.UtcNow; - foreach (var (inner, bypassed) in rowsToCreate) - { - db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval - { - ContractId = contract.Id, - PhaseAtApproval = (int)fromPhase, - DepartmentId = inner.DepartmentId, - Stage = ApprovalStage.Confirm, - ApproverUserId = actorUid, - ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}", - Comment = comment, - ApprovedAt = nowUtc, - IsBypassed = bypassed, - InnerStepId = inner.Id, - }); - doneInnerIds.Add(inner.Id); - } - - var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id)); - if (stillPending) - { - db.ContractApprovals.Add(new ContractApproval - { - ContractId = contract.Id, - FromPhase = fromPhase, - ToPhase = fromPhase, - ApproverUserId = actorUid, - Decision = ApprovalDecision.Approve, - Comment = $"[Inner step duyệt {actorPos}] {comment ?? ""}", - ApprovedAt = nowUtc, - }); - - string? reviewerName = actor.FullName ?? actor.Email; - db.ContractChangelogs.Add(new ContractChangelog - { - ContractId = contract.Id, - EntityType = ChangelogEntityType.Workflow, - Action = ChangelogAction.Transition, - PhaseAtChange = fromPhase, - UserId = actorUid, - UserName = reviewerName ?? "Hệ thống", - Summary = $"{reviewerName} duyệt cấp {actorPos} phase {fromPhase} (còn {inners.Count(i => i.IsRequired && !doneInnerIds.Contains(i.Id))} cấp pending)", - ContextNote = comment, - }); - - await db.SaveChangesAsync(ct); - return; - } - // All required inner steps done → fall through phase transition - } + $"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp."); } - else if (actor?.DepartmentId is Guid deptId) + + db.ContractApprovals.Add(new ContractApproval { - // ===== Legacy 2-stage logic (Mig 16) — fallback khi step KHÔNG có InnerSteps ===== - var isManager = actorRoles.Contains(AppRoles.DeptManager); - var canBypass = actor.CanBypassReview; - var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review; - var isBypassed = !isManager && canBypass; - var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV"); + ContractId = contract.Id, + FromPhase = fromPhase, + ToPhase = fromPhase, + ApproverUserId = actorUserId, + Decision = decision, + Comment = $"[Step {currentIdx + 1}] {comment ?? ""}", + ApprovedAt = dateTime.UtcNow, + }); - var existing = await db.ContractDepartmentApprovals - .FirstOrDefaultAsync(a => - a.ContractId == contract.Id - && a.PhaseAtApproval == (int)fromPhase - && a.DepartmentId == deptId - && a.Stage == stage - && a.InnerStepId == null, ct); - if (existing is null) + var nextIdx = currentIdx + 1; + if (nextIdx >= steps.Count) + { + // All steps done — gen mã HĐ + DaPhatHanh + if (string.IsNullOrEmpty(contract.MaHopDong)) { - db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval - { - ContractId = contract.Id, - PhaseAtApproval = (int)fromPhase, - DepartmentId = deptId, - Stage = stage, - ApproverUserId = actorUid, - ApproverRoleSnapshot = roleSnapshot, - Comment = comment, - ApprovedAt = dateTime.UtcNow, - IsBypassed = isBypassed, - InnerStepId = null, - }); - } - else - { - existing.ApproverUserId = actorUid; - existing.ApproverRoleSnapshot = roleSnapshot; - existing.Comment = comment; - existing.ApprovedAt = dateTime.UtcNow; - existing.IsBypassed = isBypassed; - } - - var hasConfirm = stage == ApprovalStage.Confirm - || await db.ContractDepartmentApprovals.AnyAsync(a => - a.ContractId == contract.Id - && a.PhaseAtApproval == (int)fromPhase - && a.DepartmentId == deptId - && a.Stage == ApprovalStage.Confirm - && a.InnerStepId == null, ct); - - if (!hasConfirm) - { - db.ContractApprovals.Add(new ContractApproval - { - ContractId = contract.Id, - FromPhase = fromPhase, - ToPhase = fromPhase, - ApproverUserId = actorUid, - Decision = ApprovalDecision.Approve, - Comment = $"[Review NV] {comment ?? ""}", - ApprovedAt = dateTime.UtcNow, - }); - - string? reviewerName = actor.FullName ?? actor.Email; - db.ContractChangelogs.Add(new ContractChangelog - { - ContractId = contract.Id, - EntityType = ChangelogEntityType.Workflow, - Action = ChangelogAction.Transition, - PhaseAtChange = fromPhase, - UserId = actorUid, - UserName = reviewerName ?? "Hệ thống", - Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm", - ContextNote = comment, - }); - - // Notify TPB cùng dept để confirm. Best effort. - try - { - var managers = await db.Users.AsNoTracking() - .Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive) - .Select(u => u.Id) - .ToListAsync(ct); - foreach (var mgrId in managers) - { - var mgr = await userManager.FindByIdAsync(mgrId.ToString()); - if (mgr is null) continue; - var roles = await userManager.GetRolesAsync(mgr); - if (!roles.Contains(AppRoles.DeptManager)) continue; - - await notifications.NotifyAsync( - mgrId, - NotificationType.ContractPhaseTransition, - title: $"HĐ {contract.MaHopDong ?? contract.TenHopDong ?? ""} chờ TPB confirm", - description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.", - href: $"/contracts/{contract.Id}", - refId: contract.Id, - ct: ct); - } - } - catch { /* notification fail non-critical */ } - - await db.SaveChangesAsync(ct); - return; + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct) + ?? throw new NotFoundException("Supplier", contract.SupplierId); + var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct) + ?? throw new NotFoundException("Project", contract.ProjectId); + contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct); } + contract.Phase = ContractPhase.DaPhatHanh; + contract.CurrentWorkflowStepIndex = null; + contract.SlaDeadline = null; + await LogTransitionAsync(contract, fromPhase, ContractPhase.DaPhatHanh, actorUserId, decision, comment, ct); } + else + { + contract.CurrentWorkflowStepIndex = nextIdx; + contract.SlaDeadline = dateTime.UtcNow.AddDays(7); + await LogTransitionAsync(contract, fromPhase, fromPhase, actorUserId, decision, + $"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct); + } + await db.SaveChangesAsync(ct); + return; } - // Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau. - // Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip. - // Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng - // path khác (vd seed/import) chưa set MaHopDong. - if (targetPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong)) + // Admin manual override + if (isAdmin) { - var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct) - ?? throw new NotFoundException("Supplier", contract.SupplierId); - var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct) - ?? throw new NotFoundException("Project", contract.ProjectId); - contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct); + contract.Phase = targetPhase; + contract.SlaDeadline = targetPhase == ContractPhase.ChoDuyet + ? dateTime.UtcNow.AddDays(7) : null; + await LogTransitionAsync(contract, fromPhase, targetPhase, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; } - contract.SlaWarningSent = false; - contract.Phase = targetPhase; + throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); + } - var sla = policy.PhaseSla.GetValueOrDefault(targetPhase); - contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value); - - db.ContractApprovals.Add(new ContractApproval - { - ContractId = contract.Id, - FromPhase = fromPhase, - ToPhase = targetPhase, - ApproverUserId = actorUserId, - Decision = decision, - Comment = comment, - ApprovedAt = dateTime.UtcNow, - }); - - // Log workflow transition vào unified Changelog (cho user xem trên tab Lịch sử) - await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, targetPhase, comment); + private async Task LogTransitionAsync( + Contract contract, + ContractPhase fromPhase, + ContractPhase toPhase, + Guid? actorUserId, + ApprovalDecision decision, + string? comment, + CancellationToken ct) + { + await changelog.LogWorkflowTransitionAsync(contract.Id, fromPhase, toPhase, comment); if (contract.DrafterUserId is Guid drafterId && drafterId != actorUserId) { - var title = targetPhase switch + var (title, type) = toPhase switch { - ContractPhase.DaPhatHanh => $"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành", - ContractPhase.TuChoi => $"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối", - _ => $"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển sang phase mới", - }; - var type = targetPhase switch - { - ContractPhase.DaPhatHanh => NotificationType.ContractPublished, - ContractPhase.TuChoi => NotificationType.ContractRejected, - _ => NotificationType.ContractPhaseTransition, + ContractPhase.DaPhatHanh => ($"HĐ {contract.MaHopDong ?? contract.TenHopDong} đã phát hành", + NotificationType.ContractPublished), + ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối", + NotificationType.ContractRejected), + ContractPhase.DangSoanThao when fromPhase == ContractPhase.ChoDuyet => + ($"HĐ {contract.TenHopDong ?? "của bạn"} bị trả lại — vui lòng sửa và trình lại", + NotificationType.ContractRejected), + _ => ($"HĐ {contract.TenHopDong ?? contract.MaHopDong ?? ""} chuyển phase mới", + NotificationType.ContractPhaseTransition), }; await notifications.NotifyAsync( - drafterId, - type, - title, - description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"), + drafterId, type, title, + description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"), href: $"/contracts/{contract.Id}", refId: contract.Id, ct: ct); } - - await db.SaveChangesAsync(ct); } } diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 05267ca..c3c970f 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -12,8 +12,11 @@ using SolutionErp.Domain.PurchaseEvaluations; namespace SolutionErp.Infrastructure.Services; -// Mirror ContractWorkflowService. Load policy từ pinned -// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry. +// PE Workflow Service — Session 16 drastic refactor (Mig 21): +// Flat workflow model. Mỗi step = 1 (Phòng × Cấp + Approvers). Service iterate +// steps OrderBy Order, advance PE.CurrentWorkflowStepIndex per approve. +// Phase enum simplified: DangSoanThao → ChoDuyet (active workflow) → DaDuyet +// (terminal) / TuChoi (khoá). Trả lại = về DangSoanThao + save RejectedAtStepIndex. public class PurchaseEvaluationWorkflowService( IApplicationDbContext db, IDateTime dateTime, @@ -21,7 +24,7 @@ public class PurchaseEvaluationWorkflowService( UserManager userManager) : IPurchaseEvaluationWorkflowService { public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) => - PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase); + phase == PurchaseEvaluationPhase.ChoDuyet ? TimeSpan.FromDays(7) : null; public async Task TransitionAsync( PurchaseEvaluation evaluation, @@ -32,345 +35,173 @@ public class PurchaseEvaluationWorkflowService( string? comment, CancellationToken ct = default) { - if (evaluation.Phase == targetPhase) - throw new ConflictException("Phiếu đã ở phase đích."); - - // ===== Smart reject + resume (Phase 9 — Migration 16) ===== var fromPhase = evaluation.Phase; - var isResumingAfterReject = decision == ApprovalDecision.Approve - && fromPhase == PurchaseEvaluationPhase.DangSoanThao - && evaluation.RejectedFromPhase != null; - - if (decision == ApprovalDecision.Reject) - { - // 2 loại Reject (Session 14): - // - target=TuChoi: "Từ chối hoàn toàn" — phiếu khoá vĩnh viễn (Phase=TuChoi - // → 17 handler Mig 16 lock edit). Drafter phải tạo phiếu mới. KHÔNG set - // RejectedFromPhase + KHÔNG clear N-stage (không resume). - // - target khác (thường = DangSoanThao): "Trả lại" — smart reject pattern - // Mig 16. Set RejectedFromPhase + force DangSoanThao + clear N-stage rows - // tại fromPhase → Drafter sửa rồi trình lại jump-back tới phase đã reject. - if (targetPhase != PurchaseEvaluationPhase.TuChoi) - { - evaluation.RejectedFromPhase = fromPhase; - targetPhase = PurchaseEvaluationPhase.DangSoanThao; - - var staleNStageRows = await db.PurchaseEvaluationDepartmentApprovals - .Where(a => a.PurchaseEvaluationId == evaluation.Id - && a.PhaseAtApproval == (int)fromPhase - && a.InnerStepId != null) - .ToListAsync(ct); - foreach (var r in staleNStageRows) db.PurchaseEvaluationDepartmentApprovals.Remove(r); - } - } - else if (isResumingAfterReject) - { - targetPhase = evaluation.RejectedFromPhase!.Value; - evaluation.RejectedFromPhase = null; - } - - PurchaseEvaluationPolicy policy; - PurchaseEvaluationWorkflowDefinition? definition = null; - if (evaluation.WorkflowDefinitionId is Guid wfId) - { - definition = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() - .Include(d => d.Steps.OrderBy(s => s.Order)) - .ThenInclude(s => s.Approvers) - .Include(d => d.Steps) - .ThenInclude(s => s.InnerSteps.OrderBy(i => i.Order)) - .FirstOrDefaultAsync(d => d.Id == wfId, ct); - policy = definition is not null - ? PurchaseEvaluationPolicyRegistry.FromDefinition(definition) - : PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation); - } - else - { - policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation); - } - var isAdmin = actorRoles.Contains(AppRoles.Admin); var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; - // Policy guard — bypass khi resume sau reject (target đã pinned). - if (!isAdmin && !isSystem && !isResumingAfterReject) + // ===== REJECT BRANCH ===== + if (decision == ApprovalDecision.Reject) { - if (!policy.Transitions.TryGetValue((fromPhase, targetPhase), out var allowedRoles)) - throw new ForbiddenException( - $"Policy '{policy.Name}' không cho phép {fromPhase} → {targetPhase}."); - - if (!policy.IsTransitionAllowed(fromPhase, targetPhase, actorRoles, actorUserId)) + if (targetPhase == PurchaseEvaluationPhase.TuChoi) { - throw new ForbiddenException( - $"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {fromPhase} → {targetPhase}. " + - $"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}."); + // Từ chối hoàn toàn — phiếu khoá vĩnh viễn (lock edit Mig 16). + evaluation.Phase = PurchaseEvaluationPhase.TuChoi; } + else + { + // Trả lại — về DangSoanThao + save RejectedAtStepIndex (resume jump-back). + evaluation.RejectedFromPhase = fromPhase; + evaluation.RejectedAtStepIndex = evaluation.CurrentWorkflowStepIndex; + evaluation.Phase = PurchaseEvaluationPhase.DangSoanThao; + evaluation.CurrentWorkflowStepIndex = null; + } + evaluation.SlaDeadline = null; + await LogTransitionAsync(evaluation, fromPhase, evaluation.Phase, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; } - // ===== Department approval (N-stage Mig 18 hoặc Legacy 2-stage Mig 16) ===== - // Active block khi: Approve + chuyển sang phase trung gian (không phải - // DangSoanThao/TuChoi) + KHÔNG admin/system + KHÔNG resume sau reject. - var currentStepDef = definition?.Steps.FirstOrDefault(s => s.Phase == fromPhase); - var hasInnerSteps = currentStepDef?.InnerSteps.Count > 0; + // ===== RESUME AFTER REJECT (Drafter trình lại) ===== + var isResumingAfterReject = decision == ApprovalDecision.Approve + && fromPhase == PurchaseEvaluationPhase.DangSoanThao + && evaluation.RejectedAtStepIndex != null; - if (decision == ApprovalDecision.Approve - && targetPhase != PurchaseEvaluationPhase.DangSoanThao - && targetPhase != PurchaseEvaluationPhase.TuChoi - && !isResumingAfterReject - && !isAdmin && !isSystem - && actorUserId is Guid actorUid) + if (isResumingAfterReject) { - var actor = await userManager.FindByIdAsync(actorUid.ToString()); + evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; + evaluation.CurrentWorkflowStepIndex = evaluation.RejectedAtStepIndex; + evaluation.RejectedAtStepIndex = null; + evaluation.RejectedFromPhase = null; + evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); + await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; + } - if (hasInnerSteps && currentStepDef is not null) + // ===== DRAFTER TRÌNH (DangSoanThao → ChoDuyet) ===== + if (fromPhase == PurchaseEvaluationPhase.DangSoanThao + && (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem)) + { + // Drafter/DeptManager only (or Admin bypass). + if (!isAdmin && !isSystem + && !actorRoles.Contains(AppRoles.Drafter) + && !actorRoles.Contains(AppRoles.DeptManager)) { - // ===== N-stage logic (Mig 18) ===== - // Yêu cầu user có DepartmentId + PositionLevel set. Match exact - // (DeptId + PositionLevel) inner step pending tiếp theo theo Order. - // Bypass: actor cùng dept + PositionLevel cao hơn + CanBypassReview - // → batch upsert luôn các inner step cấp dưới (audit IsBypassed=true). - if (actor?.DepartmentId is null || actor.PositionLevel is null) - { + throw new ForbiddenException( + $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt phiếu."); + } + evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet; + evaluation.CurrentWorkflowStepIndex = 0; + evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); + await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.ChoDuyet, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; + } + + // ===== APPROVE STEP (advance pointer trong ChoDuyet) ===== + if (fromPhase == PurchaseEvaluationPhase.ChoDuyet && decision == ApprovalDecision.Approve) + { + var def = evaluation.WorkflowDefinitionId is Guid wfId + ? await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking() + .Include(d => d.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Approvers) + .FirstOrDefaultAsync(d => d.Id == wfId, ct) + : null; + + if (def == null || def.Steps.Count == 0) + throw new ConflictException("Phiếu chưa pin workflow definition hoặc workflow không có step."); + + var steps = def.Steps.OrderBy(s => s.Order).ToList(); + var currentIdx = evaluation.CurrentWorkflowStepIndex ?? 0; + if (currentIdx < 0 || currentIdx >= steps.Count) + throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1})."); + + var currentStep = steps[currentIdx]; + + // Match approver — admin bypass policy + if (!isAdmin && !isSystem) + { + var actor = actorUserId is Guid uid ? await userManager.FindByIdAsync(uid.ToString()) : null; + if (actor == null) + throw new ForbiddenException("Không xác định được approver."); + + var matchByDeptLevel = currentStep.DepartmentId != null + && currentStep.PositionLevel != null + && actor.DepartmentId == currentStep.DepartmentId + && actor.PositionLevel != null + && (int)actor.PositionLevel >= (int)currentStep.PositionLevel; + + var matchByExplicitUser = currentStep.Approvers.Any(a => + a.Kind == WorkflowApproverKind.User + && Guid.TryParse(a.AssignmentValue, out var auid) + && auid == actor.Id); + + var matchByRole = currentStep.Approvers.Any(a => + a.Kind == WorkflowApproverKind.Role + && actorRoles.Contains(a.AssignmentValue)); + + if (!matchByDeptLevel && !matchByExplicitUser && !matchByRole) throw new ForbiddenException( - "User phải có Phòng + Cấp chức danh (NV/PP/TP) để duyệt N-stage workflow."); - } - - var actorDept = actor.DepartmentId.Value; - var actorPos = actor.PositionLevel.Value; - var canBypass = actor.CanBypassReview; - - var inners = currentStepDef.InnerSteps.OrderBy(i => i.Order).ToList(); - var innerIds = inners.Select(i => i.Id).ToList(); - - var existingApprovals = await db.PurchaseEvaluationDepartmentApprovals - .Where(a => a.PurchaseEvaluationId == evaluation.Id - && a.PhaseAtApproval == (int)fromPhase - && a.InnerStepId != null - && innerIds.Contains(a.InnerStepId!.Value)) - .ToListAsync(ct); - var doneInnerIds = existingApprovals.Select(a => a.InnerStepId!.Value).ToHashSet(); - - var pendingRequired = inners.Where(i => i.IsRequired && !doneInnerIds.Contains(i.Id)).ToList(); - if (pendingRequired.Count > 0) - { - var firstPending = pendingRequired[0]; - - // Match: same dept AND (exact level OR canBypass + level higher) - var levelOk = actorPos == firstPending.PositionLevel - || (canBypass && (int)actorPos >= (int)firstPending.PositionLevel); - if (actorDept != firstPending.DepartmentId || !levelOk) - { - throw new ForbiddenException( - $"Cấp duyệt tiếp theo: phòng {firstPending.DepartmentId} cấp {firstPending.PositionLevel}. " + - $"Bạn (phòng {actorDept} cấp {actorPos}{(canBypass ? "+bypass" : "")}) không khớp."); - } - - // Determine rows to upsert - var rowsToCreate = new List<(PurchaseEvaluationWorkflowStepInnerStep i, bool bypassed)>(); - if (actorPos == firstPending.PositionLevel) - { - rowsToCreate.Add((firstPending, false)); - } - else - { - // Bypass cùng dept: upsert tất cả pending inner trong dept actor có level - // từ firstPending.PositionLevel đến actorPos (inclusive) - foreach (var inner in inners - .Where(i => i.DepartmentId == actorDept - && (int)i.PositionLevel >= (int)firstPending.PositionLevel - && (int)i.PositionLevel <= (int)actorPos - && !doneInnerIds.Contains(i.Id))) - { - rowsToCreate.Add((inner, inner.PositionLevel != actorPos)); - } - } - - var nowUtc = dateTime.UtcNow; - foreach (var (inner, bypassed) in rowsToCreate) - { - db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval - { - PurchaseEvaluationId = evaluation.Id, - PhaseAtApproval = (int)fromPhase, - DepartmentId = inner.DepartmentId, - Stage = ApprovalStage.Confirm, // N-stage: tất cả row dùng Confirm semantically - ApproverUserId = actorUid, - ApproverRoleSnapshot = $"{inner.PositionLevel}{(bypassed ? "(bypass)" : "")}", - Comment = comment, - ApprovedAt = nowUtc, - IsBypassed = bypassed, - InnerStepId = inner.Id, - }); - doneInnerIds.Add(inner.Id); - } - - // Recheck remaining pending sau upsert - var stillPending = inners.Any(i => i.IsRequired && !doneInnerIds.Contains(i.Id)); - if (stillPending) - { - // Còn cấp duyệt tiếp theo → BLOCK phase transition. - // Log Approval (review-style) + Changelog audit. - db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval - { - PurchaseEvaluationId = evaluation.Id, - FromPhase = fromPhase, - ToPhase = fromPhase, - ApproverUserId = actorUid, - Decision = ApprovalDecision.Approve, - Comment = $"[Inner step duyệt {actorPos}] {comment ?? ""}", - ApprovedAt = nowUtc, - }); - - string? reviewerName = actor.FullName ?? actor.Email; - db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog - { - PurchaseEvaluationId = evaluation.Id, - EntityType = PurchaseEvaluationEntityType.Workflow, - Action = ChangelogAction.Transition, - PhaseAtChange = fromPhase, - UserId = actorUid, - UserName = reviewerName ?? "Hệ thống", - Summary = $"{reviewerName} duyệt cấp {actorPos} phase {fromPhase} (còn {inners.Count(i => i.IsRequired && !doneInnerIds.Contains(i.Id))} cấp pending)", - ContextNote = comment, - }); - - await db.SaveChangesAsync(ct); - return; - } - // All required inner steps done → fall through to phase transition - } - // pendingRequired.Count == 0 → all already done before this call → fall through + $"Step {currentIdx + 1} ({currentStep.Name}) yêu cầu phòng={currentStep.DepartmentId}, cấp={currentStep.PositionLevel}. Bạn không khớp."); } - else if (actor?.DepartmentId is Guid deptId) + + // Log approval row + db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval { - // ===== Legacy 2-stage logic (Mig 16) — fallback khi step KHÔNG có InnerSteps ===== - var isManager = actorRoles.Contains(AppRoles.DeptManager); - var canBypass = actor.CanBypassReview; - var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review; - var isBypassed = !isManager && canBypass; - var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV"); + PurchaseEvaluationId = evaluation.Id, + FromPhase = fromPhase, + ToPhase = fromPhase, // step advance — phase same + ApproverUserId = actorUserId, + Decision = decision, + Comment = $"[Step {currentIdx + 1}] {comment ?? ""}", + ApprovedAt = dateTime.UtcNow, + }); - // Upsert: 1 row mỗi (PEId, phase, dept, stage). UNIQUE filtered InnerStepId IS NULL. - var existing = await db.PurchaseEvaluationDepartmentApprovals - .FirstOrDefaultAsync(a => - a.PurchaseEvaluationId == evaluation.Id - && a.PhaseAtApproval == (int)fromPhase - && a.DepartmentId == deptId - && a.Stage == stage - && a.InnerStepId == null, ct); - if (existing is null) - { - db.PurchaseEvaluationDepartmentApprovals.Add(new PurchaseEvaluationDepartmentApproval - { - PurchaseEvaluationId = evaluation.Id, - PhaseAtApproval = (int)fromPhase, - DepartmentId = deptId, - Stage = stage, - ApproverUserId = actorUid, - ApproverRoleSnapshot = roleSnapshot, - Comment = comment, - ApprovedAt = dateTime.UtcNow, - IsBypassed = isBypassed, - InnerStepId = null, - }); - } - else - { - existing.ApproverUserId = actorUid; - existing.ApproverRoleSnapshot = roleSnapshot; - existing.Comment = comment; - existing.ApprovedAt = dateTime.UtcNow; - existing.IsBypassed = isBypassed; - } - - // Check Stage=Confirm tồn tại cho (PEId, fromPhase, deptId) - var hasConfirm = stage == ApprovalStage.Confirm - || await db.PurchaseEvaluationDepartmentApprovals.AnyAsync(a => - a.PurchaseEvaluationId == evaluation.Id - && a.PhaseAtApproval == (int)fromPhase - && a.DepartmentId == deptId - && a.Stage == ApprovalStage.Confirm - && a.InnerStepId == null, ct); - - if (!hasConfirm) - { - // NV review xong, chưa có TPB confirm → BLOCK phase transition. - db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval - { - PurchaseEvaluationId = evaluation.Id, - FromPhase = fromPhase, - ToPhase = fromPhase, - ApproverUserId = actorUid, - Decision = ApprovalDecision.Approve, - Comment = $"[Review NV] {comment ?? ""}", - ApprovedAt = dateTime.UtcNow, - }); - - string? reviewerName = actor.FullName ?? actor.Email; - db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog - { - PurchaseEvaluationId = evaluation.Id, - EntityType = PurchaseEvaluationEntityType.Workflow, - Action = ChangelogAction.Transition, - PhaseAtChange = fromPhase, - UserId = actorUid, - UserName = reviewerName ?? "Hệ thống", - Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm", - ContextNote = comment, - }); - - // Notify TPB cùng dept để confirm. Best effort — fail OK. - try - { - var managers = await db.Users.AsNoTracking() - .Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive) - .Select(u => u.Id) - .ToListAsync(ct); - if (managers.Count > 0) - { - foreach (var mgrId in managers) - { - var mgr = await userManager.FindByIdAsync(mgrId.ToString()); - if (mgr is null) continue; - var roles = await userManager.GetRolesAsync(mgr); - if (!roles.Contains(AppRoles.DeptManager)) continue; - - await notifications.NotifyAsync( - mgrId, - NotificationType.ContractPhaseTransition, - title: $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} chờ TPB confirm", - description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.", - href: $"/purchase-evaluations/{evaluation.Id}", - refId: evaluation.Id, - ct: ct); - } - } - } - catch { /* notification fail non-critical */ } - - await db.SaveChangesAsync(ct); - return; - } + // Advance pointer + var nextIdx = currentIdx + 1; + if (nextIdx >= steps.Count) + { + // All steps done — terminal DaDuyet + evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; + evaluation.CurrentWorkflowStepIndex = null; + evaluation.SlaDeadline = null; + await LogTransitionAsync(evaluation, fromPhase, PurchaseEvaluationPhase.DaDuyet, actorUserId, decision, comment, ct); } + else + { + evaluation.CurrentWorkflowStepIndex = nextIdx; + evaluation.SlaDeadline = dateTime.UtcNow.AddDays(7); + await LogTransitionAsync(evaluation, fromPhase, fromPhase, actorUserId, decision, + $"Hoàn tất step {currentIdx + 1}/{steps.Count}, sang step {nextIdx + 1}", ct); + } + await db.SaveChangesAsync(ct); + return; } - evaluation.SlaWarningSent = false; - evaluation.Phase = targetPhase; - - var sla = policy.PhaseSla.GetValueOrDefault(targetPhase); - evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value); - - db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval + // Admin manual override (vd test cứng phase) + if (isAdmin) { - PurchaseEvaluationId = evaluation.Id, - FromPhase = fromPhase, - ToPhase = targetPhase, - ApproverUserId = actorUserId, - Decision = decision, - Comment = comment, - ApprovedAt = dateTime.UtcNow, - }); + evaluation.Phase = targetPhase; + evaluation.SlaDeadline = targetPhase == PurchaseEvaluationPhase.ChoDuyet + ? dateTime.UtcNow.AddDays(7) : null; + await LogTransitionAsync(evaluation, fromPhase, targetPhase, actorUserId, decision, comment, ct); + await db.SaveChangesAsync(ct); + return; + } - // Resolve actor name for changelog + throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); + } + + private async Task LogTransitionAsync( + PurchaseEvaluation evaluation, + PurchaseEvaluationPhase fromPhase, + PurchaseEvaluationPhase toPhase, + Guid? actorUserId, + ApprovalDecision decision, + string? comment, + CancellationToken ct) + { + // Save Approval history (đã làm trong main flow — chỉ log Changelog ở đây) string? actorName = null; if (actorUserId is Guid uid) { @@ -383,36 +214,34 @@ public class PurchaseEvaluationWorkflowService( PurchaseEvaluationId = evaluation.Id, EntityType = PurchaseEvaluationEntityType.Workflow, Action = ChangelogAction.Transition, - PhaseAtChange = targetPhase, + PhaseAtChange = toPhase, UserId = actorUserId, UserName = actorName ?? "Hệ thống", - Summary = $"Chuyển phase {fromPhase} → {targetPhase}", + Summary = $"Chuyển phase {fromPhase} → {toPhase}", ContextNote = comment, }); - // Notify drafter + // Notify drafter on terminal states if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId) { - var title = targetPhase switch + var (title, type) = toPhase switch { - PurchaseEvaluationPhase.DaDuyet => $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt", - PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối", - _ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới", - }; - var type = targetPhase switch - { - PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished, - PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected, - _ => NotificationType.ContractPhaseTransition, + PurchaseEvaluationPhase.DaDuyet => ($"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt", + NotificationType.ContractPublished), + PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối", + NotificationType.ContractRejected), + PurchaseEvaluationPhase.DangSoanThao when fromPhase == PurchaseEvaluationPhase.ChoDuyet => + ($"Phiếu {evaluation.TenGoiThau} bị trả lại — vui lòng sửa và trình lại", + NotificationType.ContractRejected), + _ => ($"Phiếu {evaluation.TenGoiThau} chuyển phase mới", + NotificationType.ContractPhaseTransition), }; await notifications.NotifyAsync( drafterId, type, title, - description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"), + description: $"{fromPhase} → {toPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"), href: $"/purchase-evaluations/{evaluation.Id}", refId: evaluation.Id, ct: ct); } - - await db.SaveChangesAsync(ct); } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkflowAdminTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkflowAdminTests.cs index 136f7ac..027b4d9 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkflowAdminTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkflowAdminTests.cs @@ -23,12 +23,14 @@ public class CreatePeWorkflowDefinitionCommandHandlerTests Description: null, Steps: new List { - 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 { 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 { new(Kind: (int)WorkflowApproverKind.Role, AssignmentValue: AppRoles.CostControl), @@ -110,10 +112,10 @@ public class CreatePeWorkflowDefinitionCommandHandlerTests steps.Should().HaveCount(2); 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[1].Order.Should().Be(2); - steps[1].Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + steps[1].Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); } [Fact] diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs deleted file mode 100644 index fa13dea..0000000 --- a/tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs +++ /dev/null @@ -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 -{ - private readonly IdentityFixture _fx; - private readonly TestApplicationDbContext _db; - private readonly UserManager _userManager; - private readonly ContractWorkflowService _service; - private readonly Guid _deptPro; - private readonly Guid _deptCcm; - - public ContractNStageApprovalTests(IdentityFixture fx) - { - _fx = fx; - _db = fx.Services.GetRequiredService(); - _userManager = fx.Services.GetRequiredService>(); - - _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 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 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(); - } - - [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 GenerateAsync(Contract contract, string projectCode, string supplierCode, - CancellationToken ct = default) => Task.FromResult($"FAKE-{projectCode}-{supplierCode}-001"); -} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs deleted file mode 100644 index aafec55..0000000 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs +++ /dev/null @@ -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 -{ - private readonly IdentityFixture _fx; - private readonly TestApplicationDbContext _db; - private readonly UserManager _userManager; - private readonly PurchaseEvaluationWorkflowService _service; - private readonly Guid _deptPro; - private readonly Guid _deptCcm; - - public PeNStageApprovalTests(IdentityFixture fx) - { - _fx = fx; - _db = fx.Services.GetRequiredService(); - _userManager = fx.Services.GetRequiredService>(); - - _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 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 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(); - } - - [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); - } -} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs deleted file mode 100644 index 2fa8ebc..0000000 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs +++ /dev/null @@ -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 -{ - private readonly IdentityFixture _fx; - private readonly TestApplicationDbContext _db; - private readonly UserManager _userManager; - private readonly PurchaseEvaluationWorkflowService _service; - private readonly Guid _deptPro; - private readonly Guid _deptCcm; - - public PeTwoStageApprovalTests(IdentityFixture fx) - { - _fx = fx; - _db = fx.Services.GetRequiredService(); - _userManager = fx.Services.GetRequiredService>(); - - // 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 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 userIds, NotificationType type, - string title, string? description = null, string? href = null, - Guid? refId = null, CancellationToken ct = default) => Task.CompletedTask; -}