From ad9a21ed3041f7b3e0a7fa767788ab1ceb8b0870 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 10 Aug 2026 19:46:33 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Contract:=20W4+W6BE=20guard=204-c?= =?UTF-8?q?=E1=BB=ADa=20pin/re-pin=20nh=C3=A0=20chung=20(kho=C3=A1=20c?= =?UTF-8?q?=E1=BB=A9ng=20theo=20lo=E1=BA=A1i,=20bridge=20auto-pin=20nullab?= =?UTF-8?q?le)=20+=20=C4=902-guard=20=C4=91=C3=BAng-tr=E1=BA=A1m=20V2=20(V?= =?UTF-8?q?1=20gi=E1=BB=AF=20nguy=C3=AAn)=20+=20test-before=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../ContractSigningPlansController.cs | 5 +- .../CreateContractFromSigningPlanFeatures.cs | 75 ++++-- .../Contracts/ContractFeatures.cs | 61 +++-- .../Contracts/ContractWorkflowGuards.cs | 169 ++++++++++++ .../Services/ContractWorkflowService.cs | 138 ++++++++++ .../ContractWorkflowServiceApproveV2Tests.cs | 252 ++++++++++++++++++ 6 files changed, 661 insertions(+), 39 deletions(-) create mode 100644 src/Backend/SolutionErp.Application/Contracts/ContractWorkflowGuards.cs diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs index 0b66712..5f190b1 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs @@ -350,9 +350,12 @@ public class ContractSigningPlansController( // [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật). // `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng") // thay vì 500 NullReference ở tầng dưới. + // [S187 W4] `ApprovalWorkflowId` NULLABLE: client không gửi ⇒ máy tự pin quy trình của đúng + // loại HĐ (họ `HD-*`). FE hiện VẪN gửi (dialog bắt chọn) ⇒ payload cũ chạy y nguyên; đây là + // nới rộng, không phải đổi hợp-đồng. Gửi bậy vẫn 409 với câu nêu đích danh mã quy trình. public record CreateContractFromSigningPlanBody( List? LineIds, ContractType ContractType, - Guid ApprovalWorkflowId, + Guid? ApprovalWorkflowId = null, string? TenHopDong = null); } diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs index b721760..94c0af7 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs @@ -4,6 +4,10 @@ using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Contracts.Services; +// [S187 W4] Alias 1 lớp thay vì mở trọn `SolutionErp.Application.Contracts`: file này đã mở +// `SolutionErp.Domain.Contracts` (Contract, ContractType, ContractChangelog…) — hai namespace +// cùng tên đuôi mà kéo cả hai vào là mời va-chạm tên về sau. +using ContractWorkflowGuards = SolutionErp.Application.Contracts.ContractWorkflowGuards; using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ContractSigningPlans; using SolutionErp.Domain.Contracts; @@ -44,14 +48,17 @@ public record CreateContractFromSigningPlanResult(Guid ContractId, string? MaHop /// truyền đúng 1 phần tử. `GiaTri` = Σ `ApprovedAmount` của ĐÚNG tập này — không hơn không kém. /// /// -/// Quy trình duyệt HĐ (V2, `ApplicableType = Contract`). BẮT BUỘC — xem chú thích đầu file -/// điểm 2: không suy được từ `ContractType`. +/// Quy trình duyệt HĐ (V2, `ApplicableType = Contract`). +/// [S187 W4] Từ NULLABLE: bỏ trống ⇒ máy tự pin quy trình của đúng loại HĐ theo họ `HD-*` +/// (86 khoá cứng + 81 PA-A). Trước W4 field này BẮT BUỘC vì `ApplicableType=Contract` là +/// "HĐ general (any ContractType)" nên không suy được từ `ContractType` — họ mã `HD-*` (W3) +/// vừa dựng xong chính là thứ khiến việc suy trở nên có thật. /// public record CreateContractFromSigningPlanCommand( Guid PlanId, List LineIds, ContractType ContractType, - Guid ApprovalWorkflowId, + Guid? ApprovalWorkflowId = null, string? TenHopDong = null) : IRequest; public class CreateContractFromSigningPlanCommandValidator @@ -60,7 +67,10 @@ public class CreateContractFromSigningPlanCommandValidator public CreateContractFromSigningPlanCommandValidator() { RuleFor(x => x.PlanId).NotEmpty(); - RuleFor(x => x.ApprovalWorkflowId).NotEmpty(); + // [S187 W4] `ApprovalWorkflowId` KHÔNG còn `NotEmpty()`: bỏ trống nay là một lựa chọn hợp + // lệ (máy tự pin theo loại). `NotEmpty()` trên `Guid?` chặn CẢ `null` ⇒ giữ lại thì nhánh + // auto-resolve chết trước khi handler chạy. Ca "chọn nhưng chọn bậy" do handler bắt bằng + // câu tiếng Việt nêu đích danh mã quy trình — validator chỉ nói được "must not be empty". RuleFor(x => x.ContractType).IsInEnum(); RuleFor(x => x.LineIds) .NotEmpty().WithMessage("Chọn ít nhất 1 dòng của kế hoạch để đưa vào Hợp đồng."); @@ -129,23 +139,42 @@ public class CreateContractFromSigningPlanCommandHandler( throw new ConflictException($"Dòng '{ten}' đã có Hợp đồng."); } - // ---- Rào (6) WORKFLOW PIN — 4 vế. 2 vế đầu = khuôn `ContractFeatures.cs:79-87`; - // 2 vế sau = [vá-3 spec-cụm-3 §②-3] đóng cửa forge-POST pin quy trình đã nghỉ. - var aw = await db.ApprovalWorkflows.AsNoTracking() - .FirstOrDefaultAsync(w => w.Id == request.ApprovalWorkflowId, ct) - ?? throw new NotFoundException("ApprovalWorkflow", request.ApprovalWorkflowId); - if (aw.ApplicableType != ApprovalWorkflowApplicableType.Contract) - throw new ConflictException( - $"Quy trình '{aw.Code}' áp dụng cho {aw.ApplicableType}, không khớp với Hợp đồng " + - "(cần quy trình loại Hợp đồng)."); - // 2 cờ TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): admin cần đọc được - // NGUYÊN NHÂN — "đã nghỉ" và "chưa ghim" xử lý ở 2 nút khác nhau trong Designer. - if (!aw.IsActive) - throw new ConflictException( - $"Quy trình '{aw.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động."); - if (!aw.IsUserSelectable) - throw new ConflictException( - $"Quy trình '{aw.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác."); + // ---- Rào (6) WORKFLOW PIN — [S187 W4] CỬA 2/4, nay gọi NHÀ CHUNG + // `ContractWorkflowGuards.EnsureValidWorkflowForContractAsync`. + // + // 4 vế inline trước đây (chính file này là bản MẪU mà nhà chung chép ra) nay + 1 vế thứ 5: + // KHOÁ CỨNG LOẠI (86) — quy trình mang mã họ `HD-*` phải là mã của ĐÚNG `ContractType` + // đang tạo, gắn chéo ⇒ 409. Mã ngoài họ `HD-*` vẫn cho qua CÓ CHỦ ĐÍCH (xem chú thích + // đầu `ContractWorkflowGuards`): quy trình admin tự đặt tên không mang thông tin loại. + // + // 🔴 Message giữ NGUYÊN VĂN 3 chuỗi lưới T7 đang đo (`không khớp với Hợp đồng` · + // `ngừng hoạt động` · `chưa được ghim`) + `NotFoundException` cho id rác (T7-a): + // gộp về một nhà nói CÙNG câu, không phải câu na ná. + // + // 🔴 KHÔNG chọn quy trình (`null`/`Guid.Empty`) ⇒ máy TỰ tìm quy trình của đúng loại HĐ + // (PA-A, `ResolveDefaultWorkflowAsync`). Không tìm được ⇒ 409 chứ KHÔNG lẳng lặng để + // null: pin V2 là LÝ DO cầu này tồn tại (chú thích đầu file, điểm 2). HĐ không pin V2 + // thì `TransitionAsync:97` để `CurrentApprovalLevelOrder = null` ⇒ hộp thư của người + // phải duyệt RỖNG trong khi HĐ vẫn "đã trình" — đúng thế-giới-hỏng mà T8 dựng để bắt. + // (Hành vi cũ khi client không gửi field: `NotEmpty()` của validator ném 400. Nay 409 + // kèm câu chỉ-đường; cả hai đều là "cầu từ chối tạo HĐ không có quy trình".) + var pinnedWorkflowId = request.ApprovalWorkflowId is Guid reqAwId && reqAwId != Guid.Empty + ? reqAwId + : (Guid?)null; + if (pinnedWorkflowId is Guid awId) + { + await ContractWorkflowGuards.EnsureValidWorkflowForContractAsync( + db, awId, request.ContractType, ct); + } + else + { + pinnedWorkflowId = + await ContractWorkflowGuards.ResolveDefaultWorkflowAsync(db, request.ContractType, ct) + ?? throw new ConflictException( + $"Chưa chọn quy trình duyệt, và loại HĐ '{request.ContractType}' cũng chưa có quy trình " + + "mặc định (đang hoạt động + đã ghim cho người dùng chọn) — chọn quy trình trong hộp thoại, " + + "hoặc nhờ Admin ghim quy trình cho loại HĐ này."); + } // ---- [vá-6] Nạp Project + Supplier ĐỦ (cần `Code` cho codegen RG-001) — 2 guard NotFound // theo khuôn bridge cũ `CreateContractFromEvaluationFeatures.cs:63-66/:85-86`. @@ -199,7 +228,9 @@ public class CreateContractFromSigningPlanCommandHandler( BudgetManualName = plan.MaKeHoach is null ? null : $"NS tham chiếu KHKK {plan.MaKeHoach}", BudgetManualAmount = lines.Sum(l => l.PeReferenceAmount), WorkflowDefinitionId = activeWfId, // [vá-6] - ApprovalWorkflowId = request.ApprovalWorkflowId, // pin V2 + // [S187 W4] pin V2 — giá trị đã qua cửa 2/4: hoặc người bấm chọn (đã validate đủ 5 vế), + // hoặc máy tự tìm theo loại. Không bao giờ null tới đây (nhánh else đã ném 409). + ApprovalWorkflowId = pinnedWorkflowId, // [vá-6] SlaDeadline — thiếu thì HĐ sinh từ cầu này là HĐ DUY NHẤT trong hệ không có // hạn phase (cả 2 tiền lệ `ContractFeatures.cs:110` + bridge cũ `:109-110` đều set). SlaDeadline = DateTime.UtcNow.Add( diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index 5deaa1c..dac38d3 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -71,20 +71,23 @@ public class CreateContractCommandHandler( .Select(w => (Guid?)w.Id) .FirstOrDefaultAsync(ct); - // [Plan B S29 2026-05-22 Hotfix Reviewer] Validate ApprovalWorkflowId V2 - // (Mig 32) — User chọn lúc create. Phải tồn tại + ApplicableType=Contract(3). - // Mirror PE pattern PurchaseEvaluationFeatures.cs:62-77. Defense-in-depth: - // FE Workspace dropdown đã filter ApplicableType=3 server-side; BE guard - // chặn attacker forge POST với PE workflow ID (ApplicableType=1/2). - if (request.ApprovalWorkflowId is Guid awId) - { - var aw = await db.ApprovalWorkflows.AsNoTracking() - .FirstOrDefaultAsync(w => w.Id == awId, ct) - ?? throw new NotFoundException("ApprovalWorkflow", awId); - if (aw.ApplicableType != Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.Contract) - throw new ConflictException( - $"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với HĐ (cần ApplicableType=Contract)."); - } + // ---- [S187 W4] CỬA 1/4 — PIN quy trình V2 lúc tạo. + // + // Trước W4 chỗ này là bản inline CHỈ hỏi tồn-tại + đúng-ApplicableType (bản thứ 2 ở cầu K7 + // `CreateContractFromSigningPlanFeatures.cs` hỏi thêm `IsActive` + `IsUserSelectable` ⇒ 2 luật + // đi lệch nhau, đúng bug-class FLAG-7). Nay CẢ 4 cửa gọi CÙNG `ContractWorkflowGuards`. + // + // 2 nhánh theo (86)+(81): + // • người dùng pin tay ⇒ validate (gồm KHOÁ CỨNG loại — không gắn chéo); + // • không pin ⇒ máy TỰ tìm quy trình của đúng loại (PA-A). Không có thì để null + // y như hành vi trước W4 (HĐ rơi về V1 `WorkflowDefinitionId`), KHÔNG ném lỗi: + // `POST /api/contracts` là cửa CŨ đang chạy, siết nó ở đây là đổi hành vi ngoài phạm vi W4. + var pinnedApprovalWorkflowId = request.ApprovalWorkflowId; + if (pinnedApprovalWorkflowId is Guid awId) + await ContractWorkflowGuards.EnsureValidWorkflowForContractAsync(db, awId, request.Type, ct); + else + pinnedApprovalWorkflowId = + await ContractWorkflowGuards.ResolveDefaultWorkflowAsync(db, request.Type, ct); var entity = new Contract { @@ -106,7 +109,8 @@ public class CreateContractCommandHandler( // [Plan B S29 2026-05-22 Chunk E1] Pin V2 workflow nếu Drafter pick // qua Workspace Select dropdown (Chunk D FE). Cả 2 set ok — Service // ApproveV2Async branch dispatch theo ApprovalWorkflowId trước. - ApprovalWorkflowId = request.ApprovalWorkflowId, + // [S187 W4] Giá trị đã qua cửa 1/4: pin tay (đã validate) HOẶC máy tự chọn theo loại. + ApprovalWorkflowId = pinnedApprovalWorkflowId, SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), }; @@ -142,7 +146,14 @@ public record UpdateContractDraftCommand( string? DraftData, // [S61 Mig 50] BudgetId DROP — GIỮ BudgetManual* (HĐ nhập tay không đổi). string? BudgetManualName, - decimal? BudgetManualAmount) : IRequest; + decimal? BudgetManualAmount, + // [S187 W4 — cửa 3/4] RE-PIN quy trình duyệt khi HĐ còn Nháp (§1c-3 plan-review: trước W4 + // KHÔNG có đường sửa quy trình đã pin ⇒ pin nhầm là phải xoá HĐ tạo lại). + // 🔴 Field ĐẶT CUỐI + default `null` = giữ nguyên hành vi client cũ: FE hiện KHÔNG gửi field này + // ⇒ null ⇒ handler KHÔNG đụng tới `ApprovalWorkflowId` đang lưu (null ≠ "gỡ pin"). + // Không có đường GỠ pin ở đây CÓ CHỦ ĐÍCH: gỡ pin = HĐ rơi về V1 legacy, đó là quyết định + // khác (và ngược chiều (86) máy-tự-pin), không lén nhét vào cửa sửa nháp. + Guid? ApprovalWorkflowId = null) : IRequest; public class UpdateContractDraftCommandHandler( IApplicationDbContext db, @@ -171,6 +182,24 @@ public class UpdateContractDraftCommandHandler( if (entity.BudgetManualAmount != request.BudgetManualAmount) changes.Add(new { Field = "BudgetManualAmount", Old = entity.BudgetManualAmount, New = request.BudgetManualAmount }); + // ---- [S187 W4] CỬA 3/4 — RE-PIN quy trình duyệt V2 khi HĐ còn Nháp. + // + // 3 tầng bảo toàn hành-vi cũ, theo đúng thứ tự: + // (1) `null` (client cũ KHÔNG gửi field) ⇒ không vào khối này ⇒ ô đang lưu bất động. + // `null` ở đây nghĩa là "không nói gì", KHÔNG phải "gỡ pin". + // (2) gửi ĐÚNG giá trị đang lưu ⇒ cũng không validate lại. Cố ý: nếu quy trình đã pin + // sau đó bị Admin cho nghỉ, thì một lần sửa GIÁ TRỊ HĐ (client echo nguyên payload) + // sẽ ăn 409 vì một lý do chẳng liên quan tới thứ người dùng vừa sửa. + // (3) chỉ khi ĐỔI sang quy trình khác mới đi qua nhà chung — và đi qua ĐỦ 5 vế + // (gồm KHOÁ CỨNG loại (86)), y hệt cửa 1/4 và 2/4: 3 cửa ghi không được lệch nhau, + // đó là toàn bộ lý do `ContractWorkflowGuards` tồn tại. + if (request.ApprovalWorkflowId is Guid rePinId && rePinId != entity.ApprovalWorkflowId) + { + await ContractWorkflowGuards.EnsureValidWorkflowForContractAsync(db, rePinId, entity.Type, ct); + changes.Add(new { Field = "ApprovalWorkflowId", Old = entity.ApprovalWorkflowId, New = (Guid?)rePinId }); + entity.ApprovalWorkflowId = rePinId; + } + entity.GiaTri = request.GiaTri; entity.TenHopDong = request.TenHopDong; entity.NoiDung = request.NoiDung; diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractWorkflowGuards.cs b/src/Backend/SolutionErp.Application/Contracts/ContractWorkflowGuards.cs new file mode 100644 index 0000000..267b8ce --- /dev/null +++ b/src/Backend/SolutionErp.Application/Contracts/ContractWorkflowGuards.cs @@ -0,0 +1,169 @@ +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Contracts; + +namespace SolutionErp.Application.Contracts; + +// [S187 W4 — YC-023/YC-024] Rào DÙNG CHUNG cho quan hệ "loại Hợp đồng" ⟷ "quy trình duyệt V2 đã pin". +// +// Owner @S188: (86) KHOÁ CỨNG — máy tự pin quy trình đúng loại, KHÔNG cho đổi chéo loại +// (mirror KHKK/Duyệt NCC) · (80) trục 7 LOẠI HĐ · (81) PA-A (mỗi loại một mã riêng, 0 cột mới). +// +// 🔴 VÌ SAO `public static class` RIÊNG (FLAG-7 plan-review §6): +// trước đợt này luật pin sống ở HAI bản inline đi lệch nhau — +// • `ContractFeatures.cs:79-87` (Create): chỉ hỏi tồn-tại + đúng-ApplicableType; +// • `CreateContractFromSigningPlanFeatures.cs:132-148` (cầu K7): 4 vế (+`IsActive` +`IsUserSelectable`). +// Cửa thứ 3 (sửa nháp) thì KHÔNG có đường re-pin, cửa thứ 4 (TRÌNH duyệt) không kiểm gì. +// Viết inline lần thứ 3 = nhân bản luật lần nữa. Site thứ 4 là `ContractWorkflowService.TransitionAsync` +// nằm ở **Infrastructure** ⇒ không thấy `internal` của Application ⇒ phải `public static`, +// đúng ba lối đã cân nhắc-và-loại ghi ở `ContractSigningPlanWorkflowGuards.cs:14-22`. +// +// 🔴 HÌNH DẠNG KHOÁ = theo KHÔNG-GIAN-TÊN `HD-`, không phải "bằng-nhau-tuyệt-đối": +// code bắt đầu `HD-` ⇒ PHẢI == `ContractApprovalWorkflowFamily.CodeFor(type)`; +// code NGOÀI `HD-` ⇒ CHO QUA, CÓ CHỦ ĐÍCH. +// Lý do (giống hệt lý do của khuôn KHKK `ContractSigningPlanWorkflowGuards.cs:56-64`): ràng buộc này +// chỉ nói về 7 quy trình họ `HD-*` seed @W3. Quy trình type-3 đặt tay (`QT-HD-V2-001` — bản DUY NHẤT +// đang sống trên Dev đo 2026-08-10, và mọi workflow trong tests) KHÔNG mang thông tin loại trong mã +// ⇒ không có gì để đối chiếu; chặn nó là bịa ràng buộc và giết luôn quyền admin tự tạo quy trình. +// Vế "PHẢI == khi ở trong họ" mới là răng: pin `HD-MB` lên HĐ Thầu phụ = đổi chéo loại ⇒ 409. +// Nó cũng bắt ca gõ nhầm TRONG họ (`HD-TPX`) thay vì cho trôi im lặng. +// +// 🔴 So sánh mã bằng `OrdinalIgnoreCase` ở CẢ 2 vế (tiền tố + bằng nhau) — bài S182 MAJOR-2: +// SQL Server collation `*_CI_AS` coi `hd-tp` ≡ `HD-TP`, SQLite (test) so BINARY ⇒ hai tầng nói ngược +// nhau nếu rào dùng so sánh case-SENSITIVE. Đường ghi đã chuẩn-hoá `Trim().ToUpperInvariant()` +// (`ApprovalWorkflowV2AdminFeatures.cs` handler Create) — đó là belt cho dữ-liệu MỚI; cờ này là +// suspenders cho dữ-liệu CŨ (row có trước bản chuẩn-hoá). +public static class ContractWorkflowGuards +{ + /// Rào ĐƯỜNG GHI (pin / re-pin): Create · cầu K7 · sửa nháp. 5 vế, message TÁCH RIÊNG từng + /// nguyên nhân (gộp `||` thì người dùng không biết phải bấm nút nào). + /// + /// Thứ tự vế = từ "sai định danh" tới "sai vòng đời": tồn-tại → đúng loại module → đúng loại HĐ + /// (khoá cứng) → còn hoạt động → còn ghim. + /// + /// 🔴 `ConflictException` chứ KHÔNG phải `ValidationException`: `ValidationException` + /// (`Common/Exceptions/ApplicationException.cs:10-25`) không có ctor nhận message ⇒ không mang + /// được câu tiếng Việt; và lưới hành-vi đang sống assert `ConflictException` + /// (`CreateContractCommandApplicableTypeTests.cs:88` + `CreateContractFromSigningPlanTests.cs:475-488`). + /// Nhánh id-rác giữ `NotFoundException` — lỗi tham chiếu, không phải lỗi trạng thái. + public static async Task EnsureValidWorkflowForContractAsync( + IApplicationDbContext db, Guid workflowId, ContractType contractType, CancellationToken ct) + { + var aw = await db.ApprovalWorkflows.AsNoTracking() + .FirstOrDefaultAsync(w => w.Id == workflowId, ct) + ?? throw new NotFoundException("ApprovalWorkflow", workflowId); + + // Vế 1 — đúng module. Message PHẢI chứa CẢ "không khớp với Hợp đồng" (lưới cầu K7) LẪN + // "ApplicableType=Contract" (lưới Create): một nhà chung phát ngôn cho cả hai cửa. + if (aw.ApplicableType != ApprovalWorkflowApplicableType.Contract) + throw new ConflictException( + $"Quy trình '{aw.Code}' áp dụng cho {aw.ApplicableType}, không khớp với Hợp đồng " + + "(cần ApplicableType=Contract)."); + + // Vế 2 — KHOÁ CỨNG LOẠI (86). Xem chú thích đầu class về hình dạng khoá. + EnsureFamilyLock(aw.Code, contractType); + + // Vế 3+4 — vòng đời. 2 cờ TÁCH RIÊNG (không gộp): admin cần đọc được NGUYÊN NHÂN — + // "đã nghỉ" và "chưa ghim" xử lý ở 2 nút khác nhau trong Designer. + if (!aw.IsActive) + throw new ConflictException( + $"Quy trình '{aw.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động."); + if (!aw.IsUserSelectable) + throw new ConflictException( + $"Quy trình '{aw.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác."); + } + + /// PA-A (81): tự tìm quy trình của ĐÚNG loại HĐ — `null` khi chưa seed / admin đã gỡ ghim. + /// + /// 🔴 Điều kiện lọc = ĐÚNG BỘ điều kiện mà `EnsureValidWorkflowForContractAsync` chấp nhận + /// (`IsActive` ∧ `IsUserSelectable` ∧ đúng type ∧ đúng Code): máy chỉ được tự pin thứ mà cửa + /// validate cũng cho pin, nếu không thì cùng một quy trình vừa được máy gắn vừa bị cửa sửa-nháp + /// từ chối — hai tầng nói ngược nhau. + /// `null` KHÔNG phải lỗi: HĐ rơi về nhánh V1 legacy (`WorkflowDefinitionId`) đúng như hành vi + /// trước W4 khi người dùng không chọn gì. Cửa nào cần "bắt buộc phải có" thì tự ném (cầu K7). + /// `OrderByDescending(Version)` cho xác định: sau W1a (deactivate per-Code) chỉ 1 bản active mỗi + /// Code, nhưng dữ-liệu cũ/tay có thể còn 2 — lấy bản MỚI nhất thay vì "bản nào cũng được". + public static async Task ResolveDefaultWorkflowAsync( + IApplicationDbContext db, ContractType contractType, CancellationToken ct) + { + var code = ContractApprovalWorkflowFamily.CodeFor(contractType); + if (code is null) return null; + + return await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Code == code + && w.ApplicableType == ApprovalWorkflowApplicableType.Contract + && w.IsActive + && w.IsUserSelectable) + .OrderByDescending(w => w.Version) + .Select(w => (Guid?)w.Id) + .FirstOrDefaultAsync(ct); + } + + /// Rào CỬA TRÌNH (site 4/4) — mirror vai trò của rào nhóm site 3/3 bên KHKK + /// (`ContractSigningPlanWorkflowService.SubmitAsync` gọi `EnsureWorkflowGroupMatchAsync`): + /// đây là cửa DUY NHẤT bắt được HĐ pin lệch có TỪ TRƯỚC (3 cửa ghi chỉ chặn lúc GHI, nên HĐ tạo + /// trước khi có rào — hoặc sau khi admin đổi mã quy trình — vẫn nằm sẵn trong DB với loại lệch). + /// + /// 🔴 CỐ Ý NHẸ HƠN cửa ghi — KHÔNG hỏi `IsActive` / `IsUserSelectable`: + /// admin tạo version mới là tự động hạ `IsActive` của bản cũ (`CreateAwDefinitionCommandHandler`), + /// và "gỡ ghim" là thao tác về khả-năng-CHỌN chứ không phải về hiệu-lực. Hỏi 2 cờ đó ở đây = + /// HĐ nháp đang chạy bỗng không trình được vì một thao tác Designer chẳng liên quan + /// ⇒ đúng thứ đề bài cấm ("KHÔNG chặn đường duyệt phiếu cũ đang chạy"). + /// Chỉ giữ 2 vế về ĐỊNH DANH (còn tồn tại + đúng loại), vì pin sai định danh thì phiếu treo + /// im lặng trong hộp thư của không ai cả. + public static async Task EnsurePinnedWorkflowStillValidAsync( + IApplicationDbContext db, Guid workflowId, ContractType contractType, CancellationToken ct) + { + var aw = await db.ApprovalWorkflows.AsNoTracking() + .Where(w => w.Id == workflowId) + .Select(w => new { w.Code, w.ApplicableType }) + .FirstOrDefaultAsync(ct); + + if (aw is null) + throw new ConflictException( + "Quy trình duyệt đã pin cho HĐ này không còn tồn tại — vào HĐ (Nháp) chọn lại quy trình trước khi trình."); + + if (aw.ApplicableType != ApprovalWorkflowApplicableType.Contract) + throw new ConflictException( + $"Quy trình '{aw.Code}' đang pin áp dụng cho {aw.ApplicableType}, không khớp với Hợp đồng " + + "— vào HĐ (Nháp) chọn lại quy trình đúng loại."); + + EnsureFamilyLock(aw.Code, contractType); + } + + /// Thân của KHOÁ CỨNG (86) — dùng chung cho cửa ghi và cửa trình để 2 nơi không thể đi lệch. + private static void EnsureFamilyLock(string? workflowCode, ContractType contractType) + { + var code = workflowCode ?? string.Empty; + + // Ngoài không-gian-tên họ ⇒ cho qua (xem chú thích đầu class). + if (!code.StartsWith(ContractApprovalWorkflowFamily.CodePrefix, StringComparison.OrdinalIgnoreCase)) + return; + + var expected = ContractApprovalWorkflowFamily.CodeFor(contractType); + + // `expected == null` = loại HĐ nằm ngoài họ (enum nở thêm mà chưa bổ sung `Members`). + // Không có mã kỳ vọng để đối chiếu ⇒ KHÔNG cho mượn mã họ của loại khác. + if (expected is null) + throw new ConflictException( + $"Loại HĐ '{contractType}' chưa có quy trình riêng trong họ '{ContractApprovalWorkflowFamily.CodePrefix}*' " + + $"— không gắn được quy trình '{code}' cho loại này."); + + if (string.Equals(code, expected, StringComparison.OrdinalIgnoreCase)) return; + + var shortName = ShortNameOf(contractType); + throw new ConflictException( + $"Quy trình '{code}' là quy trình duyệt của loại Hợp đồng KHÁC — HĐ loại \"{shortName}\" phải dùng " + + $"quy trình '{expected}'. Không gắn chéo quy trình giữa các loại HĐ."); + } + + /// Tên loại tiếng Việt lấy TỪ nhà 7 mã (không gõ lại chuỗi ở đây — mã đổi thì message đổi theo). + private static string ShortNameOf(ContractType contractType) + { + foreach (var m in ContractApprovalWorkflowFamily.Members) + if (m.Type == contractType) return m.ShortName; + return contractType.ToString(); + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 1b6fd91..d5e8f33 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -3,6 +3,11 @@ using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Contracts.Services; +// [S187 W4] Alias thay vì `using SolutionErp.Application.Contracts;`: file này đã mở +// `SolutionErp.Domain.Contracts` (Contract, ContractPhase, WorkflowDefinition…) — kéo trọn +// namespace Application cùng tên vào đây là mời một va-chạm tên trong tương lai mà lỗi sẽ hiện ra +// ở một file chẳng liên quan. Cần đúng 1 lớp thì trỏ đúng 1 lớp. +using ContractWorkflowGuards = SolutionErp.Application.Contracts.ContractWorkflowGuards; using SolutionErp.Application.Notifications; using SolutionErp.Domain.Common; using SolutionErp.Domain.Contracts; @@ -48,6 +53,26 @@ public class ContractWorkflowService( // ===== REJECT BRANCH ===== if (decision == ApprovalDecision.Reject) { + // ---- [S187 W6] CỬA ĐÚNG-TRẠM cho HĐ đã pin quy trình V2 — vá lỗ gotcha #82-S156. + // + // Trước đợt này thân nhánh dưới chạy TRƯỚC MỌI rào: bất kỳ ai đăng nhập gửi + // `decision=Reject` là lật được HĐ BẤT KỲ sang TraLai/TuChoi — kể cả HĐ đã + // `DaPhatHanh` (đã gen mã, đã phát hành). Màn duyệt V2 (W6-FE) chỉ tắt 2 nút ở UI; + // API còn hở thì phần tắt đó là trang trí, forge 1 request qua DevTools là xong. + // + // 🔴 PHẠM VI CỐ Ý HẸP — chỉ HĐ pin V2 (`ApprovalWorkflowId != null`). HĐ V1 legacy đi + // tiếp y nguyên từng byte (owner: "chỗ hợp đồng cứ từ từ" cho V1). Đo Dev + // 2026-08-10 (W4): 7 HĐ, 0 HĐ pin V2 ⇒ đợt này KHÔNG đổi hành vi của bất kỳ HĐ nào + // đang sống, chỉ dựng rào sẵn cho luồng V2 sắp mở. Siết V1 là việc riêng, rủi ro + // riêng, cần owner chốt riêng. + // + // 🔴 ĐẶT Ở ĐÂY chứ KHÔNG nhân bản thân nhánh thành bản V2 thứ hai: phần thân dưới + // (set Phase + xoá con-trỏ + log) đúng cho CẢ V1 lẫn V2. Chép ra bản thứ hai là đẻ + // hai luật rồi để chúng đi lệch nhau — đúng cái bệnh mà + // `ContractWorkflowGuards.cs:14-21` vừa gỡ ở đường pin quy trình. + if (contract.ApprovalWorkflowId is Guid rejectAwId) + await EnsureCanRejectV2Async(contract, rejectAwId, targetPhase, actorUserId, isAdmin, ct); + if (targetPhase == ContractPhase.TuChoi) { contract.Phase = ContractPhase.TuChoi; @@ -90,6 +115,34 @@ public class ContractWorkflowService( throw new ForbiddenException( $"Role ({string.Join(",", actorRoles)}) không đủ quyền trình duyệt HĐ."); } + + // ---- [S187 W4] CỬA 4/4 — quy trình ĐÃ PIN còn hợp lệ không? + // + // 3 cửa GHI (Create · cầu K7 · sửa nháp) chỉ chặn ĐÚNG LÚC GHI. HĐ pin từ trước khi có + // rào — hoặc pin xong rồi Admin đổi mã quy trình đó trong Designer — vẫn nằm sẵn trong + // DB với loại lệch. Đây là cửa duy nhất bắt được lớp đó. + // + // 🔴 ĐẶT SAU rào vai (403 đứng trước 409): sai người thì lý do đầu tiên người dùng cần + // nghe là "không phải việc của bạn", không phải chuyện cấu hình quy trình. + // + // 🔴 CỐ Ý KHÔNG cắm thêm vào nhánh APPROVE dù cùng là "đường duyệt V2": HĐ đã sang + // `ChoDuyet` thì KHÔNG còn đường sửa quy trình (`UpdateContractDraftCommandHandler` + // chỉ mở ở `DangSoanThao`) ⇒ chặn ở đó là tự tay giam phiếu, không ai gỡ được kể cả + // Admin. Rào chỉ được đứng ở cửa mà người dùng còn lối đi tiếp: ở ĐÂY, HĐ đang Nháp, + // người lập sửa lại quy trình qua cửa 3/4 rồi trình lại. + // ⚠️ Dư-lượng đã biết, KHÔNG vá trong W4: HĐ ở `TraLai` cũng không sửa được + // (`UpdateContractDraft` chỉ nhận `DangSoanThao`) ⇒ nếu một HĐ `TraLai` mang pin + // lệch thì nó kẹt. Hiện KHÔNG có HĐ nào rơi vào ca đó (đo Dev 2026-08-10: 7 HĐ, + // 0 HĐ pin V2) và nới phase được-sửa là đổi hành-vi nằm ngoài phạm vi W4. + // + // 🔴 KHÔNG hỏi `IsActive`/`IsUserSelectable` — xem lý do trong + // `ContractWorkflowGuards.EnsurePinnedWorkflowStillValidAsync`: hai cờ đó thuộc về + // khả-năng-CHỌN, không phải hiệu-lực; hỏi ở đây thì một thao tác Designer chẳng liên + // quan sẽ làm HĐ nháp đang chạy hết trình được. + if (contract.ApprovalWorkflowId is Guid pinnedAwId) + await ContractWorkflowGuards.EnsurePinnedWorkflowStillValidAsync( + db, pinnedAwId, contract.Type, ct); + contract.Phase = ContractPhase.ChoDuyet; contract.CurrentWorkflowStepIndex = 0; // [Plan B S29 2026-05-22] V2 pointer init — mirror PE line 153. @@ -214,6 +267,91 @@ public class ContractWorkflowService( throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ."); } + // ===== [S187 W6] CỬA ĐÚNG-TRẠM CHO TRẢ LẠI / TỪ CHỐI — CHỈ HĐ PIN V2 ===== + // Vá gotcha #82-S156. Cùng vế quyền với nhánh Duyệt (`ApproveV2Async` :301-312) và đọc + // con-trỏ bằng CÙNG một phép (`?? 0` / `?? 1`, xem :284/:288) — hai đường phải hiểu "ai đang + // tới lượt" y hệt nhau, lệch một nước là phiếu vừa duyệt được vừa trả lại được bởi hai người + // khác nhau. Khuôn: KHKK `ContractSigningPlanWorkflowService.ReturnOrRejectAsync:374-390` + // (cửa trạng-thái đứng trước cửa người) + PE + // `PurchaseEvaluationWorkflowService.EnsureCanRejectV2Async:336`. + // + // 🔴 KHÁC PE/KHKK Ở KIỂU NGOẠI LỆ — CỐ Ý, đừng "sửa" cho đồng bộ: + // PE `:359` và KHKK `:477` ném `ForbiddenException` (403) cho cùng vị từ này; ở đây là + // `ConflictException` (409) theo lệnh W6. Lý do đứng được: dưới OR-of-N thì "sai người" và + // "chưa tới lượt" là MỘT vị từ không tách được, mà "chưa tới lượt" là xung đột TRẠNG THÁI + // (lát nữa chính người đó lại duyệt được) chứ không phải thiếu quyền vĩnh viễn. + // ⚠️ Dư-lượng đã biết, KHAI thẳng: trong CÙNG panel W6-FE, nút Duyệt sai người trả 403 + // (`:309`) còn nút Trả lại sai người trả 409. Muốn 2 nút cùng mã thì đổi Ở ĐÂY, KHÔNG đổi + // `:309` — đường Duyệt đang bị lưới hành-vi neo 403 + // (`ContractWorkflowServiceApproveV2Tests.cs:456`). + // + // 🔴 Admin MIỄN TRỌN cửa này (kể cả vế trạng-thái) — giữ nguyên tiền lệ owner đã chốt ở PE + // EDGE-5 (`PurchaseEvaluationWorkflowService.cs:58-72`: "ADMIN + SYSTEM MIỄN … Admin đầy + // đủ nhất", giữ cả khả năng un-terminal). Admin cũng là đường gỡ kẹt DUY NHẤT khi quy + // trình pin hỏng/mất row; chặn admin ở đây là tự tay giam HĐ mà không ai mở được. + // + // 🔴 `actorUserId == null` ⇒ CHẶN, không thả. Hôm nay KHÔNG caller nào rơi vào đây: caller + // hệ-thống duy nhất là `SlaExpiryJob.cs:100-107` và nó gửi `AutoApprove` chứ không bao giờ + // `Reject`; đường người dùng `ContractFeatures.cs:252` đã chặn thiếu UserId từ trước. Mai + // có cron tự-trả-lại thì phải khai tường minh, KHÔNG mượn lỗ null đi qua — chỗ thả null ở + // PE `:342` chính là phần hở còn lại của khuôn nguồn, không phải thứ đáng chép. + private async Task EnsureCanRejectV2Async( + Contract contract, + Guid awId, + ContractPhase targetPhase, + Guid? actorUserId, + bool isAdmin, + CancellationToken ct) + { + if (isAdmin) return; + + var actionLabel = targetPhase == ContractPhase.TuChoi ? "từ chối" : "trả lại"; + + // (1) CỬA TRẠNG THÁI — đứng TRƯỚC cửa người: HĐ không ở "Đã gửi duyệt" thì không có Cấp + // nào đang chờ để mà xét tới lượt ai. Đây chính là vế khoá `DaPhatHanh` của S156. + if (contract.Phase != ContractPhase.ChoDuyet) + throw new ConflictException( + $"Chỉ {actionLabel} được HĐ đang ở trạng thái Đã gửi duyệt." + + (contract.Phase == ContractPhase.DangSoanThao + ? " HĐ còn Bản nháp thì dùng Xoá HĐ." + : string.Empty)); + + // (2) CỬA NGƯỜI — actor phải có tên trong Cấp ĐANG CHỜ (OR-of-N: một trong N là đủ). + if (actorUserId is not Guid actorId) + throw new ConflictException( + $"Không xác định được người {actionLabel} HĐ — thao tác này cần đăng nhập."); + + var aw = await db.ApprovalWorkflows.AsNoTracking() + .Include(w => w.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Levels.OrderBy(l => l.Order)) + .FirstOrDefaultAsync(w => w.Id == awId, ct) + ?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại."); + + var steps = aw.Steps.OrderBy(s => s.Order).ToList(); + if (steps.Count == 0) + throw new ConflictException("Quy trình chưa có bước nào."); + + var currentIdx = contract.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]; + var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1; + + var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList(); + var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key); + if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder) + throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder})."); + + var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder) + ?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}."); + + if (!pendingLevelGroup.Any(l => l.ApproverUserId == actorId)) + throw new ConflictException( + $"Không đến lượt bạn — HĐ đang chờ Bước {currentIdx + 1} ({currentStep.Name}) Cấp {currentLevelOrder}; " + + $"bạn không thuộc cấp duyệt hiện tại nên không {actionLabel} được HĐ này."); + } + // ===== V2 APPROVE (Mig 32+33 — Plan B S29 2026-05-22) ===== // Mirror PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634). // Khác PE: terminal hoàn tất → gen mã HĐ + Phase=DaPhatHanh (PE chỉ diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs index f602917..a7c38c1 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs @@ -515,4 +515,256 @@ public class ContractWorkflowServiceApproveV2Tests contract.Phase.Should().Be(ContractPhase.ChoDuyet, "State unchanged"); } } + + // ===================================================================== + // [S187 W6] CỬA ĐÚNG-TRẠM cho TRẢ LẠI / TỪ CHỐI — vá gotcha #82-S156 + // ===================================================================== + // Lỗ S156: nhánh Reject của `TransitionAsync` chạy TRƯỚC MỌI rào ⇒ ai đăng nhập cũng + // trả lại / từ chối được HĐ bất kỳ, kể cả HĐ đã `DaPhatHanh`. 5 [Fact] dưới đây neo CẢ + // HAI chiều để rào không trượt về một phía: + // • chặn đúng người cần chặn — W6-1 (ngoài quy trình) · W6-2 (đúng người SAI LƯỢT) · + // W6-3 (HĐ đã phát hành, kể cả người đang tới lượt) + // • KHÔNG chặn người cần cho qua — W6-4 (approver Cấp đang chờ trả lại được thật) + // • KHÔNG lan sang V1 — W6-5 (HĐ legacy giữ hành vi cũ) + // Bỏ W6-4 thì một rào chặn-tất-cả vẫn xanh 4/5; bỏ W6-2 thì rào chỉ-cần-có-tên-trong-quy- + // trình (không xét Cấp) cũng xanh — hai ca đó là phần chịu lực, đừng cắt khi gọn hoá. + // + // Vế "đúng approver duyệt được bình thường" KHÔNG viết lại ở đây: BW1 (:222) đã neo sẵn + // và vẫn xanh sau đợt này — thêm bản sao chỉ là churn. + + // Dựng service + trả kèm những thứ test cần assert. Gom lại vì 5 ca dưới lặp y hệt + // 8 dòng wiring của các ca BW phía trên. + private static (ContractWorkflowService svc, TestApplicationDbContext db) WireService( + IdentityFixture fix, Guid actorId, params string[] actorRoles) + { + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc)); + var currentUser = new TestCurrentUser { UserId = actorId, Roles = actorRoles }; + var changelog = new ChangelogService(db, currentUser, um); + var svc = new ContractWorkflowService( + db, new ContractCodeGenerator(db, dt), dt, new NoOpNotificationService(), changelog, um); + return (svc, db); + } + + // ---- W6-1: người NGOÀI quy trình bấm Trả lại HĐ V2 đang chờ duyệt → 409, HĐ đứng yên ---- + + [Fact] + public async Task RejectV2_OutsiderNonAdmin_ThrowsConflict_AndLeavesContractUntouched() + { + var fix = new IdentityFixture(); + using (fix) + { + var approver1 = await fix.CreateUserAsync("a1-w61@test.local", "Approver 1 W61", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var approver2 = await fix.CreateUserAsync("a2-w61@test.local", "Approver 2 W61", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var outsider = await fix.CreateUserAsync("out-w61@test.local", "Outsider W61", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var (svc, db) = WireService(fix, outsider.Id, AppRoles.CostControl); + var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W61"); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W61", projectCode: "PW61"); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.TraLai, + actorUserId: outsider.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "người ngoài thử trả lại", + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Không đến lượt bạn*"); + + contract.Phase.Should().Be(ContractPhase.ChoDuyet, "Rào chặn TRƯỚC khi mutate phase"); + contract.CurrentWorkflowStepIndex.Should().Be(0, "Con-trỏ Bước nguyên vẹn"); + contract.CurrentApprovalLevelOrder.Should().Be(1, "Con-trỏ Cấp nguyên vẹn"); + + // Rào ném trước `SaveChangesAsync` ⇒ không được để lại vết nào trong DB. + var changelogs = await db.ContractChangelogs + .Where(c => c.ContractId == contract.Id).ToListAsync(); + changelogs.Should().BeEmpty("Trả lại bị chặn thì không ghi lịch sử chuyển trạng thái"); + } + } + + // ---- W6-2: ĐÚNG người trong quy trình nhưng SAI LƯỢT (approver Cấp 2 khi HĐ đang ở Cấp 1) + // Ca chịu lực: phân biệt rào ĐÚNG-TRẠM với rào chỉ hỏi "có tên trong quy trình không". ---- + + [Fact] + public async Task RejectV2_ApproverOfLaterLevel_NotYetTheirTurn_ThrowsConflict() + { + var fix = new IdentityFixture(); + using (fix) + { + var approver1 = await fix.CreateUserAsync("a1-w62@test.local", "Approver 1 W62", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var approver2 = await fix.CreateUserAsync("a2-w62@test.local", "Approver 2 W62", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + // Actor = approver Cấp 2 — CÓ tên trong quy trình, nhưng HĐ đang đứng ở Cấp 1. + var (svc, db) = WireService(fix, approver2.Id, AppRoles.CostControl); + var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W62"); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W62", projectCode: "PW62"); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.TraLai, + actorUserId: approver2.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "Cấp 2 nhảy lượt Cấp 1", + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Không đến lượt bạn*Cấp 1*"); + contract.Phase.Should().Be(ContractPhase.ChoDuyet); + contract.CurrentApprovalLevelOrder.Should().Be(1); + } + } + + // ---- W6-3: HĐ ĐÃ PHÁT HÀNH bất khả xâm phạm — kể cả người đang có chân duyệt (lỗ S156) ---- + + [Fact] + public async Task RejectV2_ContractDaPhatHanh_EvenApproverInWorkflow_ThrowsConflict() + { + var fix = new IdentityFixture(); + using (fix) + { + var approver1 = await fix.CreateUserAsync("a1-w63@test.local", "Approver 1 W63", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var approver2 = await fix.CreateUserAsync("a2-w63@test.local", "Approver 2 W63", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var (svc, db) = WireService(fix, approver1.Id, AppRoles.CostControl); + var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W63"); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W63", projectCode: "PW63"); + + // HĐ đã đi hết quy trình: mã đã gen, con-trỏ đã xoá — đúng hình dạng sau terminal. + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1); + contract.Phase = ContractPhase.DaPhatHanh; + contract.MaHopDong = "PW63/HĐTP/SOL&W63/01"; + contract.CurrentWorkflowStepIndex = null; + contract.CurrentApprovalLevelOrder = null; + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.TuChoi, + actorUserId: approver1.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "thử lật HĐ đã phát hành", + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Chỉ từ chối được HĐ đang ở trạng thái Đã gửi duyệt*"); + + contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "HĐ đã phát hành không bị lật"); + contract.MaHopDong.Should().Be("PW63/HĐTP/SOL&W63/01", "Mã HĐ đã cấp giữ nguyên"); + } + } + + // ---- W6-4: CHIỀU DƯƠNG — approver của Cấp ĐANG CHỜ vẫn trả lại được thật ---- + // Không có ca này thì một rào chặn-sạch-mọi-request cũng xanh hết phần còn lại. + + [Fact] + public async Task RejectV2_ApproverOfCurrentLevel_ReturnsContractToTraLai() + { + var fix = new IdentityFixture(); + using (fix) + { + var approver1 = await fix.CreateUserAsync("a1-w64@test.local", "Approver 1 W64", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var approver2 = await fix.CreateUserAsync("a2-w64@test.local", "Approver 2 W64", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var (svc, db) = WireService(fix, approver1.Id, AppRoles.CostControl); + var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id, code: "QT-CT-W64"); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W64", projectCode: "PW64"); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.TraLai, + actorUserId: approver1.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "thiếu phụ lục — nhờ bổ sung", + ct: CancellationToken.None); + + contract.Phase.Should().Be(ContractPhase.TraLai, "Đúng người đang tới lượt → trả lại được"); + contract.CurrentWorkflowStepIndex.Should().BeNull("Trả lại xoá con-trỏ Bước (hành vi sẵn có)"); + contract.SlaDeadline.Should().BeNull(); + + var changelogs = await db.ContractChangelogs + .Where(c => c.ContractId == contract.Id + && c.EntityType == ChangelogEntityType.Workflow).ToListAsync(); + changelogs.Should().Contain(c => c.Summary != null + && c.Summary.Contains("ChoDuyet") && c.Summary.Contains("TraLai")); + } + } + + // ---- W6-5: CONTROL V1 legacy — HĐ không pin V2 giữ nguyên hành vi cũ ---- + // 🔴 CỐ Ý chỉ neo ca `ChoDuyet`: đây là bằng chứng "rào W6 không lan sang V1". KHÔNG neo ca + // V1 + `DaPhatHanh` (lỗ S156 phía V1 còn mở theo quyết định owner "chỗ hợp đồng cứ từ + // từ") — viết assert cho một lỗ đang mở là biến nó thành hợp-đồng-hành-vi, để rồi đợt sau + // ai đi vá V1 lại phải cãi nhau với lưới test. + [Fact] + public async Task RejectV1Legacy_NonApprover_StillReturnsToTraLai_BehaviorUnchanged() + { + var fix = new IdentityFixture(); + using (fix) + { + var randomUser = await fix.CreateUserAsync("rnd-w65@test.local", "Random W65", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var (svc, db) = WireService(fix, randomUser.Id, AppRoles.CostControl); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "W65", projectCode: "PW65"); + var contract = new Contract + { + Id = Guid.NewGuid(), + Type = ContractType.HopDongThauPhu, + Phase = ContractPhase.ChoDuyet, + SupplierId = sup.Id, + ProjectId = proj.Id, + DrafterUserId = Guid.NewGuid(), + TenHopDong = "HĐ V1 legacy", + GiaTri = 50_000_000m, + ApprovalWorkflowId = null, // ← V1: không pin quy trình V2 + WorkflowDefinitionId = null, + CurrentWorkflowStepIndex = 0, + }; + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.TraLai, + actorUserId: randomUser.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "V1 trả lại như cũ", + ct: CancellationToken.None); + + contract.Phase.Should().Be(ContractPhase.TraLai, + "HĐ V1 KHÔNG đi qua cửa đúng-trạm — hành vi legacy giữ nguyên từng byte"); + contract.CurrentWorkflowStepIndex.Should().BeNull(); + contract.SlaDeadline.Should().BeNull(); + } + } }