diff --git a/fe-admin/src/components/pe/PeListPanel.tsx b/fe-admin/src/components/pe/PeListPanel.tsx index 14c3c9d..0180704 100644 --- a/fe-admin/src/components/pe/PeListPanel.tsx +++ b/fe-admin/src/components/pe/PeListPanel.tsx @@ -244,7 +244,7 @@ export function PeListPanel({ // hết, user vẫn thấy được phiếu Đã gửi duyệt cùng với tất cả khác. Trade-off // chấp nhận tới khi BE thêm multi-phase param. function statusToPhaseValue(status: PeDisplayStatus): string { - if (status === PeDisplayStatus.BanNhap) return String(PurchaseEvaluationPhase.DangSoanThao) + if (status === PeDisplayStatus.Nhap) return String(PurchaseEvaluationPhase.DangSoanThao) if (status === PeDisplayStatus.DaDuyet) return String(PurchaseEvaluationPhase.DaDuyet) if (status === PeDisplayStatus.TuChoi) return String(PurchaseEvaluationPhase.TuChoi) return '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE add support) diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx index b24f860..76e1bbe 100644 --- a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -122,10 +122,12 @@ export function PurchaseEvaluationsListPage() { setParam('phase', e.target.value)}> {Object.values(PeDisplayStatus).map(s => { - const phaseValue = s === PeDisplayStatus.BanNhap + const phaseValue = s === PeDisplayStatus.Nhap ? String(PurchaseEvaluationPhase.DangSoanThao) : s === PeDisplayStatus.DaDuyet ? String(PurchaseEvaluationPhase.DaDuyet) + : s === PeDisplayStatus.TraLai + ? String(PurchaseEvaluationPhase.TraLai) : s === PeDisplayStatus.TuChoi ? String(PurchaseEvaluationPhase.TuChoi) : '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE) diff --git a/fe-user/src/types/budget.ts b/fe-user/src/types/budget.ts index 5eb972c..b3cf732 100644 --- a/fe-user/src/types/budget.ts +++ b/fe-user/src/types/budget.ts @@ -7,15 +7,17 @@ export const BudgetPhase = { ChoCCM: 2, ChoCEO: 3, DaDuyet: 4, + TraLai: 98, TuChoi: 99, } as const export type BudgetPhase = typeof BudgetPhase[keyof typeof BudgetPhase] export const BudgetPhaseLabel: Record = { - 1: 'Đang soạn thảo', + 1: 'Nháp', 2: 'Chờ CCM', 3: 'Chờ CEO', 4: 'Đã duyệt', + 98: 'Trả lại', 99: 'Từ chối', } @@ -24,6 +26,7 @@ export const BudgetPhaseColor: Record = { 2: 'bg-indigo-100 text-indigo-700', 3: 'bg-pink-100 text-pink-700', 4: 'bg-emerald-100 text-emerald-700', + 98: 'bg-yellow-100 text-yellow-800', 99: 'bg-red-100 text-red-700', } diff --git a/fe-user/src/types/contracts.ts b/fe-user/src/types/contracts.ts index 9eb53e3..9025d16 100644 --- a/fe-user/src/types/contracts.ts +++ b/fe-user/src/types/contracts.ts @@ -8,6 +8,8 @@ export const ContractPhase = { DangTrinhKy: 7, DangDongDau: 8, DaPhatHanh: 9, + ChoDuyet: 10, + TraLai: 98, TuChoi: 99, } as const @@ -15,7 +17,7 @@ export type ContractPhase = typeof ContractPhase[keyof typeof ContractPhase] export const ContractPhaseLabel: Record = { 1: 'Đang chọn NCC', - 2: 'Đang soạn thảo', + 2: 'Nháp', 3: 'Đang góp ý', 4: 'Đang đàm phán', 5: 'Đang in ký', @@ -23,12 +25,14 @@ export const ContractPhaseLabel: Record = { 7: 'Đang trình ký', 8: 'Đang đóng dấu', 9: 'Đã phát hành', + 10: 'Đã gửi duyệt', + 98: 'Trả lại', 99: 'Từ chối', } export const ContractPhaseColor: Record = { 1: 'bg-slate-100 text-slate-700', - 2: 'bg-blue-100 text-blue-700', + 2: 'bg-slate-100 text-slate-700', 3: 'bg-amber-100 text-amber-700', 4: 'bg-orange-100 text-orange-700', 5: 'bg-purple-100 text-purple-700', @@ -36,6 +40,8 @@ export const ContractPhaseColor: Record = { 7: 'bg-fuchsia-100 text-fuchsia-700', 8: 'bg-pink-100 text-pink-700', 9: 'bg-emerald-100 text-emerald-700', + 10: 'bg-amber-100 text-amber-700', + 98: 'bg-yellow-100 text-yellow-800', 99: 'bg-red-100 text-red-700', } diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 0dea333..263ddf1 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -27,20 +27,20 @@ export const PurchaseEvaluationPhase = { ChoCEODuyetNCC: 6, // [LEGACY] DaDuyet: 7, ChoDuyet: 10, // [Mig 21] generic intermediate - TraLai: 98, // [LEGACY] + TraLai: 98, // Phase riêng — Drafter sửa+gửi lại chạy từ đầu TuChoi: 99, } as const export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase] export const PurchaseEvaluationPhaseLabel: Record = { - 1: 'Đang soạn thảo', + 1: 'Nháp', 2: 'Chờ Purchasing', 3: 'Chờ Dự án', 4: 'Chờ CCM', 5: 'Chờ CEO duyệt PA', 6: 'Chờ CEO duyệt NCC', 7: 'Đã duyệt', - 10: 'Đang duyệt', + 10: 'Đã gửi duyệt', 98: 'Trả lại', 99: 'Từ chối', } @@ -65,15 +65,14 @@ export function isEditablePhase(phase: number): boolean { || phase === PurchaseEvaluationPhase.TraLai } -// Display status meta — gom các phase chi tiết thành 4 nhóm hiển thị end-user -// friendly. Workflow timeline + workflow service vẫn dùng phase chi tiết. -// User 2026-05-07 chỉnh: -// - Bản nháp = DangSoanThao (chỉ hiện ở Thao tác workspace, ko Duyệt menu) -// - Đã gửi duyệt = bất kỳ phase trung gian (ChoPurchasing/ChoDuAn/ChoCCM/...) -// - Đã duyệt = DaDuyet -// - Từ chối = TuChoi +// Display status meta — 5 trạng thái spec Session 17: +// Nháp = DangSoanThao (chưa vào quy trình duyệt) +// Đã gửi duyệt = ChoDuyet/legacy intermediate (đang chạy quy trình) +// Trả lại = TraLai (có history đã đi qua quy trình, sửa+gửi lại chạy từ đầu) +// Đã duyệt = DaDuyet (terminal OK — input cho phiếu khác / in trình ký) +// Từ chối = TuChoi (terminal lock — không thao tác gì được nữa) export const PeDisplayStatus = { - BanNhap: 'BanNhap', + Nhap: 'Nhap', DaGuiDuyet: 'DaGuiDuyet', TraLai: 'TraLai', DaDuyet: 'DaDuyet', @@ -82,7 +81,7 @@ export const PeDisplayStatus = { export type PeDisplayStatus = typeof PeDisplayStatus[keyof typeof PeDisplayStatus] export const PeDisplayStatusLabel: Record = { - BanNhap: 'Bản nháp', + Nhap: 'Nháp', DaGuiDuyet: 'Đã gửi duyệt', TraLai: 'Trả lại', DaDuyet: 'Đã duyệt', @@ -90,7 +89,7 @@ export const PeDisplayStatusLabel: Record = { } export const PeDisplayStatusColor: Record = { - BanNhap: 'bg-slate-100 text-slate-700', + Nhap: 'bg-slate-100 text-slate-700', DaGuiDuyet: 'bg-amber-100 text-amber-700', TraLai: 'bg-yellow-100 text-yellow-800', DaDuyet: 'bg-emerald-100 text-emerald-700', @@ -98,7 +97,7 @@ export const PeDisplayStatusColor: Record = { } export function getPeDisplayStatus(phase: number): PeDisplayStatus { - if (phase === PurchaseEvaluationPhase.DangSoanThao) return PeDisplayStatus.BanNhap + if (phase === PurchaseEvaluationPhase.DangSoanThao) return PeDisplayStatus.Nhap if (phase === PurchaseEvaluationPhase.DaDuyet) return PeDisplayStatus.DaDuyet if (phase === PurchaseEvaluationPhase.TraLai) return PeDisplayStatus.TraLai if (phase === PurchaseEvaluationPhase.TuChoi) return PeDisplayStatus.TuChoi diff --git a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs index 03967ca..ea984e9 100644 --- a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs +++ b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs @@ -136,29 +136,24 @@ public class TransitionBudgetCommandHandler( var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct) ?? throw new NotFoundException("Budget", request.Id); - // ===== Smart reject + resume (Phase 9 — Migration 16) ===== + // ===== Reject → TraLai (Session 17 spec mới) ===== + // Bỏ smart-reject jump-back. Trả lại = Phase riêng (TraLai). + // Drafter từ TraLai gửi lại như Nháp — Policy `(TraLai, ChoCCM)` đã wire. + // Field RejectedFromPhase giữ DB column nhưng KHÔNG set value mới (data cũ vẫn đọc). var fromPhase = entity.Phase; var targetPhase = request.TargetPhase; - var isResumingAfterReject = request.Decision == ApprovalDecision.Approve - && fromPhase == BudgetPhase.DangSoanThao - && entity.RejectedFromPhase != null; - if (request.Decision == ApprovalDecision.Reject) + if (request.Decision == ApprovalDecision.Reject && targetPhase != BudgetPhase.TuChoi) { - entity.RejectedFromPhase = fromPhase; - targetPhase = BudgetPhase.DangSoanThao; - } - else if (isResumingAfterReject) - { - targetPhase = entity.RejectedFromPhase!.Value; - entity.RejectedFromPhase = null; + // Trả lại — override target → TraLai + targetPhase = BudgetPhase.TraLai; } var policy = BudgetPolicies.Default; var isAdmin = currentUser.Roles.Contains(AppRoles.Admin); - // Policy guard — bypass khi resume sau reject. - if (!isAdmin && !isResumingAfterReject + // Policy guard + if (!isAdmin && !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles)) throw new ForbiddenException( $"Role không đủ quyền chuyển {fromPhase} → {targetPhase}."); @@ -168,8 +163,8 @@ public class TransitionBudgetCommandHandler( // nhưng giữ consistent UX 3 module. if (request.Decision == ApprovalDecision.Approve && targetPhase != BudgetPhase.DangSoanThao + && targetPhase != BudgetPhase.TraLai && targetPhase != BudgetPhase.TuChoi - && !isResumingAfterReject && !isAdmin && currentUser.UserId is Guid actorUid) { diff --git a/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs index f5ba935..0d4c9ea 100644 --- a/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs @@ -74,7 +74,7 @@ public class GetWorkflowAdminOverviewQueryHandler( private static readonly Dictionary PhaseLabels = new() { - [ContractPhase.DangSoanThao] = "Đang soạn thảo", + [ContractPhase.DangSoanThao] = "Nháp", [ContractPhase.DangGopY] = "Đang góp ý", [ContractPhase.DangDamPhan] = "Đang đàm phán", [ContractPhase.DangInKy] = "Đang in ký", @@ -82,6 +82,9 @@ public class GetWorkflowAdminOverviewQueryHandler( [ContractPhase.DangTrinhKy] = "Đang trình ký", [ContractPhase.DangDongDau] = "Đang đóng dấu", [ContractPhase.DaPhatHanh] = "Đã phát hành", + [ContractPhase.ChoDuyet] = "Đã gửi duyệt", + [ContractPhase.TraLai] = "Trả lại", + [ContractPhase.TuChoi] = "Từ chối", }; public async Task Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct) diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs index 9e9143c..37f7b21 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs @@ -69,13 +69,16 @@ public class GetPeWorkflowAdminOverviewQueryHandler( private static readonly Dictionary PhaseLabels = new() { - [PurchaseEvaluationPhase.DangSoanThao] = "Đang soạn thảo", + [PurchaseEvaluationPhase.DangSoanThao] = "Nháp", [PurchaseEvaluationPhase.ChoPurchasing] = "Chờ Purchasing", [PurchaseEvaluationPhase.ChoDuAn] = "Chờ Dự án", [PurchaseEvaluationPhase.ChoCCM] = "Chờ CCM", [PurchaseEvaluationPhase.ChoCEODuyetPA] = "Chờ CEO duyệt PA", [PurchaseEvaluationPhase.ChoCEODuyetNCC] = "Chờ CEO duyệt NCC", + [PurchaseEvaluationPhase.ChoDuyet] = "Đã gửi duyệt", [PurchaseEvaluationPhase.DaDuyet] = "Đã duyệt", + [PurchaseEvaluationPhase.TraLai] = "Trả lại", + [PurchaseEvaluationPhase.TuChoi] = "Từ chối", }; public async Task Handle(GetPeWorkflowAdminOverviewQuery request, CancellationToken ct) diff --git a/src/Backend/SolutionErp.Domain/Budgets/BudgetPhase.cs b/src/Backend/SolutionErp.Domain/Budgets/BudgetPhase.cs index d3d727e..8349b4c 100644 --- a/src/Backend/SolutionErp.Domain/Budgets/BudgetPhase.cs +++ b/src/Backend/SolutionErp.Domain/Budgets/BudgetPhase.cs @@ -1,14 +1,17 @@ namespace SolutionErp.Domain.Budgets; -// State machine ngân sách — đơn giản 3 bước duyệt + 2 terminal. -// DangSoanThao → ChoCCM → ChoCEO → DaDuyet -// Bất kỳ phase duyệt → DangSoanThao (reject) -// DangSoanThao → TuChoi +// State machine ngân sách — Session 17 spec mới (5 trạng thái mirror PE/HĐ): +// DangSoanThao (Nháp) → ChoCCM (Drafter trình) +// TraLai (Trả lại) → ChoCCM (Drafter sửa+gửi lại, chạy từ đầu) +// ChoCCM/ChoCEO → next phase OR TraLai OR TuChoi +// ChoCEO → DaDuyet (terminal) +// DangSoanThao/TraLai → TuChoi (Drafter huỷ) public enum BudgetPhase { DangSoanThao = 1, ChoCCM = 2, ChoCEO = 3, DaDuyet = 4, + TraLai = 98, // Phase riêng (không revert DangSoanThao) TuChoi = 99, } diff --git a/src/Backend/SolutionErp.Domain/Budgets/BudgetPolicy.cs b/src/Backend/SolutionErp.Domain/Budgets/BudgetPolicy.cs index 4b947c3..02f0c0f 100644 --- a/src/Backend/SolutionErp.Domain/Budgets/BudgetPolicy.cs +++ b/src/Backend/SolutionErp.Domain/Budgets/BudgetPolicy.cs @@ -31,12 +31,15 @@ public static class BudgetPolicies private static readonly Dictionary DefaultSla = new() { [BudgetPhase.DangSoanThao] = TimeSpan.FromDays(5), + [BudgetPhase.TraLai] = TimeSpan.FromDays(5), [BudgetPhase.ChoCCM] = TimeSpan.FromDays(3), [BudgetPhase.ChoCEO] = TimeSpan.FromDays(2), [BudgetPhase.DaDuyet] = null, [BudgetPhase.TuChoi] = null, }; + // Session 17 spec: Reject = về TraLai (Phase riêng). Drafter từ TraLai + // gửi lại = entry point thứ 2 (mirror DangSoanThao → ChoCCM). public static readonly BudgetPolicy Default = new( Name: "Default", Description: "Quy trình ngân sách 3-step (Drafter → CCM → CEO).", @@ -44,17 +47,22 @@ public static class BudgetPolicies { [(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager], [(BudgetPhase.DangSoanThao, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(BudgetPhase.TraLai, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(BudgetPhase.TraLai, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], [(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO)] = [AppRoles.CostControl], - [(BudgetPhase.ChoCCM, BudgetPhase.DangSoanThao)] = [AppRoles.CostControl], + [(BudgetPhase.ChoCCM, BudgetPhase.TraLai)] = [AppRoles.CostControl], + [(BudgetPhase.ChoCCM, BudgetPhase.TuChoi)] = [AppRoles.CostControl], [(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(BudgetPhase.ChoCEO, BudgetPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(BudgetPhase.ChoCEO, BudgetPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(BudgetPhase.ChoCEO, BudgetPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], }, PhaseSla: DefaultSla, ActivePhases: [ BudgetPhase.DangSoanThao, + BudgetPhase.TraLai, BudgetPhase.ChoCCM, BudgetPhase.ChoCEO, BudgetPhase.DaDuyet, diff --git a/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs b/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs index 4f9f288..a0cc97f 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs @@ -1,25 +1,28 @@ namespace SolutionErp.Domain.Contracts; -// 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á) +// State machine HĐ — Session 17 spec mới (5 trạng thái): +// DangSoanThao (Nháp) ──Drafter trình──► ChoDuyet +// TraLai (Trả lại) ──Drafter sửa+gửi lại──► ChoDuyet (chạy lại từ Cấp 1 Bước 1) +// ChoDuyet (Đã gửi duyệt) ──advance step pointer──► ChoDuyet +// ──last step done──────────► DaPhatHanh (terminal + gen mã HĐ) +// ──Approver Trả lại────────► TraLai +// ──Approver Từ chối────────► TuChoi (terminal) // // LEGACY values (DangChon, DangGopY, DangDamPhan, DangInKy, DangKiemTraCCM, // DangTrinhKy, DangDongDau) deprecated post-Mig 21 — giữ enum cho data cũ. +// TraLai=98: Session 17 thêm mới — Trả lại là Phase RIÊNG (mirror PE). public enum ContractPhase { DangChon = 1, // [LEGACY] - DangSoanThao = 2, + DangSoanThao = 2, // Nháp 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á + DaPhatHanh = 9, // Đã duyệt (cho HĐ — gen mã + phát hành) — terminal thành công + ChoDuyet = 10, // Đã gửi duyệt — generic intermediate, CurrentWorkflowStepIndex tracking + TraLai = 98, // Trả lại — Phase riêng, Drafter sửa rồi gửi lại chạy từ đầu + TuChoi = 99, // Từ chối — terminal khoá } diff --git a/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs b/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs index 4122450..94d4f48 100644 --- a/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs +++ b/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs @@ -52,6 +52,7 @@ public static class WorkflowPolicies private static readonly Dictionary DefaultSla = new() { [ContractPhase.DangSoanThao] = TimeSpan.FromDays(7), + [ContractPhase.TraLai] = TimeSpan.FromDays(7), [ContractPhase.DangGopY] = TimeSpan.FromDays(7), [ContractPhase.DangDamPhan] = TimeSpan.FromDays(7), [ContractPhase.DangInKy] = TimeSpan.FromDays(1), @@ -65,6 +66,8 @@ public static class WorkflowPolicies // ===== STANDARD: 9-phase formal workflow ===== // Per QT-TP-NCC.docx: Thầu phụ / NCC / Tổ đội — full CCM review required. + // Session 17: Reject = về TraLai (Phase riêng). Drafter từ TraLai gửi lại + // = entry point thứ 2 (mirror DangSoanThao → DangGopY). public static readonly WorkflowPolicy Standard = new( Name: "Standard", Description: "Quy trình đầy đủ 8 phase — CCM kiểm tra + BOD duyệt. Áp dụng HĐ Thầu phụ / NCC / Giao khoán.", @@ -72,26 +75,28 @@ public static class WorkflowPolicies { [(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager], [(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(ContractPhase.TraLai, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(ContractPhase.TraLai, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], [(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment], + [(ContractPhase.DangGopY, ContractPhase.TraLai)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment], [(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager], [(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager], [(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl], - [(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl], + [(ContractPhase.DangKiemTraCCM, ContractPhase.TraLai)] = [AppRoles.CostControl], [(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(ContractPhase.DangTrinhKy, ContractPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin], }, PhaseSla: DefaultSla, ActivePhases: [ - ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan, + ContractPhase.DangSoanThao, ContractPhase.TraLai, ContractPhase.DangGopY, ContractPhase.DangDamPhan, ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy, ContractPhase.DangDongDau, ContractPhase.DaPhatHanh, ContractPhase.TuChoi, ]); @@ -106,9 +111,11 @@ public static class WorkflowPolicies { [(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager], [(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(ContractPhase.TraLai, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(ContractPhase.TraLai, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], [(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager], - [(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment], + [(ContractPhase.DangGopY, ContractPhase.TraLai)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment], [(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager], @@ -116,14 +123,14 @@ public static class WorkflowPolicies [(ContractPhase.DangInKy, ContractPhase.DangTrinhKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager], [(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(ContractPhase.DangTrinhKy, ContractPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin], }, PhaseSla: DefaultSla, ActivePhases: [ - ContractPhase.DangSoanThao, ContractPhase.DangGopY, ContractPhase.DangDamPhan, + ContractPhase.DangSoanThao, ContractPhase.TraLai, ContractPhase.DangGopY, ContractPhase.DangDamPhan, ContractPhase.DangInKy, ContractPhase.DangTrinhKy, ContractPhase.DangDongDau, ContractPhase.DaPhatHanh, ContractPhase.TuChoi, ]); @@ -213,22 +220,35 @@ public static class WorkflowPolicyRegistry transitions[(prev.Value, s.Phase)] = roles; if (userIds.Length > 0) userTransitions[(prev.Value, s.Phase)] = userIds; - // Reject path back to Drafter (common pattern QT docx) + // Mirror: TraLai → s.Phase (Drafter resubmit từ Trả lại = entry point thứ 2) + if (prev.Value == ContractPhase.DangSoanThao) + { + transitions.TryAdd((ContractPhase.TraLai, s.Phase), roles); + if (userIds.Length > 0) + userTransitions.TryAdd((ContractPhase.TraLai, s.Phase), userIds); + } + + // Reject path → TraLai (Phase riêng, không revert DangSoanThao) if (prev.Value != ContractPhase.DangSoanThao && s.Phase != ContractPhase.DangSoanThao) { - transitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), roles); + transitions.TryAdd((s.Phase, ContractPhase.TraLai), roles); if (userIds.Length > 0) - userTransitions.TryAdd((s.Phase, ContractPhase.DangSoanThao), userIds); + userTransitions.TryAdd((s.Phase, ContractPhase.TraLai), userIds); } } prev = s.Phase; } - // First step có thể reject to TuChoi + // First step có thể reject to TuChoi (cả Nháp + Trả lại) if (steps.Count > 0) + { transitions.TryAdd((steps[0].Phase, ContractPhase.TuChoi), [AppRoles.Drafter, AppRoles.DeptManager]); + transitions.TryAdd((ContractPhase.TraLai, ContractPhase.TuChoi), + [AppRoles.Drafter, AppRoles.DeptManager]); + } if (!activePhases.Contains(ContractPhase.TuChoi)) activePhases.Add(ContractPhase.TuChoi); + if (!activePhases.Contains(ContractPhase.TraLai)) activePhases.Add(ContractPhase.TraLai); return new WorkflowPolicy( Name: $"{def.Code}-v{def.Version:D2}", diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs index c69241b..9899e63 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPhase.cs @@ -1,25 +1,27 @@ namespace SolutionErp.Domain.PurchaseEvaluations; -// 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) +// State machine PE — Session 17 spec mới (5 trạng thái): +// DangSoanThao (Nháp) ──Drafter trình──► ChoDuyet +// TraLai (Trả lại) ──Drafter sửa+gửi lại──► ChoDuyet (chạy lại từ Cấp 1 Bước 1) +// ChoDuyet (Đã gửi duyệt) ──advance step pointer──► ChoDuyet +// ──last step done──────────► DaDuyet (terminal) +// ──Approver Trả lại────────► TraLai +// ──Approver Từ chối────────► TuChoi (terminal) // DangSoanThao → TuChoi (Drafter huỷ trước trình) // -// 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). +// LEGACY values 2-6 deprecated post-Mig 21 (data cũ vẫn đọc OK). +// TraLai=98: Session 17 restore làm primary state — Trả lại là Phase RIÊNG, +// không revert về DangSoanThao như Mig 21. public enum PurchaseEvaluationPhase { - DangSoanThao = 1, - 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 - 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 + DangSoanThao = 1, // Nháp + ChoPurchasing = 2, // [LEGACY] deprecated + ChoDuAn = 3, // [LEGACY] deprecated + ChoCCM = 4, // [LEGACY] deprecated + ChoCEODuyetPA = 5, // [LEGACY] deprecated + ChoCEODuyetNCC = 6, // [LEGACY] deprecated + DaDuyet = 7, // Đã duyệt — terminal thành công + ChoDuyet = 10, // Đã gửi duyệt — generic intermediate, CurrentWorkflowStepIndex tracking + TraLai = 98, // Trả lại — Phase riêng, Drafter sửa rồi gửi lại chạy từ đầu + TuChoi = 99, // Từ chối — terminal khoá phiếu, KHÔNG cho edit/thao tác } diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs index 67a8d2b..b8a7442 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationPolicy.cs @@ -38,6 +38,7 @@ public static class PurchaseEvaluationPolicies private static readonly Dictionary DefaultSla = new() { [PurchaseEvaluationPhase.DangSoanThao] = TimeSpan.FromDays(3), + [PurchaseEvaluationPhase.TraLai] = TimeSpan.FromDays(3), [PurchaseEvaluationPhase.ChoPurchasing] = TimeSpan.FromDays(2), [PurchaseEvaluationPhase.ChoDuAn] = TimeSpan.FromDays(2), [PurchaseEvaluationPhase.ChoCCM] = TimeSpan.FromDays(2), @@ -48,33 +49,37 @@ public static class PurchaseEvaluationPolicies }; // A — DuyetNcc (3 step thực + Drafter soạn): Drafter → Purchasing → CCM → CEO + // Session 17 spec: Reject = về TraLai (Phase riêng, không revert DangSoanThao). + // Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao → ChoPurchasing). public static readonly PurchaseEvaluationPolicy NccOnly = new( Name: "NccOnly", Description: "Duyệt NCC — 3 step (Purchasing → CCM → CEO). Không cần duyệt phương án.", Transitions: new Dictionary<(PurchaseEvaluationPhase, PurchaseEvaluationPhase), string[]> { + // Drafter trình từ Nháp HOẶC gửi lại từ Trả lại — cùng entry point. [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], - // Phase trung gian: 3 hành động — Duyệt forward / Trả lại Drafter (DangSoanThao) / Từ chối hoàn toàn (TuChoi). - // Trả lại = smart reject pattern Mig 16 (set RejectedFromPhase + về DangSoanThao + Drafter sửa). - // Từ chối = phiếu khoá hoàn toàn (Phase=TuChoi → 17 handler Mig 16 lock edit). + // Phase trung gian: 3 hành động — Duyệt forward / Trả lại (TraLai Phase riêng) / Từ chối (TuChoi terminal). [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.Procurement], - [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Procurement], + [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.CostControl], - [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.CostControl], + [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], }, PhaseSla: DefaultSla, ActivePhases: [ PurchaseEvaluationPhase.DangSoanThao, + PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetNCC, @@ -90,32 +95,35 @@ public static class PurchaseEvaluationPolicies { [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], [(PurchaseEvaluationPhase.DangSoanThao, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing)] = [AppRoles.Drafter, AppRoles.DeptManager], + [(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager], - // Phase trung gian: 3 hành động — Duyệt forward / Trả lại / Từ chối (xem comment NccOnly). + // Phase trung gian: 3 hành động — Duyệt forward / Trả lại (TraLai) / Từ chối (TuChoi). [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn)] = [AppRoles.Procurement], - [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Procurement], + [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Procurement], [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.ChoCCM)] = [AppRoles.ProjectManager], - [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.ProjectManager], + [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.TraLai)] = [AppRoles.ProjectManager], [(PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.ProjectManager], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA)] = [AppRoles.CostControl], - [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.CostControl], + [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.CostControl], [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner], - [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner], + [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TraLai)] = [AppRoles.Director, AppRoles.AuthorizedSigner], [(PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.TuChoi)] = [AppRoles.Director, AppRoles.AuthorizedSigner], }, PhaseSla: DefaultSla, ActivePhases: [ PurchaseEvaluationPhase.DangSoanThao, + PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn, PurchaseEvaluationPhase.ChoCCM, @@ -183,30 +191,45 @@ public static class PurchaseEvaluationPolicyRegistry transitions[(prev.Value, s.Phase)] = roles; if (userIds.Length > 0) userTransitions[(prev.Value, s.Phase)] = userIds; - // 3 hành động phase trung gian — Duyệt forward + Trả lại Drafter + Từ chối hoàn toàn + // Mirror: TraLai → s.Phase cho cả Drafter (resubmit từ Trả lại = entry point thứ 2) + if (prev.Value == PurchaseEvaluationPhase.DangSoanThao) + { + transitions.TryAdd((PurchaseEvaluationPhase.TraLai, s.Phase), roles); + if (userIds.Length > 0) + userTransitions.TryAdd((PurchaseEvaluationPhase.TraLai, s.Phase), userIds); + } + + // 3 hành động phase trung gian — Duyệt forward + Trả lại (TraLai) + Từ chối (TuChoi) if (prev.Value != PurchaseEvaluationPhase.DangSoanThao && s.Phase != PurchaseEvaluationPhase.DangSoanThao) { - transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.DangSoanThao), roles); + transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TraLai), roles); transitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TuChoi), roles); if (userIds.Length > 0) { - userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.DangSoanThao), userIds); + userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TraLai), userIds); userTransitions.TryAdd((s.Phase, PurchaseEvaluationPhase.TuChoi), userIds); } } } prev = s.Phase; } - // First step (DangSoanThao) — Drafter có thể TuChoi (huỷ phiếu) + // First step (DangSoanThao) — Drafter có thể TuChoi (huỷ phiếu). + // Tương tự cho TraLai — Drafter có thể TuChoi luôn từ Trả lại. if (steps.Count > 0) + { transitions.TryAdd((steps[0].Phase, PurchaseEvaluationPhase.TuChoi), [AppRoles.Drafter, AppRoles.DeptManager]); + transitions.TryAdd((PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.TuChoi), + [AppRoles.Drafter, AppRoles.DeptManager]); + } - // Terminal states always available + // Terminal states always available + TraLai phase if (!activePhases.Contains(PurchaseEvaluationPhase.TuChoi)) activePhases.Add(PurchaseEvaluationPhase.TuChoi); if (!activePhases.Contains(PurchaseEvaluationPhase.DaDuyet)) activePhases.Add(PurchaseEvaluationPhase.DaDuyet); + if (!activePhases.Contains(PurchaseEvaluationPhase.TraLai)) + activePhases.Add(PurchaseEvaluationPhase.TraLai); return new PurchaseEvaluationPolicy( Name: $"{def.Code}-v{def.Version:D2}", diff --git a/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs b/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs index 8be6acf..27e0aca 100644 --- a/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs +++ b/src/Backend/SolutionErp.Infrastructure/Reports/ContractExcelExporter.cs @@ -12,7 +12,7 @@ public class ContractExcelExporter(IApplicationDbContext db, IDateTime dateTime) private static readonly Dictionary PhaseLabel = new() { [ContractPhase.DangChon] = "Đang chọn NCC", - [ContractPhase.DangSoanThao] = "Đang soạn thảo", + [ContractPhase.DangSoanThao] = "Nháp", [ContractPhase.DangGopY] = "Đang góp ý", [ContractPhase.DangDamPhan] = "Đang đàm phán", [ContractPhase.DangInKy] = "Đang in ký", @@ -20,6 +20,8 @@ public class ContractExcelExporter(IApplicationDbContext db, IDateTime dateTime) [ContractPhase.DangTrinhKy] = "Đang trình ký", [ContractPhase.DangDongDau] = "Đang đóng dấu", [ContractPhase.DaPhatHanh] = "Đã phát hành", + [ContractPhase.ChoDuyet] = "Đã gửi duyệt", + [ContractPhase.TraLai] = "Trả lại", [ContractPhase.TuChoi] = "Từ chối", }; diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index b2addf9..be04890 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -11,10 +11,15 @@ using SolutionErp.Domain.Notifications; namespace SolutionErp.Infrastructure.Services; -// 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. +// Contract Workflow Service — Session 17 spec mới (state machine 5 trạng thái): +// Nháp (DangSoanThao) ──trình──► Đã gửi duyệt (ChoDuyet) ─approve cấp cuối─► Đã phát hành (DaPhatHanh, terminal + gen mã) +// ├─ Trả lại ────────► Trả lại (TraLai) +// └─ Từ chối ────────► Từ chối (TuChoi, terminal) +// Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back) +// +// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại = Phase RIÊNG +// (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại như +// case Nháp — workflow chạy lại từ Cấp 1 Bước 1. public class ContractWorkflowService( IApplicationDbContext db, IContractCodeGenerator codeGenerator, @@ -48,9 +53,9 @@ public class ContractWorkflowService( } else { - contract.RejectedFromPhase = fromPhase; - contract.RejectedAtStepIndex = contract.CurrentWorkflowStepIndex; - contract.Phase = ContractPhase.DangSoanThao; + // Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao). + // Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1. + contract.Phase = ContractPhase.TraLai; contract.CurrentWorkflowStepIndex = null; } contract.SlaDeadline = null; @@ -59,25 +64,9 @@ public class ContractWorkflowService( return; } - // ===== RESUME AFTER REJECT ===== - var isResumingAfterReject = decision == ApprovalDecision.Approve - && fromPhase == ContractPhase.DangSoanThao - && contract.RejectedAtStepIndex != null; - - if (isResumingAfterReject) - { - 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; - } - - // ===== DRAFTER TRÌNH ===== - if (fromPhase == ContractPhase.DangSoanThao + // ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) ===== + // Cả 2 entry point cùng logic: chạy lại từ đầu (CurrentWorkflowStepIndex=0). + if ((fromPhase == ContractPhase.DangSoanThao || fromPhase == ContractPhase.TraLai) && (targetPhase == ContractPhase.ChoDuyet || (!isAdmin && !isSystem))) { if (!isAdmin && !isSystem @@ -213,7 +202,7 @@ public class ContractWorkflowService( NotificationType.ContractPublished), ContractPhase.TuChoi => ($"HĐ {contract.TenHopDong ?? "của bạn"} bị từ chối", NotificationType.ContractRejected), - ContractPhase.DangSoanThao when fromPhase == ContractPhase.ChoDuyet => + ContractPhase.TraLai 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", diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index c3c970f..9a5cdfe 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -12,11 +12,17 @@ using SolutionErp.Domain.PurchaseEvaluations; namespace SolutionErp.Infrastructure.Services; -// 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. +// PE Workflow Service — Session 17 spec mới (state machine 5 trạng thái): +// Nháp (DangSoanThao) ──trình──► Đã gửi duyệt (ChoDuyet) ─approve cấp cuối─► Đã duyệt (DaDuyet, terminal) +// ├─ Trả lại ────────► Trả lại (TraLai) +// └─ Từ chối ────────► Từ chối (TuChoi, terminal) +// Trả lại ──Drafter sửa+gửi lại──► Đã gửi duyệt (chạy LẠI từ đầu, KHÔNG jump-back) +// +// Khác Mig 21 (Session 16): bỏ smart-reject jump-back. Trả lại giờ là Phase +// RIÊNG (TraLai=98), không revert về DangSoanThao. Drafter từ TraLai gửi lại +// như case Nháp — workflow chạy lại từ Cấp 1 Bước 1. Field RejectedAtStepIndex +// + RejectedFromPhase giữ DB column (nullable, không set value mới) cho data +// cũ — sẽ cleanup migration sau. public class PurchaseEvaluationWorkflowService( IApplicationDbContext db, IDateTime dateTime, @@ -49,10 +55,9 @@ public class PurchaseEvaluationWorkflowService( } else { - // Trả lại — về DangSoanThao + save RejectedAtStepIndex (resume jump-back). - evaluation.RejectedFromPhase = fromPhase; - evaluation.RejectedAtStepIndex = evaluation.CurrentWorkflowStepIndex; - evaluation.Phase = PurchaseEvaluationPhase.DangSoanThao; + // Trả lại — Phase=TraLai RIÊNG (không revert về DangSoanThao). + // Drafter sửa từ TraLai rồi gửi lại sẽ chạy lại từ Cấp 1 Bước 1. + evaluation.Phase = PurchaseEvaluationPhase.TraLai; evaluation.CurrentWorkflowStepIndex = null; } evaluation.SlaDeadline = null; @@ -61,25 +66,10 @@ public class PurchaseEvaluationWorkflowService( return; } - // ===== RESUME AFTER REJECT (Drafter trình lại) ===== - var isResumingAfterReject = decision == ApprovalDecision.Approve - && fromPhase == PurchaseEvaluationPhase.DangSoanThao - && evaluation.RejectedAtStepIndex != null; - - if (isResumingAfterReject) - { - 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; - } - - // ===== DRAFTER TRÌNH (DangSoanThao → ChoDuyet) ===== - if (fromPhase == PurchaseEvaluationPhase.DangSoanThao + // ===== DRAFTER TRÌNH/GỬI LẠI (Nháp HOẶC Trả lại → ChoDuyet) ===== + // Cả 2 entry point cùng logic: chạy lại từ đầu (CurrentWorkflowStepIndex=0). + if ((fromPhase == PurchaseEvaluationPhase.DangSoanThao + || fromPhase == PurchaseEvaluationPhase.TraLai) && (targetPhase == PurchaseEvaluationPhase.ChoDuyet || !isAdmin && !isSystem)) { // Drafter/DeptManager only (or Admin bypass). @@ -230,7 +220,7 @@ public class PurchaseEvaluationWorkflowService( NotificationType.ContractPublished), PurchaseEvaluationPhase.TuChoi => ($"Phiếu {evaluation.TenGoiThau} bị từ chối", NotificationType.ContractRejected), - PurchaseEvaluationPhase.DangSoanThao when fromPhase == PurchaseEvaluationPhase.ChoDuyet => + PurchaseEvaluationPhase.TraLai 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", diff --git a/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs b/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs index af71068..a4f6dba 100644 --- a/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs +++ b/tests/SolutionErp.Domain.Tests/Budgets/BudgetPolicyTests.cs @@ -45,11 +45,11 @@ public class BudgetPolicyTests } [Fact] - public void Default_CostControl_ChoCCM_To_DangSoanThao_Allowed() + public void Default_CostControl_ChoCCM_To_TraLai_Allowed() { - // Trả về Drafter + // Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao BudgetPolicies.Default - .IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.DangSoanThao, + .IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.TraLai, [AppRoles.CostControl]) .Should().BeTrue(); } @@ -93,11 +93,12 @@ public class BudgetPolicyTests } [Fact] - public void Default_ActivePhases_Includes_All5States() + public void Default_ActivePhases_Includes_All6States() { + // Session 17: thêm TraLai = Phase riêng cho Trả lại BudgetPolicies.Default.ActivePhases.Should().BeEquivalentTo(new[] { - BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM, + BudgetPhase.DangSoanThao, BudgetPhase.TraLai, BudgetPhase.ChoCCM, BudgetPhase.ChoCEO, BudgetPhase.DaDuyet, BudgetPhase.TuChoi, }); } @@ -111,11 +112,21 @@ public class BudgetPolicyTests } [Fact] - public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_DangSoanThao() + public void Default_NextPhasesFrom_TraLai_Includes_ChoCCM_And_TuChoi() + { + // Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao) + var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TraLai); + next.Should().Contain(BudgetPhase.ChoCCM); + next.Should().Contain(BudgetPhase.TuChoi); + } + + [Fact] + public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_TraLai() { var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.ChoCEO); next.Should().Contain(BudgetPhase.DaDuyet); - next.Should().Contain(BudgetPhase.DangSoanThao); + next.Should().Contain(BudgetPhase.TraLai); + next.Should().Contain(BudgetPhase.TuChoi); } // SLA — chống regression khi đổi phase deadline accidentally diff --git a/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs b/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs index 3d3242f..dcb90bc 100644 --- a/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs +++ b/tests/SolutionErp.Domain.Tests/Contracts/WorkflowPolicyTests.cs @@ -90,12 +90,23 @@ public class WorkflowPolicyTests } [Fact] - public void Standard_RejectFromCCM_GoesBackToDraft() + public void Standard_RejectFromCCM_GoesTo_TraLai() { + // Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao WorkflowPolicies.Standard - .IsTransitionAllowed(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao, + .IsTransitionAllowed(ContractPhase.DangKiemTraCCM, ContractPhase.TraLai, [AppRoles.CostControl]) - .Should().BeTrue("CCM có quyền reject về Drafter"); + .Should().BeTrue("CCM có quyền reject về TraLai"); + } + + [Fact] + public void Standard_TraLai_To_DangGopY_Allowed_For_Drafter() + { + // Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao → DangGopY) + WorkflowPolicies.Standard + .IsTransitionAllowed(ContractPhase.TraLai, ContractPhase.DangGopY, + [AppRoles.Drafter]) + .Should().BeTrue("Drafter resubmit từ Trả lại"); } [Fact] diff --git a/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs b/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs index 17e36e1..c12095d 100644 --- a/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs +++ b/tests/SolutionErp.Domain.Tests/PurchaseEvaluations/PurchaseEvaluationPolicyTests.cs @@ -131,17 +131,33 @@ public class PurchaseEvaluationPolicyTests [Theory] [InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))] [InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))] - public void BothPolicies_RejectFromCCM_GoesBackTo_DangSoanThao(string policyName) + public void BothPolicies_RejectFromCCM_GoesTo_TraLai(string policyName) { + // Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly) ? PurchaseEvaluationPolicies.NccOnly : PurchaseEvaluationPolicies.NccWithPlan; - policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.DangSoanThao, + policy.IsTransitionAllowed(PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.TraLai, [AppRoles.CostControl]) .Should().BeTrue(); } + [Theory] + [InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))] + [InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))] + public void BothPolicies_TraLai_To_ChoPurchasing_AllowedForDrafter(string policyName) + { + // Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao) + var policy = policyName == nameof(PurchaseEvaluationPolicies.NccOnly) + ? PurchaseEvaluationPolicies.NccOnly + : PurchaseEvaluationPolicies.NccWithPlan; + + policy.IsTransitionAllowed(PurchaseEvaluationPhase.TraLai, PurchaseEvaluationPhase.ChoPurchasing, + [AppRoles.Drafter]) + .Should().BeTrue(); + } + [Theory] [InlineData(nameof(PurchaseEvaluationPolicies.NccOnly))] [InlineData(nameof(PurchaseEvaluationPolicies.NccWithPlan))]