[CLAUDE] Contract: W4+W6BE guard 4-cửa pin/re-pin nhà chung (khoá cứng theo loại, bridge auto-pin nullable) + Đ2-guard đúng-trạm V2 (V1 giữ nguyên) + test-before security
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -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<Guid>? LineIds,
|
||||
ContractType ContractType,
|
||||
Guid ApprovalWorkflowId,
|
||||
Guid? ApprovalWorkflowId = null,
|
||||
string? TenHopDong = null);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
/// </param>
|
||||
/// <param name="ApprovalWorkflowId">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public record CreateContractFromSigningPlanCommand(
|
||||
Guid PlanId,
|
||||
List<Guid> LineIds,
|
||||
ContractType ContractType,
|
||||
Guid ApprovalWorkflowId,
|
||||
Guid? ApprovalWorkflowId = null,
|
||||
string? TenHopDong = null) : IRequest<CreateContractFromSigningPlanResult>;
|
||||
|
||||
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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Guid?> 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();
|
||||
}
|
||||
}
|
||||
@ -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ỉ
|
||||
|
||||
@ -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<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
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<ConflictException>()
|
||||
.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<ConflictException>()
|
||||
.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<ConflictException>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user