[CLAUDE] Contract: YC-030 Q3+Q5 — port finalize KHKK vào máy duyệt HĐ (Mig 73 EndedByLevelFinalize + ApproveV2 + body/DTO nở + tắt QT-HD-V2-001, test 697)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -64,7 +64,12 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
||||
[HttpPost("{id:guid}/transitions")]
|
||||
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionContractBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new TransitionContractCommand(id, body.TargetPhase, body.Decision, body.Comment), ct);
|
||||
// [WQ3-BE S190] `?? true` ĐẶT Ở ĐÂY (đúng khuôn KHKK): body là chỗ duy nhất phân biệt được
|
||||
// "client không gửi" ⟂ "client gửi false"; xuống tới command thì đã là `bool` dứt khoát.
|
||||
await mediator.Send(new TransitionContractCommand(
|
||||
id, body.TargetPhase, body.Decision, body.Comment,
|
||||
ApplyLevelFinalize: body.ApplyLevelFinalize ?? true,
|
||||
SkipToFinal: body.SkipToFinal), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -253,5 +258,18 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
||||
=> Ok(await mediator.Send(new ListContractDepartmentApprovalsQuery(id), ct));
|
||||
}
|
||||
|
||||
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||
// [WQ3-BE S190 · YC-030 Q3] +2 field append-CUỐI, ĐỀU có default ⇒ payload FE cũ (3 field) chạy
|
||||
// y nguyên, 0 call-site vỡ.
|
||||
// • `ApplyLevelFinalize` — OPT-OUT khuôn KHKK (`ContractSigningPlansController.cs:330-333`):
|
||||
// null/true = auto kết-thúc-tại-cấp khi Cấp có `AllowApproverFinalize`; false = người duyệt bỏ
|
||||
// tick ⇒ trình tiếp. `bool?` chứ không `bool` để phân biệt "client CŨ không gửi" với "client
|
||||
// MỚI gửi false" — cả hai đều xuống service, nhưng chỉ vế đầu được hưởng mặc định true.
|
||||
// • `SkipToFinal` — cờ F2 (Mig 31) trước nay CÓ trong service mà KHÔNG có cửa API nào bật
|
||||
// (`TransitionContractCommand` cũ 4 field, handler gọi `ct:` bỏ qua) ⇒ chết câm từ S23.
|
||||
public record TransitionContractBody(
|
||||
ContractPhase TargetPhase,
|
||||
ApprovalDecision Decision,
|
||||
string? Comment,
|
||||
bool? ApplyLevelFinalize = null,
|
||||
bool SkipToFinal = false);
|
||||
public record AddCommentBody(string Content);
|
||||
|
||||
@ -225,11 +225,16 @@ public class UpdateContractDraftCommandHandler(
|
||||
|
||||
// ========== TRANSITION phase ==========
|
||||
|
||||
// [WQ3-BE S190 · YC-030 Q3] +2 field append-CUỐI, đều có default (positional record — chèn giữa
|
||||
// là vỡ mọi call-site truyền theo vị trí). `ApplyLevelFinalize` ĐÃ là `bool` dứt khoát: cửa
|
||||
// `?? true` nằm ở controller, nơi duy nhất phân biệt được "không gửi" ⟂ "gửi false".
|
||||
public record TransitionContractCommand(
|
||||
Guid Id,
|
||||
ContractPhase TargetPhase,
|
||||
ApprovalDecision Decision,
|
||||
string? Comment) : IRequest;
|
||||
string? Comment,
|
||||
bool ApplyLevelFinalize = true,
|
||||
bool SkipToFinal = false) : IRequest;
|
||||
|
||||
public class TransitionContractCommandValidator : AbstractValidator<TransitionContractCommand>
|
||||
{
|
||||
@ -262,6 +267,8 @@ public class TransitionContractCommandHandler(
|
||||
currentUser.Roles,
|
||||
request.Decision,
|
||||
request.Comment,
|
||||
skipToFinal: request.SkipToFinal,
|
||||
applyLevelFinalize: request.ApplyLevelFinalize,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
@ -782,6 +789,10 @@ public class GetContractQueryHandler(
|
||||
// ⇒ null (FE giữ nhánh render cũ). KHÔNG precompute status — panel tự suy từ cặp
|
||||
// con-trỏ (`CurrentWorkflowStepIndex` INDEX 0-based + `CurrentApprovalLevelOrder`).
|
||||
List<ContractWorkflowStepDto>? workflowStepsDto = null;
|
||||
// [WQ3-BE S190] Meta quy trình cho panel duyệt — lấy TỪ CHÍNH bản `wf` đang nạp cây thô,
|
||||
// KHÔNG mở query thứ hai (2 query = 2 cơ hội đọc lệch nhau khi admin sửa giữa chừng).
|
||||
string? workflowCode = null;
|
||||
int? workflowVersion = null;
|
||||
if (c.ApprovalWorkflowId is Guid awIdTree)
|
||||
{
|
||||
var wf = await db.ApprovalWorkflows.AsNoTracking()
|
||||
@ -790,6 +801,8 @@ public class GetContractQueryHandler(
|
||||
.FirstOrDefaultAsync(w => w.Id == awIdTree, ct);
|
||||
if (wf is not null)
|
||||
{
|
||||
workflowCode = wf.Code;
|
||||
workflowVersion = wf.Version;
|
||||
var approverIds = wf.Steps.SelectMany(s => s.Levels)
|
||||
.Select(l => l.ApproverUserId).Distinct().ToList();
|
||||
var approverNames = await userManager.Users.AsNoTracking()
|
||||
@ -867,7 +880,11 @@ public class GetContractQueryHandler(
|
||||
// `CurrentWorkflowStepIndex` đọc THẲNG cột đã có (`Contract.cs:40`) ⇒ 0-migration.
|
||||
Source: sourceDto,
|
||||
CurrentWorkflowStepIndex: c.CurrentWorkflowStepIndex,
|
||||
WorkflowSteps: workflowStepsDto);
|
||||
WorkflowSteps: workflowStepsDto,
|
||||
// [WQ3-BE S190 · YC-030 Q3] meta quy trình + cờ RUNTIME kết-thúc-sớm (Mig 73).
|
||||
WorkflowCode: workflowCode,
|
||||
WorkflowVersion: workflowVersion,
|
||||
EndedByLevelFinalize: c.EndedByLevelFinalize);
|
||||
}
|
||||
|
||||
// FE uses this to render next-phase buttons dynamically — no more hardcoded
|
||||
|
||||
@ -88,7 +88,21 @@ public record ContractDetailDto(
|
||||
// là đường INLINE 1-request để panel không phải gọi thêm — KHÔNG đẻ đường thứ 3.
|
||||
ContractSourceDto? Source = null,
|
||||
int? CurrentWorkflowStepIndex = null,
|
||||
List<ContractWorkflowStepDto>? WorkflowSteps = null);
|
||||
List<ContractWorkflowStepDto>? WorkflowSteps = null,
|
||||
// ================= [WQ3-BE S190 — YC-030 Q3] append-CUỐI (khuôn KHKK DTO
|
||||
// `ContractSigningPlanFeatures.cs:216-224`) ==========================================
|
||||
// • `WorkflowCode` + `WorkflowVersion` — dòng meta `{code} v{NN}` của panel duyệt. Đọc từ
|
||||
// `ApprovalWorkflow` ĐÃ PIN (cùng một lượt nạp cây thô `:787` — KHÔNG query thêm). `null`
|
||||
// khi HĐ chưa pin V2 (V1 legacy) ⇒ FE không render dòng meta.
|
||||
// 🔴 `Code` ở đây là mã QUY TRÌNH (`HD-TP`, `QT-HD-V2-001`…), KHÔNG phải `MaHopDong` —
|
||||
// hai thứ trùng chữ "mã" mà khác hẳn nhau.
|
||||
// • `EndedByLevelFinalize` — CỜ RUNTIME đọc THẲNG cột `Contract.EndedByLevelFinalize` (Mig 73),
|
||||
// KHÔNG suy từ `AllowApproverFinalize` của cây (bài #78 / PE Mig 60: HĐ opt-out sẽ bị gắn
|
||||
// nhãn sai, và HĐ kết-thúc-sớm thật mất nhãn ngay khi admin gỡ tick). Non-nullable: HĐ V1
|
||||
// legacy luôn `false` — đúng sự thật, không phải "không biết".
|
||||
string? WorkflowCode = null,
|
||||
int? WorkflowVersion = null,
|
||||
bool EndedByLevelFinalize = false);
|
||||
|
||||
// [W2 S187] Nguồn gốc GĐ2 (Kế hoạch ký kết — KHKK) của một HĐ. Dựng từ N dòng kế hoạch
|
||||
// cùng trỏ về HĐ này (bridge K7 gán `Line.ContractId` cho TỪNG dòng được chọn —
|
||||
|
||||
@ -17,6 +17,12 @@ public interface IContractWorkflowService
|
||||
ApprovalDecision decision,
|
||||
string? comment,
|
||||
bool skipToFinal = false,
|
||||
// [WQ3-BE S190 · YC-030 Q3] OPT-OUT của "KẾT THÚC TẠI CẤP" (level-finalize), khuôn KHKK
|
||||
// `IContractSigningPlanWorkflowService` / PE S96: mặc định TRUE = Cấp có
|
||||
// `AllowApproverFinalize` bấm Duyệt là HĐ phát hành luôn; client gửi false = người duyệt
|
||||
// BỎ tick ⇒ trình tiếp Bước/Cấp sau. Default true để KHÔNG break caller cũ (mọi call-site
|
||||
// hiện tại truyền `ct:` theo TÊN nên chèn tham số ở đây an toàn).
|
||||
bool applyLevelFinalize = true,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.
|
||||
|
||||
@ -45,6 +45,20 @@ public class Contract : AuditableEntity
|
||||
// khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc V2 terminal (DaPhatHanh).
|
||||
public int? CurrentApprovalLevelOrder { get; set; }
|
||||
|
||||
// [WQ3-BE S190 · YC-030 Q3 — Mig 73] Cờ RUNTIME "HĐ này ĐÃ kết thúc sớm bằng level-finalize"
|
||||
// (Cấp có `AllowApproverFinalize` bấm Duyệt ⇒ phát hành luôn, không trình tiếp các Bước/Cấp sau).
|
||||
// Mirror 1-1 `ContractSigningPlan.cs:51` (KHKK Mig 71) — cùng ngữ nghĩa, cùng lý do tồn tại:
|
||||
//
|
||||
// 🔴 VÌ SAO PHẢI LÀ CỘT chứ không suy từ config (bài #78, PE Mig 60): cây workflow là thứ
|
||||
// admin sửa được BẤT KỲ LÚC NÀO, và người duyệt có quyền OPT-OUT (`applyLevelFinalize=false`
|
||||
// ⇒ đi tiếp bình thường DÙ Cấp có cờ). Suy ngược từ `AllowApproverFinalize` của cây ⇒ HĐ
|
||||
// opt-out vẫn bị gắn nhãn "kết thúc sớm", và HĐ kết thúc sớm THẬT sẽ mất nhãn ngay khi admin
|
||||
// gỡ tick. Chỉ có cột ghi tại đúng thời điểm nhánh finalize chạy mới nói được sự thật.
|
||||
//
|
||||
// Write-site DUY NHẤT = nhánh finalize trong `ContractWorkflowService.ApproveV2Async`.
|
||||
// HĐ V1 legacy (`WorkflowDefinitionId`) KHÔNG dùng — luôn `false`.
|
||||
public bool EndedByLevelFinalize { get; set; }
|
||||
|
||||
public List<ContractApproval> Approvals { get; set; } = new();
|
||||
public List<ContractComment> Comments { get; set; } = new();
|
||||
public List<ContractAttachment> Attachments { get; set; } = new();
|
||||
|
||||
@ -259,8 +259,13 @@ public static class DbInitializer
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
Name = "Quy trình duyệt HĐ mẫu UAT V2",
|
||||
Description = "Sample seed cho UAT HĐ V2 — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true, // Mig 25 — user pick qua Workspace dropdown
|
||||
// [YC-030 Q5 S190] Anh chốt "Thay luôn — mỗi loại 1 quy trình tương tự KHKK":
|
||||
// 7 quy trình họ `HD-*` per-loại (seed @S188) là đường CHÍNH THỨC; bản mẫu chung
|
||||
// này TẮT từ lúc sinh cho env mới (env cũ đã có row ⇒ seeder skip, tắt bằng
|
||||
// UPDATE data cùng đợt deploy — bền vì idempotent-check là EXISTENCE, không
|
||||
// re-activate, né gotcha #75). Giữ khối seed làm mẫu-lịch-sử, KHÔNG xoá.
|
||||
IsActive = false,
|
||||
IsUserSelectable = false, // Mig 25 — KHÔNG hiện trong Workspace dropdown
|
||||
ActivatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddContractEndedByLevelFinalize : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EndedByLevelFinalize",
|
||||
table: "Contracts",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EndedByLevelFinalize",
|
||||
table: "Contracts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -833,6 +833,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid?>("DrafterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("EndedByLevelFinalize")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal>("GiaTri")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@ -44,6 +44,7 @@ public class ContractWorkflowService(
|
||||
ApprovalDecision decision,
|
||||
string? comment,
|
||||
bool skipToFinal = false,
|
||||
bool applyLevelFinalize = true,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var fromPhase = contract.Phase;
|
||||
@ -163,7 +164,7 @@ public class ContractWorkflowService(
|
||||
// V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này.
|
||||
if (contract.ApprovalWorkflowId is Guid awId)
|
||||
{
|
||||
await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, ct);
|
||||
await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, applyLevelFinalize, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
@ -374,6 +375,7 @@ public class ContractWorkflowService(
|
||||
bool isSystem,
|
||||
string? comment,
|
||||
bool skipToFinal,
|
||||
bool applyLevelFinalize,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var aw = await db.ApprovalWorkflows.AsNoTracking()
|
||||
@ -502,6 +504,36 @@ public class ContractWorkflowService(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- [WQ3-BE S190 · YC-030 Q3] KẾT THÚC TẠI CẤP (level-finalize) — port khuôn KHKK
|
||||
// `ContractSigningPlanWorkflowService.cs:286-299`. Cấp bật `AllowApproverFinalize` bấm Duyệt
|
||||
// ⇒ HĐ PHÁT HÀNH luôn tại đây, không trình tiếp các Bước/Cấp còn lại.
|
||||
//
|
||||
// 🔴 OPT-OUT, không phải bắt buộc (khuôn PE S96 → KHKK K3): `applyLevelFinalize=false` =
|
||||
// người duyệt BỎ tick "Cấp này kết thúc" ⇒ rơi xuống advance thường. Nên điều kiện là
|
||||
// HAI vế (cờ cấu hình ∧ ý người duyệt), không phải một.
|
||||
//
|
||||
// 🔴 ĐỨNG SAU `skipToFinal` CÓ CHỦ ĐÍCH: hai cờ nói hai việc ngược nhau — skip là "nhảy TỚI
|
||||
// Cấp cuối để cấp đó duyệt thật", finalize là "dừng NGAY, khỏi cần cấp sau". Ca hiếm
|
||||
// người duyệt bật cả hai: khối skip ở trên đã `return` trước ⇒ skip thắng, HĐ đi tới Cấp
|
||||
// cuối rồi cấp cuối mới kết thúc — an toàn hơn (còn một người nữa nhìn) so với thứ tự ngược.
|
||||
// Ngoại lệ: actor ĐANG ở Cấp cuối Bước cuối thì khối skip fall-through xuống đây, và
|
||||
// finalize ở ca đó ra CÙNG kết quả với terminal thường (cả hai đều `DaPhatHanh` + gen mã),
|
||||
// chỉ khác dòng changelog — không đẻ trạng thái lạ.
|
||||
//
|
||||
// 🔴 TÁI DÙNG `FinalizeToDaPhatHanhAsync` (đường terminal DUY NHẤT) chứ không chép tay:
|
||||
// gen mã RG-001 + xoá cặp con-trỏ + xoá SLA phải giống hệt nhánh terminal thường, nếu
|
||||
// không thì HĐ kết thúc sớm ra đời KHÔNG CÓ MÃ (mirror luật choke-point KHKK
|
||||
// `ContractSigningPlanWorkflowService.cs:342-347`).
|
||||
if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)
|
||||
{
|
||||
contract.EndedByLevelFinalize = true; // cờ RUNTIME (Mig 73) — write-site DUY NHẤT
|
||||
await FinalizeToDaPhatHanhAsync(
|
||||
contract, actorUserId,
|
||||
$"[Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — KẾT THÚC TẠI CẤP (level-finalize), HĐ ĐÃ PHÁT HÀNH] {comment ?? ""}".Trim(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
|
||||
if (currentLevelOrder < maxLevelOrder)
|
||||
{
|
||||
@ -516,23 +548,8 @@ public class ContractWorkflowService(
|
||||
var nextIdx = currentIdx + 1;
|
||||
if (nextIdx >= steps.Count)
|
||||
{
|
||||
// All Steps done — terminal DaPhatHanh. Khác PE: phải gen mã HĐ
|
||||
// theo RG-001 (mirror V1 line 148-155). FE sau khi nhận DaPhatHanh
|
||||
// sẽ refresh hiển thị MaHopDong.
|
||||
if (string.IsNullOrEmpty(contract.MaHopDong))
|
||||
{
|
||||
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
||||
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
|
||||
?? throw new NotFoundException("Project", contract.ProjectId);
|
||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||
}
|
||||
contract.Phase = ContractPhase.DaPhatHanh;
|
||||
contract.CurrentWorkflowStepIndex = null;
|
||||
contract.CurrentApprovalLevelOrder = null;
|
||||
contract.SlaDeadline = null;
|
||||
await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
|
||||
actorUserId, ApprovalDecision.Approve, comment, ct);
|
||||
// All Steps done — terminal DaPhatHanh (thân nằm ở `FinalizeToDaPhatHanhAsync`).
|
||||
await FinalizeToDaPhatHanhAsync(contract, actorUserId, comment, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -544,6 +561,41 @@ public class ContractWorkflowService(
|
||||
}
|
||||
}
|
||||
|
||||
/// [WQ3-BE S190 · YC-030 Q3] ĐƯỜNG TERMINAL DUY NHẤT của nhánh duyệt V2 — gen mã RG-001 (nếu
|
||||
/// chưa có) → `DaPhatHanh` → xoá cặp con-trỏ + SLA → log.
|
||||
///
|
||||
/// 🔴 LUẬT (khuôn `ApplyApprovedValuesOnFinalize` bên KHKK `ContractSigningPlanWorkflowService.cs:342`,
|
||||
/// KHÔNG đếm số vì số tự lão-hoá): MỌI nhánh đưa HĐ V2 sang `DaPhatHanh` BẮT BUỘC đi qua đây.
|
||||
/// Bỏ qua = HĐ "Đã phát hành" mà `MaHopDong` rỗng, hoặc còn con-trỏ Bước/Cấp ⇒ nằm mãi trong
|
||||
/// hộp thư của một cấp duyệt đã xong việc. Tự kiểm 2 lệnh (phải khớp nhau trên nhánh V2):
|
||||
/// grep -n "Phase = ContractPhase.DaPhatHanh" src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
|
||||
/// grep -n "FinalizeToDaPhatHanhAsync" src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs
|
||||
/// (Nhánh V1 legacy `:229-244` CỐ Ý đứng ngoài — giữ nguyên từng byte cho 7 HĐ prod đang chạy.)
|
||||
///
|
||||
/// `MaHopDong` đã có ⇒ KHÔNG gen lại (HĐ bị trả lại rồi duyệt lại giữ nguyên mã cũ).
|
||||
private async Task FinalizeToDaPhatHanhAsync(
|
||||
Contract contract,
|
||||
Guid? actorUserId,
|
||||
string? logComment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Gen mã HĐ theo RG-001 (mirror V1 `:232-239`). FE sau khi nhận DaPhatHanh refresh MaHopDong.
|
||||
if (string.IsNullOrEmpty(contract.MaHopDong))
|
||||
{
|
||||
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
||||
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
|
||||
?? throw new NotFoundException("Project", contract.ProjectId);
|
||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||
}
|
||||
contract.Phase = ContractPhase.DaPhatHanh;
|
||||
contract.CurrentWorkflowStepIndex = null;
|
||||
contract.CurrentApprovalLevelOrder = null;
|
||||
contract.SlaDeadline = null;
|
||||
await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
|
||||
actorUserId, ApprovalDecision.Approve, logComment, ct);
|
||||
}
|
||||
|
||||
private async Task LogTransitionAsync(
|
||||
Contract contract,
|
||||
ContractPhase fromPhase,
|
||||
|
||||
@ -0,0 +1,463 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
// `NoOpNotificationService` là stub `internal` sống ở
|
||||
// `Services/PurchaseEvaluationWorkflowServiceGuardTests.cs:188` — dùng lại thay vì chép bản thứ hai
|
||||
// (bản sao thứ hai của một no-op là thứ sẽ lệch nhau lúc `INotificationService` nở thêm hàm).
|
||||
using SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [WQ3-TEST · YC-030 Q3 — S190 2026-08-12] KẾT THÚC TẠI CẤP (level-finalize) của Hợp đồng V2.
|
||||
//
|
||||
// Đối tượng đo: nhánh `ContractWorkflowService.ApproveV2Async:527-535` (cờ `EndedByLevelFinalize`
|
||||
// + `FinalizeToDaPhatHanhAsync:576`). Đây là **test-bù** (mã BE đã land trước, chưa commit) —
|
||||
// KHÔNG phải test-before; nếu ca nào đỏ thì đỏ đó là phát hiện về mã, không được nới assert.
|
||||
//
|
||||
// ── VÌ SAO QUY TRÌNH PHẢI CÓ ĐUÔI (điều kiện SỐNG CÒN của phép đo, bài S164-K3) ──────────────
|
||||
// Đặt cờ finalize ở slot CUỐI thì "hợp đồng phát hành + có mã" xảy ra ở CẢ HAI thế giới (có
|
||||
// nhánh finalize / không có nhánh finalize) ⇒ F-1 xanh mà chứng minh 0. Nên cả 4 ca dùng chung
|
||||
// một hình: **2 Bước** — Bước 1 có **2 Cấp**, Bước 2 có 1 Cấp — và người bấm Duyệt luôn là
|
||||
// **Bước 1 Cấp 1**, tức còn NGUYÊN 2 chặng phía sau. Gỡ nhánh finalize khỏi production ⇒ F-1 rơi
|
||||
// về "sang Cấp 2 cùng Bước 1" ⇒ ĐỎ. Hình này được chốt bằng `AssertFinalizeSlotIsNotTheLastAsync`
|
||||
// gọi ngay trước mỗi lượt bấm, để một lần "gọn hoá" seed sau này không âm thầm làm rỗng cả file.
|
||||
//
|
||||
// ── MA TRẬN 3 CA CHỨNG `&&` (cùng ruột, khác ĐÚNG 1 biến) ────────────────────────────────────
|
||||
// F-1: cờ Cấp = true · ý người duyệt = MẶC ĐỊNH (không truyền tham số) ⇒ PHÁT HÀNH
|
||||
// F-2: cờ Cấp = true · applyLevelFinalize = false ⇒ đi tiếp bình thường
|
||||
// F-3: cờ Cấp = false · applyLevelFinalize = true ⇒ đi tiếp bình thường
|
||||
// F-1 giữ vế dương. F-2/F-3 mỗi ca tắt MỘT vế khác nhau — cài `||` thay `&&` (`:527`) sẽ làm ĐỎ
|
||||
// cả hai; bỏ đọc tham số ⇒ chỉ F-2 đỏ; bỏ đọc cờ cấu hình ⇒ chỉ F-3 đỏ. Ba ca không thay thế nhau.
|
||||
// 🔴 F-1 CỐ Ý KHÔNG truyền `applyLevelFinalize`: đó là phép đo DUY NHẤT cho ngữ nghĩa OPT-OUT
|
||||
// (`IContractWorkflowService.cs:25` mặc định `true`). Truyền tay `true` là bỏ mất vế đó.
|
||||
//
|
||||
// ── F-4: bất biến sau khi đã phát hành ───────────────────────────────────────────────────────
|
||||
// Đọc mã trước khi chọn assert (lệnh cấm nới): sau finalize `Phase=DaPhatHanh` ⇒ lượt bấm Duyệt
|
||||
// kế tiếp trượt hết mọi nhánh của `TransitionAsync` (reject `:55` / trình `:96` / duyệt `:159`
|
||||
// đều đòi phase khác) và rơi xuống câu cuối `:268` `ConflictException("Transition … không hỗ
|
||||
// trợ.")`. Vậy assert đúng là **409 + không đổi gì**, KHÔNG phải "im lặng bỏ qua".
|
||||
//
|
||||
// ── 2 quyết định hạ tầng ─────────────────────────────────────────────────────────────────────
|
||||
// (1) `IdentityFixture` (KHÔNG phải fixture riêng có `AuditingInterceptor` kiểu
|
||||
// `ContractDeleteGuardTests.cs:69`). Service đòi `UserManager<User>` thật, còn interceptor chỉ
|
||||
// phục vụ ô audit/xoá-mềm — file này không đo ô nào trong số đó. Bê interceptor vào đây là
|
||||
// nghi thức thừa, lại phải dựng lại toàn bộ Identity wiring. Khuôn theo file cùng đối tượng đo:
|
||||
// `Services/ContractWorkflowServiceApproveV2Tests.cs:25`.
|
||||
// (2) Mỗi ca một fixture ⇒ một DB SQLite in-memory riêng ⇒ `ContractCodeSequences` không dính
|
||||
// nhau; mã HĐ mong đợi luôn là `/01`. Vẫn đặt Code NCC/dự án khác nhau từng ca cho dễ đọc log.
|
||||
public class ContractApproveFinalizeTests
|
||||
{
|
||||
private static readonly DateTime Clock2026 = new(2026, 8, 12, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Dựng service đúng 6 dep như prod (`ContractWorkflowService.cs:28-34`), changelog THẬT để
|
||||
// còn đọc được dòng nhật ký phân biệt finalize ⟂ advance thường.
|
||||
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(Clock2026);
|
||||
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);
|
||||
}
|
||||
|
||||
// Quy trình 2 BƯỚC: Bước 1 (Cấp 1 + Cấp 2) → Bước 2 (Cấp 1). Cờ finalize chỉ gắn ở slot
|
||||
// Bước 1 Cấp 1 — đúng chỗ người duyệt sẽ bấm, và là slot còn 2 chặng phía sau.
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1)>
|
||||
SeedTwoStepWorkflowAsync(
|
||||
TestApplicationDbContext db,
|
||||
Guid s1l1Approver,
|
||||
Guid s1l2Approver,
|
||||
Guid s2l1Approver,
|
||||
bool allowFinalizeAtS1L1,
|
||||
string code)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Name = "Quy trình duyệt HĐ — 2 Bước",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var s1 = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null,
|
||||
Name = "Bước 1 Phòng Mua hàng",
|
||||
};
|
||||
var s2 = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 2,
|
||||
DepartmentId = null,
|
||||
Name = "Bước 2 Ban Giám đốc",
|
||||
};
|
||||
var s1l1 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = s1.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = s1l1Approver,
|
||||
AllowApproverFinalize = allowFinalizeAtS1L1,
|
||||
};
|
||||
var s1l2 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = s1.Id,
|
||||
Order = 2,
|
||||
ApproverUserId = s1l2Approver,
|
||||
};
|
||||
var s2l1 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = s2.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = s2l1Approver,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.AddRange(s1, s2);
|
||||
db.ApprovalWorkflowLevels.AddRange(s1l1, s1l2, s2l1);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, s1, s1l1);
|
||||
}
|
||||
|
||||
// TIỀN ĐỀ chống-rỗng: slot sắp bấm Duyệt (Bước 1 Cấp 1) phải còn ĐUÔI phía sau. Xem khối
|
||||
// "VÌ SAO QUY TRÌNH PHẢI CÓ ĐUÔI" ở đầu file — thiếu vế này thì F-1 đúng ở mọi thế giới.
|
||||
private static async Task AssertFinalizeSlotIsNotTheLastAsync(
|
||||
TestApplicationDbContext db, Guid workflowId, Guid step1Id)
|
||||
{
|
||||
(await db.ApprovalWorkflowSteps.CountAsync(s => s.ApprovalWorkflowId == workflowId))
|
||||
.Should().Be(2, "phải còn Bước 2 phía sau thì 'kết thúc sớm' mới khác 'đi hết quy trình'");
|
||||
(await db.ApprovalWorkflowLevels.CountAsync(l => l.ApprovalWorkflowStepId == step1Id))
|
||||
.Should().Be(2, "ngay trong Bước 1 cũng còn Cấp 2 chưa duyệt");
|
||||
}
|
||||
|
||||
private static async Task<(Supplier sup, Project proj)> SeedSupplierProjectAsync(
|
||||
TestApplicationDbContext db, string supplierCode, string projectCode)
|
||||
{
|
||||
var sup = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = supplierCode,
|
||||
Name = $"NCC {supplierCode}",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
var proj = new Project
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = projectCode,
|
||||
Name = $"Dự án {projectCode}",
|
||||
};
|
||||
db.Suppliers.Add(sup);
|
||||
db.Projects.Add(proj);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (sup, proj);
|
||||
}
|
||||
|
||||
// HĐ đang chờ duyệt, con-trỏ đứng ở Bước 1 (index 0) Cấp 1 — đúng hình sau khi người soạn trình.
|
||||
private static Contract BuildContractAtStep0Level1(Guid workflowId, Guid supplierId, Guid projectId)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ContractType.HopDongThauPhu,
|
||||
Phase = ContractPhase.ChoDuyet,
|
||||
SupplierId = supplierId,
|
||||
ProjectId = projectId,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
TenHopDong = "HĐ thầu phụ — ca level-finalize",
|
||||
GiaTri = 800_000_000m,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
};
|
||||
|
||||
private static async Task<(User a1, User a2, User a3)> SeedThreeApproversAsync(
|
||||
IdentityFixture fix, string tag)
|
||||
{
|
||||
var a1 = await fix.CreateUserAsync($"b1c1-{tag}@test.local", $"NV Bước1 Cấp1 {tag}",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var a2 = await fix.CreateUserAsync($"b1c2-{tag}@test.local", $"NV Bước1 Cấp2 {tag}",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var a3 = await fix.CreateUserAsync($"b2c1-{tag}@test.local", $"NV Bước2 Cấp1 {tag}",
|
||||
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
return (a1, a2, a3);
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// F-1 — Cấp có cờ + người duyệt KHÔNG bỏ tick (mặc định) ⇒ HĐ PHÁT HÀNH ngay tại đây.
|
||||
// 5 vế đo, mỗi vế canh một tầng khác nhau của `FinalizeToDaPhatHanhAsync`:
|
||||
// (a) Phase = DaPhatHanh → có kết thúc thật
|
||||
// (b) EndedByLevelFinalize = true → kết thúc BẰNG ĐƯỜNG NÀO (cột runtime Mig 73)
|
||||
// (c) MaHopDong sinh đúng RG-001 → không đẻ HĐ phát hành mà rỗng mã (luật choke-point)
|
||||
// (d) con-trỏ Bước/Cấp + SLA = null → không kẹt trong hộp thư cấp đã xong việc
|
||||
// (e) đọc lại từ DB → cờ được LƯU, không chỉ set trong bộ nhớ
|
||||
// =====================================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_LevelFinalizeFlag_WithDefaultApply_PublishesContractAndSetsRuntimeFlag()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f1");
|
||||
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
|
||||
var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
|
||||
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN1");
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN1", projectCode: "PFIN1");
|
||||
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
|
||||
|
||||
// 🔴 KHÔNG truyền `applyLevelFinalize` — đo luôn mặc định OPT-OUT của hợp đồng dịch vụ.
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: a1.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "đủ hồ sơ, phát hành luôn",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.Phase.Should().Be(ContractPhase.DaPhatHanh,
|
||||
"Cấp bật 'kết thúc tại cấp' bấm Duyệt ⇒ HĐ phát hành ngay, không trình tiếp Cấp 2 / Bước 2");
|
||||
contract.EndedByLevelFinalize.Should().BeTrue(
|
||||
"cột runtime phải nói HĐ kết thúc BẰNG ĐƯỜNG level-finalize, không suy từ cấu hình (bài #78)");
|
||||
contract.MaHopDong.Should().Be("PFIN1/HĐTP/SOL&FIN1/01",
|
||||
"kết thúc sớm vẫn phải đi qua đường terminal duy nhất ⇒ có mã RG-001 như HĐ đi hết quy trình");
|
||||
contract.CurrentWorkflowStepIndex.Should().BeNull("terminal xoá con-trỏ Bước");
|
||||
contract.CurrentApprovalLevelOrder.Should().BeNull("terminal xoá con-trỏ Cấp");
|
||||
contract.SlaDeadline.Should().BeNull("hết hạn xử lý vì không còn ai phải xử lý");
|
||||
|
||||
// (e) cờ phải nằm trong DB — `TransitionAsync:168` SaveChanges sau nhánh V2.
|
||||
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
|
||||
row.EndedByLevelFinalize.Should().BeTrue("cờ được LƯU, không chỉ sống trong entity đang track");
|
||||
row.Phase.Should().Be(ContractPhase.DaPhatHanh);
|
||||
row.MaHopDong.Should().Be("PFIN1/HĐTP/SOL&FIN1/01");
|
||||
|
||||
// Vết nhật ký phân biệt hẳn với terminal thường (dòng terminal thường không mang chữ này).
|
||||
var changelogs = await db.ContractChangelogs
|
||||
.Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
|
||||
.ToListAsync();
|
||||
changelogs.Should().Contain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"),
|
||||
"người đọc lịch sử phải phân biệt được 'phát hành vì hết quy trình' ⟂ 'phát hành vì cấp này chốt'");
|
||||
|
||||
// Ý kiến cấp duyệt vẫn được UPSERT trước khi finalize (`:442-469`) — kết thúc sớm
|
||||
// KHÔNG được nuốt mất chữ ký của chính người bấm.
|
||||
var opinions = await db.ContractLevelOpinions.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1);
|
||||
opinions[0].SignedByUserId.Should().Be(a1.Id);
|
||||
opinions[0].Comment.Should().Be("đủ hồ sơ, phát hành luôn");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// F-2 — CÙNG seed F-1, đổi ĐÚNG một biến: `applyLevelFinalize=false` (người duyệt bỏ tick).
|
||||
// Vế OPT-OUT: cờ cấu hình bật KHÔNG đủ để kết thúc. Kết quả phải là advance thường.
|
||||
// =====================================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_LevelFinalizeFlag_ButApproverOptsOut_AdvancesNormally()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f2");
|
||||
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
|
||||
var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
|
||||
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN2");
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN2", projectCode: "PFIN2");
|
||||
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: a1.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "duyệt nhưng để Cấp 2 xem tiếp",
|
||||
applyLevelFinalize: false,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||
"bỏ tick 'kết thúc tại cấp' ⇒ HĐ đi tiếp như một lượt duyệt bình thường");
|
||||
contract.CurrentApprovalLevelOrder.Should().Be(2, "sang Cấp 2 cùng Bước 1");
|
||||
contract.CurrentWorkflowStepIndex.Should().Be(0, "vẫn trong Bước 1 vì còn Cấp 2");
|
||||
contract.EndedByLevelFinalize.Should().BeFalse(
|
||||
"không kết thúc sớm thì tuyệt đối không được gắn nhãn kết thúc sớm");
|
||||
contract.MaHopDong.Should().BeNull("chưa phát hành thì chưa cấp mã HĐ");
|
||||
contract.SlaDeadline.Should().NotBeNull("SLA đặt lại cho Cấp 2 nhận việc");
|
||||
|
||||
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
|
||||
row.EndedByLevelFinalize.Should().BeFalse();
|
||||
row.Phase.Should().Be(ContractPhase.ChoDuyet);
|
||||
|
||||
var changelogs = await db.ContractChangelogs
|
||||
.Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
|
||||
.ToListAsync();
|
||||
changelogs.Should().Contain(c => c.ContextNote != null
|
||||
&& c.ContextNote.Contains("Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"));
|
||||
changelogs.Should().NotContain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"),
|
||||
"không được để lại vết 'kết thúc tại cấp' cho một lượt duyệt bình thường");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// F-3 — Vế thứ hai của phép `&&`: Cấp KHÔNG có cờ, người duyệt gửi `applyLevelFinalize=true`.
|
||||
// Ý người dùng KHÔNG tự cấp được quyền kết thúc — quyền đó do Admin gắn ở Designer.
|
||||
// (Ca này là cửa chặn thật: `ApplyLevelFinalize` đến từ body request, ai cũng gửi được true.)
|
||||
// =====================================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_NoLevelFinalizeFlag_EvenWhenApplyRequested_AdvancesNormally()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f3");
|
||||
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
|
||||
var (wf, s1, s1l1) = await SeedTwoStepWorkflowAsync(
|
||||
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: false, code: "QT-CT-FIN3");
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN3", projectCode: "PFIN3");
|
||||
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
|
||||
// Chốt biến đang bị tắt là ĐÚNG biến định tắt (seed sai cờ ⇒ ca này trùng F-2).
|
||||
(await db.ApprovalWorkflowLevels.AsNoTracking().SingleAsync(l => l.Id == s1l1.Id))
|
||||
.AllowApproverFinalize.Should().BeFalse("ca này tắt vế CẤU HÌNH, giữ nguyên vế ý-người-duyệt");
|
||||
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: a1.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "tự xin kết thúc dù Cấp không được phép",
|
||||
applyLevelFinalize: true,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||
"Cấp không được Admin cho phép kết thúc ⇒ xin cũng không được, HĐ đi tiếp");
|
||||
contract.CurrentApprovalLevelOrder.Should().Be(2);
|
||||
contract.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
contract.EndedByLevelFinalize.Should().BeFalse();
|
||||
contract.MaHopDong.Should().BeNull("không có đường nào phát hành ⇒ không có mã");
|
||||
|
||||
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
|
||||
row.EndedByLevelFinalize.Should().BeFalse();
|
||||
row.Phase.Should().Be(ContractPhase.ChoDuyet);
|
||||
|
||||
var changelogs = await db.ContractChangelogs
|
||||
.Where(c => c.ContractId == contract.Id && c.EntityType == ChangelogEntityType.Workflow)
|
||||
.ToListAsync();
|
||||
changelogs.Should().NotContain(c => c.ContextNote != null && c.ContextNote.Contains("KẾT THÚC TẠI CẤP"));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// F-4 — Sau khi đã kết thúc sớm, HĐ là BẤT BIẾN với mọi lượt bấm Duyệt tiếp theo.
|
||||
// Hai người bấm, hai lý do đời thực khác nhau, cùng phải 409:
|
||||
// (a) chính người vừa chốt bấm lại (double-click / gửi lại request)
|
||||
// (b) NV Cấp 2 — người vẫn đang thấy HĐ trong hộp thư của mình lúc quy trình bị cắt ngang
|
||||
// Vế "không đổi gì" quan trọng ngang vế ném lỗi: một cài đặt chặn-bằng-cách-ném-sau-khi-ghi
|
||||
// vẫn xanh nếu chỉ assert exception. Nên đo cả mã HĐ, cả số vết duyệt.
|
||||
// =====================================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_AfterLevelFinalize_FurtherApproveThrowsConflict_AndChangesNothing()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2, a3) = await SeedThreeApproversAsync(fix, "f4");
|
||||
var (svc, db) = WireService(fix, a1.Id, AppRoles.CostControl);
|
||||
var (wf, s1, _) = await SeedTwoStepWorkflowAsync(
|
||||
db, a1.Id, a2.Id, a3.Id, allowFinalizeAtS1L1: true, code: "QT-CT-FIN4");
|
||||
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "FIN4", projectCode: "PFIN4");
|
||||
var contract = BuildContractAtStep0Level1(wf.Id, sup.Id, proj.Id);
|
||||
db.Contracts.Add(contract);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await AssertFinalizeSlotIsNotTheLastAsync(db, wf.Id, s1.Id);
|
||||
|
||||
// Lượt 1 — kết thúc tại cấp (tiền đề của cả ca; xanh ở đây trùng với F-1).
|
||||
await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: a1.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "chốt tại Cấp 1",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "tiền đề của F-4");
|
||||
var maSauFinalize = contract.MaHopDong;
|
||||
maSauFinalize.Should().Be("PFIN4/HĐTP/SOL&FIN4/01");
|
||||
|
||||
// (a) chính người vừa chốt bấm lại.
|
||||
var actAgainSamePerson = async () => await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: a1.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "bấm lại lần nữa",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await actAgainSamePerson.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*DaPhatHanh*không hỗ trợ*",
|
||||
"HĐ đã phát hành không còn nhánh duyệt nào nhận nó (`ContractWorkflowService.cs:268`)");
|
||||
|
||||
// (b) NV Cấp 2 — người bị cắt lượt vẫn còn thấy nút Duyệt trên màn hình cũ.
|
||||
var actNextLevelApprover = async () => await svc.TransitionAsync(
|
||||
contract: contract,
|
||||
targetPhase: ContractPhase.ChoDuyet,
|
||||
actorUserId: a2.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "Cấp 2 duyệt trên màn hình cũ",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await actNextLevelApprover.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*DaPhatHanh*không hỗ trợ*");
|
||||
|
||||
// Bất biến: không lượt bấm nào để lại dấu vết hay xê dịch trạng thái.
|
||||
contract.Phase.Should().Be(ContractPhase.DaPhatHanh);
|
||||
contract.MaHopDong.Should().Be(maSauFinalize, "không được cấp mã HĐ lần thứ hai");
|
||||
contract.EndedByLevelFinalize.Should().BeTrue("nhãn kết thúc sớm giữ nguyên");
|
||||
contract.CurrentWorkflowStepIndex.Should().BeNull("không được hồi sinh con-trỏ Bước");
|
||||
contract.CurrentApprovalLevelOrder.Should().BeNull("không được hồi sinh con-trỏ Cấp");
|
||||
|
||||
var approvals = await db.ContractApprovals.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||
approvals.Should().HaveCount(1, "đúng 1 vết duyệt — 2 lượt bấm sau bị chặn TRƯỚC khi ghi");
|
||||
|
||||
var opinions = await db.ContractLevelOpinions.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1, "không đẻ thêm ý kiến cấp duyệt cho lượt bấm bị chặn");
|
||||
|
||||
var row = await db.Contracts.AsNoTracking().SingleAsync(c => c.Id == contract.Id);
|
||||
row.Phase.Should().Be(ContractPhase.DaPhatHanh);
|
||||
row.MaHopDong.Should().Be(maSauFinalize);
|
||||
row.EndedByLevelFinalize.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user