+ )}
+
+ {/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
+ {opinions.length > 0 && (
+
+
+
+ Ý kiến cấp duyệt
+
+ {opinions.length}
+
+
+
+ {opinions.map(o => (
+
+
+ {o.signedByFullName}
+ ·
+
+ {o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'}
+ {o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
+
+ ·
+ {formatDateTime(o.signedAt)}
+ {/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
+ {o.approverUserId && o.approverUserId !== o.signedByUserId && (
+
+ duyệt thay
+
+ )}
+
+
+ {o.comment?.trim() || '(duyệt — không ý kiến)'}
+
+
+ ))}
+
+
+ )}
+
+
+ {action !== null && (
+
+ )}
+
+ )
+}
diff --git a/fe-user/src/types/khkk.ts b/fe-user/src/types/khkk.ts
index 1d5efe8..a7b9e34 100644
--- a/fe-user/src/types/khkk.ts
+++ b/fe-user/src/types/khkk.ts
@@ -20,7 +20,10 @@
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
// GET /contract-signing-plans/{id}/attachments/{attId}/download
// DELETE /contract-signing-plans/{id}/attachments/{attId}
-// (transitions submit/approve/return/reject = W3, CỐ Ý chưa có ở đây.)
+//
+// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
+// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
+// policy `KeHoachKyKet.Update`
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
export const KhkkPhase = {
@@ -161,12 +164,36 @@ export interface KhkkLevelOpinionDto {
stepOrder: number | null
stepName: string | null
levelOrder: number | null
+ // [W3 S161] BE `ContractSigningPlanLevelOpinionDto:99` CÓ field này (W2 khai thiếu).
+ // `signedByUserId !== approverUserId` ⇒ có người duyệt THAY (Admin) — panel gắn nhãn.
+ approverUserId: string | null
comment: string | null
signedAt: string
signedByUserId: string
signedByFullName: string
}
+// [W3 S161] Cây quy trình THÔ do BE trả (`ContractSigningPlanWorkflowLevelDto:105-110`).
+// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending — FE tự suy từ cặp
+// con-trỏ `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder`
+// (GIÁ TRỊ `level.order`). Xem `ContractSigningPlanFeatures.cs:224-226` + `:259-260`.
+export interface KhkkWorkflowLevelDto {
+ id: string
+ order: number
+ name: string | null
+ approverUserId: string
+ approverFullName: string | null
+}
+
+// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
+export interface KhkkWorkflowStepDto {
+ id: string
+ order: number
+ name: string
+ departmentId: string | null
+ levels: KhkkWorkflowLevelDto[]
+}
+
export interface KhkkDetailDto extends KhkkListItemDto {
workflowName: string | null
currentWorkflowStepIndex: number | null
@@ -177,6 +204,8 @@ export interface KhkkDetailDto extends KhkkListItemDto {
dossierItems: KhkkDossierItemDto[]
attachments: KhkkAttachmentDto[]
levelOpinions: KhkkLevelOpinionDto[]
+ // `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
+ workflowSteps: KhkkWorkflowStepDto[]
}
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
@@ -218,6 +247,34 @@ export interface UpsertKhkkDossierItemInput {
note?: string | null
}
+// ===================== W3 — chuyển trạng thái (duyệt 3 trạm) =========================
+// 🔴 HỢP-ĐỒNG LITERAL do LEAD chốt (S161 W3). Tên field ở đây = tên field BE bind.
+// Đổi 1 chữ ở đây mà không đổi BE = đứt im lặng (bài W2 6-điểm-đứt).
+// submit = trình duyệt (DangSoanThao | TraLai → ChoDuyet)
+// approve = duyệt cấp hiện tại (OR-of-N; comment ≠ rỗng ⇒ UPSERT LevelOpinion)
+// return = trả lại (→ TraLai)
+// reject = từ chối (→ TuChoi)
+export const KhkkTransitionAction = {
+ Submit: 'submit',
+ Approve: 'approve',
+ Return: 'return',
+ Reject: 'reject',
+} as const
+export type KhkkTransitionActionValue =
+ (typeof KhkkTransitionAction)[keyof typeof KhkkTransitionAction]
+
+export interface KhkkTransitionInput {
+ action: KhkkTransitionActionValue
+ comment: string | null
+}
+
+// Response 200 — 3 field, ĐÚNG tên hợp-đồng. `phase` là INT (mirror `KhkkPhase`).
+export interface KhkkTransitionResult {
+ phase: number
+ currentWorkflowStepIndex: number | null
+ currentApprovalLevelOrder: number | null
+}
+
export interface PagedResult {
items: T[]
total: number
diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs
index f4ed031..01f4999 100644
--- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.ContractSigningPlans;
+using SolutionErp.Application.ContractSigningPlans.Services;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Api.Controllers;
@@ -18,12 +19,16 @@ namespace SolutionErp.Api.Controllers;
// (`MenuKeys.cs:42,180`) × `MenuKeys.Actions` trong `Api/Program.cs`. Key KHÔNG nằm
// trong `All` ⇒ 500 lúc chạy (policy không tồn tại), không phải 403.
//
-// 🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3.
+// [W3 đợt-1 — S161] ĐÃ CÓ endpoint transition (đính chính dòng cũ "KHÔNG có ... đó là W3"):
+// `POST {id}/transitions` mở ở cuối file. Đợt-1 mới là SKELETON — service ném
+// NotImplementedException ⇒ endpoint trả 500 cho tới khi đợt-3 điền logic. CỐ Ý: test-before.
// GlobalExceptionMiddleware map exception → ProblemDetails ⇒ TUYỆT ĐỐI không try-catch ở đây.
[ApiController]
[Route("api/contract-signing-plans")]
[Authorize(Policy = "KeHoachKyKet.Read")]
-public class ContractSigningPlansController(IMediator mediator) : ControllerBase
+public class ContractSigningPlansController(
+ IMediator mediator,
+ IContractSigningPlanWorkflowService workflow) : ControllerBase
{
// ========================= Đọc =========================
@@ -190,6 +195,21 @@ public class ContractSigningPlansController(IMediator mediator) : ControllerBase
return NoContent();
}
+ // ===================== Duyệt 3 trạm (W3) =====================
+ // 🔴 HỢP-ĐỒNG LITERAL (lead chốt @S161 — 2 lane BE/FE khớp từng tên field):
+ // body { "action": "submit"|"approve"|"return"|"reject", "comment": string|null }
+ // response { "phase": int, "currentWorkflowStepIndex": int|null,
+ // "currentApprovalLevelOrder": int|null }
+ // Policy `KeHoachKyKet.Update` (KHÔNG Create/Delete): duyệt = GHI vào phiếu đã có.
+ // Đi THẲNG service (không qua MediatR) vì đây là 1 thao tác state-machine, không phải
+ // CQRS command — mirror `ContractsController` gọi `IContractWorkflowService`.
+
+ [HttpPost("{id:guid}/transitions")]
+ [Authorize(Policy = "KeHoachKyKet.Update")]
+ public async Task> Transition(
+ Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct)
+ => Ok(await workflow.TransitionAsync(id, body.Action, body.Comment, ct));
+
// ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
@@ -197,4 +217,12 @@ public class ContractSigningPlansController(IMediator mediator) : ControllerBase
string? GhiChu = null,
string? HoSoLink = null,
Guid? ApprovalWorkflowId = null);
+
+ // Transition: `Action` BẮT BUỘC (thiếu ⇒ service ném 409 "Hành động không hợp lệ"),
+ // `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" —
+ // ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường
+ // (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy).
+ public record ContractSigningPlanTransitionBody(
+ string Action,
+ string? Comment = null);
}
diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs
new file mode 100644
index 0000000..0f55b30
--- /dev/null
+++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs
@@ -0,0 +1,41 @@
+namespace SolutionErp.Application.ContractSigningPlans.Services;
+
+// [W3 ĐỢT-1 SKELETON — S161 2026-07-29] Hợp đồng transition phiếu "Kế hoạch ký kết HĐ" (GĐ2).
+//
+// 🔴 ĐỢT NÀY = SKELETON THUẦN (test-before): interface + record chốt CHỮ KÝ để
+// test-specialist viết 6 test RED compile được. Thân service NÉM NotImplementedException
+// — logic thật (OR-of-N, con-trỏ đôi, LevelOpinions UPSERT, finalize) là ĐỢT-3.
+//
+// 🔴 HỢP-ĐỒNG LITERAL (lead định nghĩa @S161 — bài W2 "6 điểm đứt": 2 lane implement phải
+// khớp TỪNG TÊN FIELD, không tự đổi):
+// POST /api/contract-signing-plans/{id}/transitions [Authorize(Policy="KeHoachKyKet.Update")]
+// body : { "action": "submit" | "approve" | "return" | "reject", "comment": string|null }
+// response : { "phase": int, "currentWorkflowStepIndex": int|null, "currentApprovalLevelOrder": int|null }
+//
+// Ý nghĩa 4 action:
+// submit — người soạn trình phiếu: DangSoanThao | TraLai → ChoDuyet (chạy LẠI từ Bước 1 Cấp 1)
+// approve — approver Cấp đang chờ duyệt: tiến con-trỏ OR-of-N (+comment ⇒ UPSERT LevelOpinion)
+// return — trả lại người soạn: → TraLai
+// reject — từ chối: → TuChoi (terminal)
+public interface IContractSigningPlanWorkflowService
+{
+ // Thực hiện 1 transition. Ném ConflictException/ForbiddenException/NotFoundException
+ // (GlobalExceptionMiddleware map → ProblemDetails; controller KHÔNG try-catch).
+ // Trả về trạng-thái SAU transition để controller phản hồi thẳng 3 field hợp-đồng.
+ Task TransitionAsync(
+ Guid planId,
+ string action,
+ string? comment,
+ CancellationToken ct = default);
+}
+
+// 3 field ĐÚNG BẰNG response hợp-đồng, KHÔNG thừa không thiếu.
+// 🔴 `Phase` để kiểu `int` (KHÔNG ContractSigningPlanPhase) là CỐ Ý: hợp-đồng chốt
+// `"phase": int` ⇒ ép cast tại nguồn, khỏi phụ thuộc cấu hình JsonStringEnumConverter
+// (Api/Program.cs:33-34 hiện KHÔNG đăng ký converter enum, nhưng thêm sau sẽ lật kiểu
+// JSON của mọi enum ⇒ khoá kiểu ở đây là rào chống hồi-quy im lặng).
+// Null-semantics: 2 con-trỏ = null khi phiếu ở phase KHÔNG chạy workflow (nháp/terminal).
+public record ContractSigningPlanTransitionResult(
+ int Phase,
+ int? CurrentWorkflowStepIndex,
+ int? CurrentApprovalLevelOrder);
diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
index b2f206d..ebdacf3 100644
--- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
+++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs
@@ -40,6 +40,9 @@ public static class DependencyInjection
services.AddScoped();
// [W1 Mig 69 — S161] mã phiếu KHKK/{YYYY}/{Seq:D3} qua WorkflowAppCodeSequences dùng chung.
services.AddScoped();
+ // [W3 đợt-1 — S161] workflow duyệt 3 trạm KHKK. Đợt-1 = SKELETON (mọi nhánh ném
+ // NotImplementedException); đăng ký sẵn để endpoint + test resolve được đúng chữ ký.
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs
new file mode 100644
index 0000000..a02fa74
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs
@@ -0,0 +1,589 @@
+using Microsoft.EntityFrameworkCore;
+using SolutionErp.Application.Common.Exceptions;
+using SolutionErp.Application.Common.Interfaces;
+using SolutionErp.Application.ContractSigningPlans.Services;
+using SolutionErp.Application.Notifications;
+using SolutionErp.Domain.ApprovalWorkflowsV2;
+using SolutionErp.Domain.ContractSigningPlans;
+using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (enum dùng chung)
+using SolutionErp.Domain.Identity; // AppRoles
+using SolutionErp.Domain.Notifications; // NotificationType
+
+namespace SolutionErp.Infrastructure.Services;
+
+// [W3 ĐỢT-3 — S161 2026-07-29] Workflow phiếu "Kế hoạch ký kết HĐ" (GĐ2).
+//
+// Đợt-1 để skeleton ném NotImplementedException cho 6 test sàn ĐỎ THẬT; đợt này điền
+// logic để chúng TỰ xanh. Hợp-đồng đợt-1 (4 GIỮ + 3 ADAPT + 2 CẤM) chép lại rút gọn
+// bên dưới kèm ĐỊA CHỈ, và ghi rõ chỗ nào đã thi hành / chỗ nào CỐ Ý không.
+//
+// NGUỒN COPY: `ContractWorkflowService.ApproveV2Async` (`ContractWorkflowService.cs:217-394`).
+// 🔴 CẤM copy `ProposalFeatures` — Proposal FLATTEN Level (SelectMany) nên OR-of-N biến
+// thành AND ÂM THẦM, và nó chỉ có 1 con-trỏ LevelOrder ⇒ không diễn tả được "Bước mới
+// reset Cấp về 1". Ở đây giữ đúng khuôn nguồn: `GroupBy(l => l.Order)` + `Contains(actor)`.
+//
+// 4 ĐIỂM GIỮ (mỗi điểm = 1 acceptance, đều đã thi hành):
+// 1. Con-trỏ ĐÔI `CurrentWorkflowStepIndex` (0-based INDEX vào list Steps đã sort theo
+// Order — KHÔNG phải giá trị Order) + `CurrentApprovalLevelOrder` (1-based).
+// Sang Bước mới ⇒ Cấp reset về 1 (`AdvancePointer` bên dưới).
+// 2. GroupBy Order = Cấp (khuôn `ContractWorkflowService.cs:246`).
+// 3. OR-of-N: MỘT người trong Cấp duyệt là Cấp đó xong (khuôn `:255-266`) —
+// `ResolveActingLevel` khớp theo tập, KHÔNG đòi đủ chữ ký cả nhóm.
+// 4. UPSERT ý kiến vào `ContractSigningPlanLevelOpinions` (khuôn `:292-316`), 1 row /
+// cặp (Plan × Level), `SignedByUserId` = người ký THẬT (Admin duyệt thay vẫn ghi Admin).
+//
+// 3 ĐIỂM ADAPT:
+// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
+// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT**
+// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
+// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
+// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
+// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
+// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
+// Đó chính là write-path 2 mà test T3 đo.
+// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
+// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
+// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
+// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
+// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
+// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
+//
+// LỊCH SỬ: mỗi transition ghi `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs`
+// qua `LogSigningPlanTransitionAsync` (method KHKK-RIÊNG, bên dưới).
+// 🔴 CẤM `IChangelogService.LogWorkflowTransitionAsync(...)`: nó nhận `Guid contractId` và
+// ghi `ContractChangelogs` — bảng có FK VẬT LÝ `ContractId` Cascade
+// (`ContractChangelogConfiguration.cs:22-26`) ⇒ truyền `plan.Id` = FK violation 547 LÚC CHẠY,
+// compiler KHÔNG chặn. Rào bằng THIẾT KẾ: service CỐ Ý không inject `IChangelogService`
+// (không có sẵn thì không lỡ tay gọi) — giữ nguyên từ đợt-1.
+//
+// ĐIỂM LƯU (khai để lead phân xử): ĐÚNG MỘT `SaveChangesAsync` ở CUỐI `TransitionAsync`.
+// Khác nguồn copy (`ContractWorkflowService.cs:537` TransitionAsync KHÔNG tự save, caller
+// save) vì ở đây service LÀ điểm vào (controller gọi thẳng, không qua MediatR) ⇒ không có
+// "caller downstream" nào để save hộ. 1 transition = 1 unit-of-work: con-trỏ + hàng
+// Approvals + Changelogs + LevelOpinion + giá chốt + Notifications cùng sống hoặc cùng chết
+// (`NotificationService.cs:7-8` cố ý KHÔNG tự SaveChanges để caller giữ atomicity).
+public class ContractSigningPlanWorkflowService(
+ IApplicationDbContext db,
+ ICurrentUser currentUser,
+ IDateTime dateTime,
+ INotificationService notifications) : IContractSigningPlanWorkflowService
+{
+ // Tập action khoá theo hợp-đồng literal của lead. So khớp KHÔNG phân biệt hoa/thường
+ // (client gửi "Submit" vẫn hiểu), nhưng nhánh xử lý luôn chạy trên bản đã chuẩn hoá.
+ private static readonly HashSet AllowedActions =
+ new(StringComparer.OrdinalIgnoreCase) { "submit", "approve", "return", "reject" };
+
+ // Ý kiến rỗng → chữ thay thế (khuôn nguồn copy `ContractWorkflowService.cs:295-297`):
+ // ô câm không phân biệt được "đã ký, không ý kiến" với "chưa ký".
+ private const string NoOpinionPlaceholder = "(duyệt — không ý kiến)";
+
+ // Trần độ dài ý kiến = cột CHẶT NHẤT trong các nơi chứa nguyên văn comment:
+ // `ContractSigningPlanApprovals.Comment` = 1000 (`ContractSigningPlanApprovalConfiguration.cs:26`).
+ // Chặn TẠI CỔNG thay vì cắt ngầm: đường này KHÔNG đi qua MediatR nên KHÔNG có
+ // FluentValidation nào đỡ; để lọt thì SQL Server ném 2628 (chuỗi bị cắt) = 500 khó hiểu.
+ private const int MaxCommentLength = 1000;
+
+ public async Task TransitionAsync(
+ Guid planId,
+ string action,
+ string? comment,
+ CancellationToken ct = default)
+ {
+ // Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
+ // không phải "không tìm thấy phiếu" — trả 409 để FE hiện đúng nguyên nhân.
+ var normalized = (action ?? string.Empty).Trim().ToLowerInvariant();
+ if (!AllowedActions.Contains(normalized))
+ throw new ConflictException("Hành động không hợp lệ.");
+
+ var normalizedComment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
+ if (normalizedComment is not null && normalizedComment.Length > MaxCommentLength)
+ throw new ConflictException($"Ý kiến duyệt tối đa {MaxCommentLength} ký tự.");
+
+ if (!currentUser.IsAuthenticated || currentUser.UserId is null)
+ throw new UnauthorizedException();
+ var actorId = currentUser.UserId.Value;
+ var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
+
+ // Include Lines NGAY: nhánh finalize cần chốt giá, và nạp kèm ở đây thì phép lọc
+ // "chỉ dòng của phiếu này" là do CHÍNH quan hệ cha-con bảo đảm (xem LUẬT ở
+ // `ApplyApprovedValuesOnFinalize`), không phải do nhớ viết `Where(PlanId == ...)`.
+ var plan = await db.ContractSigningPlans
+ .Include(p => p.Lines)
+ .FirstOrDefaultAsync(p => p.Id == planId, ct)
+ ?? throw new NotFoundException("ContractSigningPlan", planId);
+
+ // switch TÁCH RIÊNG từng nhánh (không gộp) — mỗi hành động có guard riêng; gộp lại
+ // là mất ranh giới giữa "ai được trình" và "ai được duyệt".
+ switch (normalized)
+ {
+ case "submit":
+ await SubmitAsync(plan, actorId, isAdmin, normalizedComment, ct);
+ break;
+
+ case "approve":
+ await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
+ break;
+
+ case "return":
+ await ReturnOrRejectAsync(
+ plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TraLai, ct);
+ break;
+
+ case "reject":
+ await ReturnOrRejectAsync(
+ plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TuChoi, ct);
+ break;
+
+ default:
+ // Không tới được (đã lọc ở rào trên) — giữ để switch đóng kín.
+ throw new ConflictException("Hành động không hợp lệ.");
+ }
+
+ await db.SaveChangesAsync(ct);
+
+ // Trả trạng thái SAU transition, đọc từ chính entity vừa lưu ⇒ FE vẽ lại panel không
+ // cần refetch mà vẫn không thể lệch với DB.
+ return new ContractSigningPlanTransitionResult(
+ (int)plan.Phase, plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder);
+ }
+
+ // =====================================================================
+ // submit — người soạn trình phiếu
+ // =====================================================================
+ private async Task SubmitAsync(
+ ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
+ {
+ if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai))
+ throw new ConflictException("Chỉ trình duyệt được phiếu đang ở trạng thái Nháp hoặc Trả lại.");
+
+ // Nói RÕ nguyên nhân: phiếu chưa gắn quy trình thì không có Bước/Cấp nào để chờ,
+ // trình đại = phiếu treo ở "Đã gửi duyệt" mà không nằm trong hộp thư của ai.
+ if (plan.ApprovalWorkflowId is not Guid awId)
+ throw new ConflictException(
+ "Phiếu chưa gắn quy trình duyệt — chọn quy trình trong phiếu nháp trước khi trình.");
+
+ await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
+
+ var steps = await LoadStepsAsync(awId, ct);
+ var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
+ if (firstLevelGroup.Count == 0)
+ throw new ConflictException(
+ $"Quy trình duyệt chưa cấu hình Cấp 1 cho Bước 1 ({steps[0].Name}) — chưa trình được.");
+
+ var fromPhase = plan.Phase;
+ plan.Phase = ContractSigningPlanPhase.ChoDuyet;
+
+ // 🔴 RESET về đầu quy trình cho CẢ hai lối vào (Nháp lần đầu VÀ Trả lại gửi lại):
+ // nối tiếp chỗ đang dở thì bản sửa của người soạn không được các Bước trước xem lại
+ // lần nào (acceptance #6).
+ plan.CurrentWorkflowStepIndex = 0;
+ plan.CurrentApprovalLevelOrder = 1;
+
+ await LogSigningPlanTransitionAsync(
+ plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Pending, comment,
+ actingLevelId: null,
+ summary: fromPhase == ContractSigningPlanPhase.TraLai
+ ? "Trình duyệt lại kế hoạch ký kết (chạy lại từ Bước 1 Cấp 1)"
+ : "Trình duyệt kế hoạch ký kết",
+ ct: ct);
+
+ await NotifyPendingApproversAsync(plan, firstLevelGroup, actorId, ct);
+ }
+
+ /// Guard trình — CỐ Ý khác khuôn role-based của Contract (né lớp 403-PMH): phiếu là của
+ /// người soạn + phòng của người soạn, không phải của một vai trong quy trình.
+ /// 🔴 KHAI RÕ nới 1 vế so với chữ của spec ("CreatedBy == actor"): chấp nhận CẢ
+ /// `DrafterUserId == actor`. Hai cột này chỉ đường tới CÙNG một người ở đường tạo phiếu
+ /// W2 (`ContractSigningPlanFeatures.cs:372` gán `DrafterUserId = actorId`, còn `CreatedBy`
+ /// do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ nới KHÔNG mở thêm ai. Nhưng
+ /// phiếu sinh ngoài đường CQRS (seed/import chạy dưới danh nghĩa hệ thống) sẽ có
+ /// `CreatedBy = null` — đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình.
+ private async Task EnsureCanSubmitAsync(
+ ContractSigningPlan plan, Guid actorId, bool isAdmin, CancellationToken ct)
+ {
+ if (isAdmin) return;
+ if (plan.CreatedBy == actorId || plan.DrafterUserId == actorId) return;
+
+ // Trưởng phòng CÙNG phòng ban CỦA PHIẾU. So với `plan.DepartmentId` (phòng của phiếu),
+ // KHÔNG phải `Step.DepartmentId` — cột kia chỉ là hint của quy trình
+ // (`ContractSigningPlan.cs:26-28`).
+ if (currentUser.Roles.Contains(AppRoles.DeptManager) && plan.DepartmentId is Guid deptId)
+ {
+ var sameDept = await db.Users.AsNoTracking()
+ .AnyAsync(u => u.Id == actorId && u.DepartmentId == deptId, ct);
+ if (sameDept) return;
+ }
+
+ throw new ForbiddenException(
+ "Chỉ người soạn kế hoạch, trưởng phòng cùng phòng ban của phiếu hoặc Admin được trình duyệt.");
+ }
+
+ // =====================================================================
+ // approve — approver Cấp đang chờ duyệt (OR-of-N)
+ // =====================================================================
+ private async Task ApproveV2Async(
+ ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
+ {
+ if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
+ throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
+ if (plan.ApprovalWorkflowId is not Guid awId)
+ throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
+
+ var steps = await LoadStepsAsync(awId, ct);
+ var (currentIdx, currentStep, currentLevelOrder, levelGroups, maxLevelOrder) =
+ ResolvePointer(plan, steps);
+
+ var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
+ ?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
+
+ var actingLevel = ResolveActingLevel(
+ pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
+
+ // Ý kiến sync ngay lúc duyệt (Q1=1B — KHÔNG endpoint ý kiến rời).
+ await UpsertLevelOpinionAsync(plan, actingLevel, actorId, comment, ct);
+
+ var fromPhase = plan.Phase;
+
+ // ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
+ // hết Bước → FINALIZE.
+ if (currentLevelOrder < maxLevelOrder)
+ {
+ plan.CurrentApprovalLevelOrder = currentLevelOrder + 1;
+ await LogSigningPlanTransitionAsync(
+ plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
+ actingLevel.Id,
+ $"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — chuyển Cấp {currentLevelOrder + 1}",
+ ct);
+ await NotifyPendingApproversAsync(
+ plan, LevelsOf(currentStep, currentLevelOrder + 1), actorId, ct);
+ return;
+ }
+
+ var nextIdx = currentIdx + 1;
+ if (nextIdx < steps.Count)
+ {
+ plan.CurrentWorkflowStepIndex = nextIdx;
+ plan.CurrentApprovalLevelOrder = 1; // 🔴 Bước mới ⇒ Cấp RESET về 1
+ await LogSigningPlanTransitionAsync(
+ plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
+ actingLevel.Id,
+ $"Hoàn tất Bước {currentIdx + 1}/{steps.Count} — chuyển Bước {nextIdx + 1} (Cấp 1)",
+ ct);
+ await NotifyPendingApproversAsync(plan, LevelsOf(steps[nextIdx], 1), actorId, ct);
+ return;
+ }
+
+ // ---- Cấp cuối của Bước cuối ⇒ terminal.
+ ApplyApprovedValuesOnFinalize(plan);
+ plan.Phase = ContractSigningPlanPhase.DaDuyet;
+ plan.CurrentWorkflowStepIndex = null; // terminal ⇒ không nằm trong hộp thư ai nữa
+ plan.CurrentApprovalLevelOrder = null;
+ await LogSigningPlanTransitionAsync(
+ plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
+ actingLevel.Id,
+ $"Duyệt Cấp cuối (Bước {currentIdx + 1}/{steps.Count}) — kế hoạch ký kết ĐÃ DUYỆT, chốt giá",
+ ct);
+ }
+
+ /// 🔴 LUẬT (không đếm số — số-đếm tự lão-hoá, bài `PurchaseEvaluationWorkflowService.cs:1008`):
+ /// MỌI nhánh đưa phiếu sang `DaDuyet` BẮT BUỘC gọi helper này TRƯỚC khi gán Phase.
+ /// Bỏ sót một nhánh = phiếu "Đã duyệt" mà `ApprovedAmount` rỗng ⇒ GĐ3 sinh hợp đồng 0đ.
+ /// Tự kiểm 2 lệnh (chạy cả hai, cùng phải khớp nhau):
+ /// grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
+ /// grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
+ ///
+ /// Vế "CHỈ dòng của phiếu này" (acceptance `AND PlanId = @id`) do QUAN HỆ bảo đảm:
+ /// `plan.Lines` nạp bằng `Include` trên đúng một phiếu, nên không có đường nào chạm dòng
+ /// của phiếu hàng xóm — cố ý KHÔNG viết `db.ContractSigningPlanLines.Where(...)` để không
+ /// tồn tại chỗ cho phép quên vế lọc.
+ ///
+ /// `??=` — GIỮ giá trị đã chốt, KHÔNG đè: số do người có thẩm quyền sửa tay phải thắng.
+ private static void ApplyApprovedValuesOnFinalize(ContractSigningPlan plan)
+ {
+ foreach (var line in plan.Lines)
+ line.ApprovedAmount ??= line.ProposedAmount;
+ }
+
+ // =====================================================================
+ // return / reject — trả lại người soạn hoặc từ chối hẳn
+ // =====================================================================
+ private async Task ReturnOrRejectAsync(
+ ContractSigningPlan plan,
+ Guid actorId,
+ bool isAdmin,
+ string? comment,
+ ContractSigningPlanPhase targetPhase,
+ CancellationToken ct)
+ {
+ var isReturn = targetPhase == ContractSigningPlanPhase.TraLai;
+
+ if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
+ throw new ConflictException(isReturn
+ ? "Chỉ trả lại được phiếu đang ở trạng thái Đã gửi duyệt."
+ : "Chỉ từ chối được phiếu đang ở trạng thái Đã gửi duyệt.");
+ if (plan.ApprovalWorkflowId is not Guid awId)
+ throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
+
+ var steps = await LoadStepsAsync(awId, ct);
+ var (currentIdx, currentStep, currentLevelOrder, levelGroups, _) = ResolvePointer(plan, steps);
+
+ var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
+ ?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
+
+ // Cùng vế quyền với `approve`: chỉ NV của Cấp ĐANG CHỜ (OR-of-N) hoặc Admin.
+ // Rào bằng CHÍNH hàm của approve ⇒ không thể lệch nhau về sau.
+ var actingLevel = ResolveActingLevel(
+ pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
+
+ // 🔴 KHÔNG UPSERT LevelOpinion ở đây: bảng ý kiến là chữ ký DUYỆT
+ // (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync").
+ // Lý do trả lại đã nằm ở Approvals + Changelogs ngay dưới.
+ var fromPhase = plan.Phase;
+ plan.Phase = targetPhase;
+
+ // Trả lại TOÀN PHẦN / từ chối: xoá con-trỏ. Để sót thì phiếu vừa nằm ở tay người soạn
+ // vừa nằm trong hộp thư người duyệt (`ResolveV2InboxIdsAsync` lọc theo con-trỏ).
+ plan.CurrentWorkflowStepIndex = null;
+ plan.CurrentApprovalLevelOrder = null;
+
+ await LogSigningPlanTransitionAsync(
+ plan, fromPhase, targetPhase, actorId, ApprovalDecision.Reject, comment,
+ actingLevel.Id,
+ isReturn
+ ? $"Trả lại người soạn tại Bước {currentIdx + 1} Cấp {currentLevelOrder}"
+ : $"Từ chối tại Bước {currentIdx + 1} Cấp {currentLevelOrder}",
+ ct);
+ }
+
+ // =====================================================================
+ // Helpers dùng chung
+ // =====================================================================
+
+ /// Nạp cây quy trình đã SORT sẵn (Bước theo Order, Cấp theo Order) — mọi nhánh đọc CÙNG
+ /// một thứ tự, nên INDEX con-trỏ luôn hiểu như nhau (khuôn `ContractWorkflowService.cs:228-236`).
+ private async Task> LoadStepsAsync(Guid awId, CancellationToken ct)
+ {
+ 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($"Quy trình duyệt {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 duyệt chưa có Bước nào.");
+ return steps;
+ }
+
+ /// Đọc con-trỏ ĐÔI + kiểm biên. 🔴 `CurrentWorkflowStepIndex` là INDEX vào list đã sort,
+ /// KHÔNG phải `Step.Order` — đọc nhầm thì phiếu nhảy sang Bước của người khác trong im lặng
+ /// (W2 đã chốt semantic INDEX ở `ContractSigningPlanFeatures.cs:256-260`).
+ private static (int Idx,
+ ApprovalWorkflowStep Step,
+ int LevelOrder,
+ List> LevelGroups,
+ int MaxLevelOrder)
+ ResolvePointer(ContractSigningPlan plan, List steps)
+ {
+ var idx = plan.CurrentWorkflowStepIndex ?? 0;
+ if (idx < 0 || idx >= steps.Count)
+ throw new ConflictException(
+ $"CurrentWorkflowStepIndex={idx} không hợp lệ (max={steps.Count - 1}).");
+
+ var step = steps[idx];
+ var levelOrder = plan.CurrentApprovalLevelOrder ?? 1;
+
+ // Cấp = nhóm Level CÙNG Order (khuôn `ContractWorkflowService.cs:246`). Giữ GroupBy —
+ // flatten kiểu Proposal sẽ biến OR-of-N thành AND mà build vẫn sạch.
+ var levelGroups = step.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
+ var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
+ if (levelOrder < 1 || levelOrder > maxLevelOrder)
+ throw new ConflictException(
+ $"CurrentApprovalLevelOrder={levelOrder} không hợp lệ (max={maxLevelOrder}).");
+
+ return (idx, step, levelOrder, levelGroups, maxLevelOrder);
+ }
+
+ /// OR-of-N (khuôn `ContractWorkflowService.cs:255-266`): BẤT KỲ ai trong Cấp đang chờ là
+ /// duyệt được — trả về ĐÚNG row Level của người ký để ý kiến treo dưới đúng tên.
+ /// Admin duyệt thay: miễn vế "có tên trong Cấp", ý kiến rơi vào row đầu Cấp và
+ /// `SignedByUserId` vẫn là Admin ⇒ FE phân biệt được (`SignedByUserId != ApproverUserId`).
+ private static ApprovalWorkflowLevel ResolveActingLevel(
+ IGrouping pendingLevelGroup,
+ ApprovalWorkflowStep step,
+ int stepIdx,
+ int levelOrder,
+ Guid actorId,
+ bool isAdmin)
+ {
+ var own = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId);
+ if (own is not null) return own;
+ if (isAdmin) return pendingLevelGroup.First();
+
+ throw new ForbiddenException(
+ $"Bước {stepIdx + 1} ({step.Name}) — Cấp {levelOrder}: bạn không có trong danh sách NV duyệt của cấp này.");
+ }
+
+ private static List LevelsOf(ApprovalWorkflowStep step, int levelOrder)
+ => step.Levels.Where(l => l.Order == levelOrder).ToList();
+
+ /// UPSERT 1 row / cặp (Plan × Level) — UNIQUE `(PlanId, LevelId)`
+ /// (`ContractSigningPlanLevelOpinionConfiguration.cs:36`). Duyệt lại cùng Cấp (sau khi bị
+ /// trả lại) = CẬP NHẬT dòng cũ; `Add` thẳng sẽ nổ UNIQUE hoặc đẻ dòng thứ hai.
+ private async Task UpsertLevelOpinionAsync(
+ ContractSigningPlan plan,
+ ApprovalWorkflowLevel actingLevel,
+ Guid actorId,
+ string? comment,
+ CancellationToken ct)
+ {
+ var text = string.IsNullOrWhiteSpace(comment) ? NoOpinionPlaceholder : comment;
+ var signedByFullName = await ResolveActorFullNameAsync(actorId, ct);
+ var now = dateTime.UtcNow;
+
+ var existing = await db.ContractSigningPlanLevelOpinions
+ .FirstOrDefaultAsync(o => o.ContractSigningPlanId == plan.Id
+ && o.ApprovalWorkflowLevelId == actingLevel.Id, ct);
+
+ if (existing is null)
+ {
+ db.ContractSigningPlanLevelOpinions.Add(new ContractSigningPlanLevelOpinion
+ {
+ ContractSigningPlanId = plan.Id,
+ ApprovalWorkflowLevelId = actingLevel.Id,
+ Comment = text,
+ SignedAt = now,
+ SignedByUserId = actorId,
+ SignedByFullName = signedByFullName,
+ });
+ }
+ else
+ {
+ // latest-write-wins TRỌN BỘ 4 field: giữ lại chữ/người/giờ cũ = hồ sơ nói sai
+ // sự thật về lần ký mới.
+ existing.Comment = text;
+ existing.SignedAt = now;
+ existing.SignedByUserId = actorId;
+ existing.SignedByFullName = signedByFullName;
+ }
+ }
+
+ /// Method ghi lịch sử KHKK-RIÊNG (spec §②-4). Ghi CẢ HAI bảng trong cùng unit-of-work:
+ /// • `ContractSigningPlanApprovals` — 1 row / transition (ai, Cấp nào, quyết định gì)
+ /// • `ContractSigningPlanChangelogs` — 1 row / transition (nhật ký đọc-được cho người dùng)
+ /// 🔴 KHÔNG gọi `IChangelogService.LogWorkflowTransitionAsync` — xem lý do FK-547 đầu file.
+ /// Kèm notify NGƯỜI SOẠN (mirror `ContractWorkflowService.cs:407-427`); notify approver
+ /// Cấp kế nằm riêng ở `NotifyPendingApproversAsync` (vế THÊM của spec §②-6).
+ private async Task LogSigningPlanTransitionAsync(
+ ContractSigningPlan plan,
+ ContractSigningPlanPhase fromPhase,
+ ContractSigningPlanPhase toPhase,
+ Guid actorId,
+ ApprovalDecision decision,
+ string? comment,
+ Guid? actingLevelId,
+ string summary,
+ CancellationToken ct)
+ {
+ var now = dateTime.UtcNow;
+
+ db.ContractSigningPlanApprovals.Add(new ContractSigningPlanApproval
+ {
+ ContractSigningPlanId = plan.Id,
+ FromPhase = fromPhase,
+ ToPhase = toPhase,
+ Decision = decision,
+ Comment = comment,
+ ApprovalWorkflowLevelId = actingLevelId,
+ ApprovedByUserId = actorId,
+ ApprovedAt = now,
+ });
+
+ db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
+ {
+ ContractSigningPlanId = plan.Id,
+ EntityType = ContractSigningPlanEntityType.Workflow,
+ EntityId = actingLevelId,
+ Action = ChangelogAction.Transition,
+ PhaseAtChange = toPhase,
+ UserId = actorId,
+ UserName = Truncate(await ResolveActorFullNameAsync(actorId, ct), 200),
+ Summary = Truncate(summary, 1000),
+ ContextNote = comment,
+ });
+
+ // Notify NGƯỜI SOẠN — trừ khi chính họ vừa bấm (khỏi tự báo cho mình).
+ if (plan.DrafterUserId != Guid.Empty && plan.DrafterUserId != actorId)
+ {
+ var label = plan.MaKeHoach ?? "Kế hoạch ký kết";
+ var (title, type) = toPhase switch
+ {
+ ContractSigningPlanPhase.DaDuyet =>
+ ($"Kế hoạch ký kết {label} đã được duyệt", NotificationType.ContractPublished),
+ ContractSigningPlanPhase.TuChoi =>
+ ($"Kế hoạch ký kết {label} bị từ chối", NotificationType.ContractRejected),
+ ContractSigningPlanPhase.TraLai =>
+ ($"Kế hoạch ký kết {label} bị trả lại — vui lòng sửa và trình lại",
+ NotificationType.ContractRejected),
+ _ => ($"Kế hoạch ký kết {label} chuyển trạng thái", NotificationType.ContractPhaseTransition),
+ };
+ await notifications.NotifyAsync(
+ plan.DrafterUserId, type, title,
+ description: summary + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
+ href: $"/khkk/{plan.Id}",
+ refId: plan.Id,
+ ct: ct);
+ }
+ }
+
+ /// Vế THÊM của spec §②-6 — báo ĐÍCH DANH NV của Cấp đang chờ (khuôn đã chạy thật ở PE
+ /// `PurchaseEvaluationWorkflowService.cs:1243-1289`), vá lỗ "chỉ người soạn được báo".
+ /// Bỏ qua: chính người vừa bấm, và NGƯỜI SOẠN — họ đã thấy phiếu ở màn "phiếu của tôi"
+ /// và nhận chuông KẾT QUẢ ở trên (bài UAT S86).
+ private async Task NotifyPendingApproversAsync(
+ ContractSigningPlan plan,
+ IEnumerable pendingLevels,
+ Guid actorId,
+ CancellationToken ct)
+ {
+ var approverIds = pendingLevels
+ .Select(l => l.ApproverUserId)
+ .Where(id => id != Guid.Empty && id != actorId && id != plan.DrafterUserId)
+ .Distinct()
+ .ToList();
+ if (approverIds.Count == 0) return;
+
+ await notifications.NotifyManyAsync(
+ approverIds,
+ NotificationType.Generic,
+ $"Kế hoạch ký kết cần bạn duyệt: {plan.MaKeHoach ?? "(chưa có mã)"}",
+ "Có kế hoạch ký kết HĐ đang chờ bạn duyệt.",
+ $"/khkk/{plan.Id}",
+ plan.Id,
+ ct);
+ }
+
+ /// Snapshot tên người ký (`SignedByFullName` là cột NOT NULL 200 ký tự —
+ /// `ContractSigningPlanLevelOpinionConfiguration.cs:24`). Khuôn
+ /// `ContractWorkflowService.cs:433-442`; thêm vế cuối lấy từ token vì service này lấy
+ /// actor qua `ICurrentUser` chứ không qua `UserManager`.
+ private async Task ResolveActorFullNameAsync(Guid actorId, CancellationToken ct)
+ {
+ var user = await db.Users.AsNoTracking()
+ .Where(u => u.Id == actorId)
+ .Select(u => new { u.FullName, u.UserName })
+ .FirstOrDefaultAsync(ct);
+
+ if (user is not null && !string.IsNullOrWhiteSpace(user.FullName)) return Truncate(user.FullName, 200);
+ if (user is not null && !string.IsNullOrWhiteSpace(user.UserName)) return Truncate(user.UserName, 200);
+ if (!string.IsNullOrWhiteSpace(currentUser.FullName)) return Truncate(currentUser.FullName!, 200);
+ if (!string.IsNullOrWhiteSpace(currentUser.Email)) return Truncate(currentUser.Email!, 200);
+ return "(unknown)";
+ }
+
+ /// Cắt AN TOÀN cho các chuỗi service TỰ DỰNG (tên Bước do admin đặt có thể dài).
+ /// Ý kiến của người dùng KHÔNG đi qua đây — chuỗi đó bị chặn ở cổng
+ /// (`MaxCommentLength`) để không bao giờ bị cắt ngầm mất chữ.
+ private static string Truncate(string value, int max)
+ => value.Length <= max ? value : value[..max];
+}
diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs
new file mode 100644
index 0000000..2b16675
--- /dev/null
+++ b/tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs
@@ -0,0 +1,729 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using SolutionErp.Application.Common.Exceptions;
+using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
+using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
+using SolutionErp.Application.ContractSigningPlans.Services;
+using SolutionErp.Domain.ApprovalWorkflowsV2;
+using SolutionErp.Domain.ContractSigningPlans;
+using SolutionErp.Domain.Identity;
+using SolutionErp.Infrastructure.Persistence;
+using SolutionErp.Infrastructure.Persistence.Interceptors;
+using SolutionErp.Infrastructure.Services;
+using SolutionErp.Infrastructure.Tests.Common;
+
+namespace SolutionErp.Infrastructure.Tests.Application;
+
+// [W3 KHKK — S161 2026-07-29] Duyệt 3 trạm phiếu "Kế hoạch ký kết HĐ" (GĐ2) — **TEST-BEFORE**.
+//
+// 🔴 ĐỢT NÀY 5/6 TEST PHẢI ĐỎ. Service mới chỉ là skeleton: mọi nhánh ném
+// `NotImplementedException("W3 đợt-3: ")` (`ContractSigningPlanWorkflowService.cs:108/113/117/121`).
+// Đỏ-vì-CHƯA-LÀM (không phải đỏ-vì-không-biên-dịch-được) chính là bằng chứng duy nhất
+// rằng 6 lưới này có răng — đợt-3 điền logic xong chúng phải TỰ xanh, không được sửa test
+// cho vừa. Test số 4 (Inbox) đi qua query handler W2 đã sống ⇒ **xanh ngay hôm nay**, cố ý
+// không ép đỏ; nó đồng thời là phép thử "seed có hợp lệ không" cho 5 test còn lại (cùng
+// bộ helper: nếu FK/So-sánh seed sai thì test 4 đỏ trước tiên).
+//
+// Tên 6 test PIN theo spec W3 §③-B (`spec-wave-w3-duyet-3-tram-29-07-2026.md:35`) — thiếu 1
+// tên là TRƯỢT acceptance, KHÔNG đổi chữ.
+//
+// ── Đo cái gì (map 1:1 spec §③-B) ──────────────────────────────────────────────
+// T1 OR-of-N trọn 4 vế (a)(b)(c)(d) T2 con-trỏ ĐÔI + reset Cấp
+// T3 finalize `AND PlanId=@id`, 2 write-path T4 Inbox đúng người đúng Cấp
+// T5 UPSERT ý kiến 1 row T6 Trả lại → gửi lại chạy LẠI từ Bước 1 Cấp 1
+//
+// ── 3 quyết định hạ tầng (đều là ĐIỀU KIỆN để phép đo có nghĩa) ────────────────
+//
+// (1) Fixture RIÊNG có `AuditingInterceptor` (mirror `ContractSigningPlanCrudTests.cs:49-82`).
+// Guard trình đợt-3 = `CreatedBy == actor ∨ DeptManager ∨ Admin`
+// (`ContractSigningPlanWorkflowService.cs:58-59`). `TestApplicationDbContext` trần KHÔNG
+// wire interceptor ⇒ `CreatedBy` của MỌI phiếu seed sẽ là null ⇒ vế đầu của guard không
+// bao giờ đúng và `submit` đỏ vì lý do SAI. Seed phiếu bằng context của chính người soạn
+// ⇒ `CreatedBy` == `DrafterUserId` (đợt-3 đọc cột nào cũng ra cùng một người).
+//
+// (2) MỖI transition chạy trên MỘT DbContext MỚI (mô phỏng scoped-per-request của prod).
+// Dùng lại 1 context cho cả seed lẫn lệnh sẽ để EF track sẵn Lines/LevelOpinions —
+// một đường đi KHÔNG có thật ở prod (ở đó context mới chỉ nạp thứ service tự Include).
+//
+// (3) User seed THẲNG `db.Users.Add(...)`, KHÔNG kéo `IdentityFixture`/`UserManager`:
+// service nhận role qua `ICurrentUser.Roles` (lane BE QUYẾT-2 cố ý không inject
+// UserManager), còn `ApprovalWorkflowLevel.ApproverUserId` chỉ cần **row tồn tại**
+// (FK Restrict — `ApprovalWorkflowConfiguration.cs:72-75`), không cần đăng nhập được.
+public class ContractSigningPlanApprovalTests
+{
+ private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
+
+ // ===================================================================
+ // Fixture: SQLite in-memory + AuditingInterceptor, 1 connection dùng chung.
+ // ===================================================================
+ private sealed class KhkkWorkflowFixture : IDisposable
+ {
+ private readonly SqliteConnection _conn;
+ public FixedDateTime Clock { get; } = new(Clock2026);
+
+ public KhkkWorkflowFixture()
+ {
+ _conn = new SqliteConnection("DataSource=:memory:");
+ _conn.Open();
+ using var boot = NewDb(TestCurrentUser.System());
+ boot.Database.EnsureCreated();
+ }
+
+ /// Context MỚI cho mỗi "request". `actor` phải là ĐÚNG người mà lệnh chạy dưới danh
+ /// nghĩa — prod resolve CÙNG một `ICurrentUser` scoped cho cả interceptor lẫn service,
+ /// tách hai ra thì `CreatedBy`/`UpdatedBy` ghi ra người sai *theo kiểu trông như đúng*.
+ public TestApplicationDbContext NewDb(ICurrentUser actor)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_conn)
+ .EnableSensitiveDataLogging()
+ .AddInterceptors(new AuditingInterceptor(actor, Clock))
+ .Options;
+ return new TestApplicationDbContext(options);
+ }
+
+ public void Dispose() => _conn.Dispose();
+ }
+
+ // ===================================================================
+ // Seed helpers
+ // ===================================================================
+
+ private static TestCurrentUser As(User u, params string[] roles)
+ => new(u.Id, u.FullName, u.Email, roles);
+
+ /// Row `AspNetUsers` tối thiểu nhưng ĐỦ THẬT: FK `Level.ApproverUserId` Restrict cần
+ /// row tồn tại, còn `SignedByFullName` (snapshot người ký) cần `FullName` khác rỗng.
+ private static async Task AddUserAsync(KhkkWorkflowFixture f, string fullName)
+ {
+ await using var db = f.NewDb(TestCurrentUser.System());
+ var email = $"u{Guid.NewGuid():N}@test.local";
+ var user = new User
+ {
+ Id = Guid.NewGuid(),
+ FullName = fullName,
+ UserName = email,
+ NormalizedUserName = email.ToUpperInvariant(),
+ Email = email,
+ NormalizedEmail = email.ToUpperInvariant(),
+ EmailConfirmed = true,
+ SecurityStamp = Guid.NewGuid().ToString("N"),
+ ConcurrencyStamp = Guid.NewGuid().ToString("N"),
+ IsActive = true,
+ CreatedAt = Clock2026,
+ };
+ db.Users.Add(user);
+ await db.SaveChangesAsync(CancellationToken.None);
+ return user;
+ }
+
+ // `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1**. Nhiều NV trong cùng một
+ // Cấp = OR-of-N (`ApprovalWorkflow.cs:85-88`).
+ //
+ // `stepOrderStart` / `stepOrderStride`: CỐ Ý cho phép `Step.Order` KHÔNG bắt đầu từ 1 và
+ // không liền nhau. `CurrentWorkflowStepIndex` là **INDEX vào list đã sort theo Order**, KHÔNG
+ // phải giá trị `Order` — mã W2 đã chốt semantic đó (`ContractSigningPlanFeatures.cs:256-260`
+ // `steps[idx]`). T2 dùng Order 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn.
+ private static async Task SeedWorkflowAsync(
+ KhkkWorkflowFixture f,
+ string code,
+ Guid[][][] steps,
+ int stepOrderStart = 1,
+ int stepOrderStride = 1)
+ {
+ await using var db = f.NewDb(TestCurrentUser.System());
+
+ var wf = new ApprovalWorkflow
+ {
+ Id = Guid.NewGuid(),
+ Code = code,
+ Version = 1,
+ Name = "Quy trình duyệt Kế hoạch ký kết",
+ // Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
+ // của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
+ ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
+ IsActive = true,
+ IsUserSelectable = true,
+ };
+ db.ApprovalWorkflows.Add(wf);
+
+ for (var s = 0; s < steps.Length; s++)
+ {
+ var step = new ApprovalWorkflowStep
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowId = wf.Id,
+ Order = stepOrderStart + (s * stepOrderStride),
+ Name = $"Bước {s + 1}",
+ DepartmentId = null, // null = bỏ qua FK phòng ban (khuôn Pattern 11)
+ };
+ db.ApprovalWorkflowSteps.Add(step);
+
+ for (var l = 0; l < steps[s].Length; l++)
+ {
+ foreach (var approverId in steps[s][l])
+ {
+ db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
+ {
+ Id = Guid.NewGuid(),
+ ApprovalWorkflowStepId = step.Id,
+ Order = l + 1, // Cấp 1-based
+ Name = $"Cấp {l + 1}",
+ ApproverUserId = approverId,
+ // 5 cờ Allow* GIỮ MẶC ĐỊNH: W3 default dry-run = đường THƯỜNG duy nhất,
+ // KHÔNG bật kết-thúc-sớm (spec §②-3b — chủ dự án phán "PHÁ VỠ" @S155).
+ });
+ }
+ }
+ }
+
+ await db.SaveChangesAsync(CancellationToken.None);
+ return wf.Id;
+ }
+
+ private sealed record LineSeed(decimal PeReference, decimal Proposed, decimal? Approved = null);
+
+ private static async Task SeedPlanAsync(
+ KhkkWorkflowFixture f,
+ TestCurrentUser drafter,
+ Guid workflowId,
+ string maKeHoach,
+ ContractSigningPlanPhase phase,
+ int? stepIndex,
+ int? levelOrder,
+ LineSeed[] lines)
+ {
+ // Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
+ await using var db = f.NewDb(drafter);
+
+ var plan = new ContractSigningPlan
+ {
+ Id = Guid.NewGuid(),
+ MaKeHoach = maKeHoach,
+ // 4 cột dưới là loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`)
+ // ⇒ W3 không phải seed PE/Project/Department như W2.
+ PurchaseEvaluationId = Guid.NewGuid(),
+ ProjectId = Guid.NewGuid(),
+ DepartmentId = Guid.NewGuid(),
+ DrafterUserId = drafter.UserId!.Value,
+ Phase = phase,
+ ApprovalWorkflowId = workflowId,
+ CurrentWorkflowStepIndex = stepIndex,
+ CurrentApprovalLevelOrder = levelOrder,
+ };
+
+ foreach (var l in lines)
+ {
+ plan.Lines.Add(new ContractSigningPlanLine
+ {
+ Id = Guid.NewGuid(),
+ ContractSigningPlanId = plan.Id,
+ SupplierId = Guid.NewGuid(), // loose-Guid (`ContractSigningPlanLine.cs:15`)
+ PeReferenceAmount = l.PeReference,
+ ProposedAmount = l.Proposed,
+ ApprovedAmount = l.Approved,
+ });
+ }
+
+ db.ContractSigningPlans.Add(plan);
+ await db.SaveChangesAsync(CancellationToken.None);
+ return plan.Id;
+ }
+
+ // ===================================================================
+ // Act / Read helpers — gọi service THẬT, KHÔNG mock.
+ // ===================================================================
+
+ private static async Task ActAsync(
+ KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
+ {
+ await using var db = f.NewDb(actor);
+ var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
+ return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
+ }
+
+ private static async Task ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
+ {
+ await using var db = f.NewDb(TestCurrentUser.System());
+ return await db.ContractSigningPlans.AsNoTracking()
+ .Include(p => p.Lines)
+ .SingleAsync(p => p.Id == planId, CancellationToken.None);
+ }
+
+ private static async Task> ReadOpinionsAsync(
+ KhkkWorkflowFixture f, Guid planId)
+ {
+ await using var db = f.NewDb(TestCurrentUser.System());
+ // Lọc `WHERE PlanId = @id` ĐÚNG như acceptance §③-B yêu cầu cho Lines: nếu đợt-3
+ // quên vế này ở bất kỳ đâu thì phiếu hàng xóm sẽ dính, và các assert "phiếu chứng
+ // nhân bất động" bên dưới bắt được.
+ return await db.ContractSigningPlanLevelOpinions.AsNoTracking()
+ .Where(o => o.ContractSigningPlanId == planId)
+ .ToListAsync(CancellationToken.None);
+ }
+
+ private static async Task LevelIdOfAsync(KhkkWorkflowFixture f, Guid approverUserId)
+ {
+ await using var db = f.NewDb(TestCurrentUser.System());
+ return await db.ApprovalWorkflowLevels.AsNoTracking()
+ .Where(l => l.ApproverUserId == approverUserId)
+ .Select(l => l.Id)
+ .SingleAsync(CancellationToken.None);
+ }
+
+ private static async Task> InboxAsync(
+ KhkkWorkflowFixture f, TestCurrentUser actor)
+ {
+ await using var db = f.NewDb(actor);
+ var handler = new GetMyContractSigningPlanInboxQueryHandler(db, actor);
+ return await handler.Handle(new GetMyContractSigningPlanInboxQuery(), CancellationToken.None);
+ }
+
+ /// Con-trỏ ĐÔI + phase là MỘT bộ ba — kiểm cả ba cùng lúc để không có chuyện
+ /// "phase đúng mà con-trỏ trôi" lọt lưới.
+ private static void ShouldBeAt(
+ ContractSigningPlan plan, ContractSigningPlanPhase phase, int? stepIndex, int? levelOrder, string because)
+ {
+ plan.Phase.Should().Be(phase, because);
+ plan.CurrentWorkflowStepIndex.Should().Be(stepIndex, because);
+ plan.CurrentApprovalLevelOrder.Should().Be(levelOrder, because);
+ }
+
+ /// 3 field response (FE đọc để vẽ lại panel ngay, không refetch) phải KHỚP DB.
+ /// Lệch = FE hiển thị một trạng thái không tồn tại cho tới lần F5 kế.
+ private static void ResultShouldMirror(ContractSigningPlanTransitionResult r, ContractSigningPlan plan)
+ {
+ r.Phase.Should().Be((int)plan.Phase, "response `phase` là int (hợp-đồng literal) và phải bằng phase đã ghi DB");
+ r.CurrentWorkflowStepIndex.Should().Be(plan.CurrentWorkflowStepIndex, "response phải soi đúng con-trỏ Bước trong DB");
+ r.CurrentApprovalLevelOrder.Should().Be(plan.CurrentApprovalLevelOrder, "response phải soi đúng con-trỏ Cấp trong DB");
+ }
+
+ // ===================================================================
+ // (T1) OR-of-N — acceptance #1 TRỌN 4 VẾ (a)(b)(c)(d), spec §③-B `:28`.
+ //
+ // Đây là lưới DUY NHẤT bắt được lỗi chết người của wave này: copy nhầm khuôn
+ // `ProposalFeatures` (flatten `SelectMany` các Level) làm OR-of-N biến thành AND **ÂM
+ // THẦM** — build sạch, 574 test cũ vẫn xanh, chỉ vỡ khi phiếu thật treo giữa chừng vì
+ // chờ đủ chữ ký. Vì thế:
+ // (a) 2 phiếu SONG SINH cùng quy trình: phiếu-1 do A duyệt, phiếu-2 do B duyệt;
+ // (d) mỗi phiếu tiến với ĐÚNG MỘT chữ ký — nếu đòi cả hai thì con-trỏ đứng im ở Cấp 1;
+ // (c) mỗi phiếu ghi ĐÚNG 1 dòng ý kiến, và dòng đó trỏ Level của CHÍNH người ký;
+ // (b) ca ÂM 2 lớp: người sai Cấp (C, Cấp 2 chưa tới lượt) và người NGOÀI quy trình (D).
+ // ===================================================================
+ [Fact]
+ public async Task ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures()
+ {
+ using var f = new KhkkWorkflowFixture();
+ var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
+ var a = await AddUserAsync(f, "NV Cung ứng A");
+ var b = await AddUserAsync(f, "NV Cung ứng B");
+ var c = await AddUserAsync(f, "Trưởng phòng C");
+ var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
+
+ // 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
+ var wf = await SeedWorkflowAsync(f, "QT-KHKK-ORN", steps:
+ [
+ [[a.Id, b.Id], [c.Id]],
+ ]);
+
+ LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
+ var p1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
+ var p2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
+ var p3 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/003", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
+
+ // Sanity TRƯỚC khi gọi service: seed đặt đúng lượt Cấp 1 Bước 1. Thiếu chốt này thì
+ // một seed hỏng (con-trỏ null) vẫn cho ra assert xanh ở thế giới sai.
+ ShouldBeAt(await ReadPlanAsync(f, p1), ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ "seed phải đặt phiếu ĐÚNG lượt Cấp 1 Bước 1 — sai seed thì test đo chính nó");
+
+ // ---- (a) + (d): A duyệt phiếu-1, B duyệt phiếu-2. Cả hai đều TIẾN.
+ await ActAsync(f, As(a), p1, "approve", "A đồng ý đơn giá");
+ var after1 = await ReadPlanAsync(f, p1);
+ ShouldBeAt(after1, ContractSigningPlanPhase.ChoDuyet, 0, 2,
+ "MỘT người trong Cấp 1 duyệt là Cấp 1 xong ⇒ con-trỏ sang Cấp 2 CÙNG Bước; "
+ + "nếu quy trình đòi đủ cả A lẫn B (AND) thì con-trỏ còn đứng ở Cấp 1");
+
+ await ActAsync(f, As(b), p2, "approve", "B đồng ý đơn giá");
+ ShouldBeAt(await ReadPlanAsync(f, p2), ContractSigningPlanPhase.ChoDuyet, 0, 2,
+ "phiếu song sinh: người CÒN LẠI của cùng Cấp duyệt cũng phải tiến y hệt — "
+ + "chỉ 1 trong 2 người tiến được là dấu hiệu match cứng theo NV đầu danh sách");
+
+ // Chưa phải cấp cuối ⇒ TUYỆT ĐỐI chưa được chốt giá.
+ after1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
+ "giá chốt CHỈ sinh tại cấp cuối cùng — chốt sớm là ký thay người chưa duyệt");
+
+ // ---- (c) ĐÚNG 1 dòng ý kiến / phiếu, trỏ Level của CHÍNH người ký.
+ var op1 = await ReadOpinionsAsync(f, p1);
+ op1.Should().ContainSingle("mỗi lượt duyệt ghi đúng 1 dòng ý kiến cho Cấp đó (UNIQUE PlanId × LevelId)");
+ op1[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, a.Id),
+ "ý kiến phải gắn vào row Level CỦA NGƯỜI KÝ; gắn nhầm row của B thì màn Ý kiến cấp duyệt "
+ + "hiện chữ ký treo dưới tên người chưa hề bấm nút");
+ op1[0].SignedByUserId.Should().Be(a.Id);
+
+ var op2 = await ReadOpinionsAsync(f, p2);
+ op2.Should().ContainSingle();
+ op2[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, b.Id),
+ "hai phiếu song sinh ký bởi hai người khác nhau ⇒ hai LevelId khác nhau");
+ op2[0].SignedByUserId.Should().Be(b.Id);
+
+ // ---- (b) ca ÂM lớp 1: C có chân trong quy trình nhưng Ở CẤP KHÁC, chưa tới lượt.
+ Func actWrongLevel = async () => await ActAsync(f, As(c), p3, "approve", "duyệt vượt lượt");
+ await actWrongLevel.Should().ThrowAsync(
+ "NV Cấp 2 KHÔNG được ký thay Cấp 1 — đây là chỗ mất trắng vòng kiểm soát nếu lọt");
+
+ // ---- (b) ca ÂM lớp 2: người ngoài quy trình.
+ Func actOutsider = async () => await ActAsync(f, As(outsider), p3, "approve", "tôi ký hộ");
+ await actOutsider.Should().ThrowAsync(
+ "người không có trong Cấp nào của quy trình thì không phải approver");
+
+ // Ném rồi mà vẫn ghi còn tệ hơn không rào: phiếu p3 phải NGUYÊN VẸN.
+ ShouldBeAt(await ReadPlanAsync(f, p3), ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ "hai lần bị chặn ⇒ con-trỏ p3 bất động");
+ (await ReadOpinionsAsync(f, p3)).Should().BeEmpty("bị chặn thì KHÔNG được để lại chữ ký");
+ }
+
+ // ===================================================================
+ // (T2) Con-trỏ ĐÔI — acceptance #2 (spec §③-B `:29`).
+ //
+ // 3 Bước × 2 Cấp, chạy trọn 6 lượt duyệt. Hai điểm dễ hỏng nhất:
+ // • hết Cấp trong Bước ⇒ sang Bước kế **và Cấp phải RESET về 1** (quên reset thì Bước 2
+ // bắt đầu từ Cấp 2, Cấp 1 của Bước 2 bị bỏ qua vĩnh viễn — mất một chữ ký thật);
+ // • `CurrentWorkflowStepIndex` là **INDEX** vào list đã sort, KHÔNG phải `Step.Order`.
+ // Seed cố ý đặt Order = 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn;
+ // mã W2 đã đọc theo INDEX (`ContractSigningPlanFeatures.cs:256-260`) nên lệch semantic
+ // ở đây = inbox mù ngay lập tức.
+ // ===================================================================
+ [Fact]
+ public async Task ApproveV2_NewStep_ResetsLevelOrderToOne()
+ {
+ using var f = new KhkkWorkflowFixture();
+ var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
+ var s1c1 = await AddUserAsync(f, "PMH cấp 1");
+ var s1c2 = await AddUserAsync(f, "PMH cấp 2");
+ var s2c1 = await AddUserAsync(f, "CCM cấp 1");
+ var s2c2 = await AddUserAsync(f, "CCM cấp 2");
+ var s3c1 = await AddUserAsync(f, "Thư ký CEO");
+ var s3c2 = await AddUserAsync(f, "CEO");
+
+ var wf = await SeedWorkflowAsync(f, "QT-KHKK-3TRAM", steps:
+ [
+ [[s1c1.Id], [s1c2.Id]], // Bước 1 (PMH)
+ [[s2c1.Id], [s2c2.Id]], // Bước 2 (CCM)
+ [[s3c1.Id], [s3c2.Id]], // Bước 3 (CEO)
+ ],
+ stepOrderStart: 5, stepOrderStride: 10); // Order 5/15/25 ≠ index 0/1/2
+
+ var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
+ ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 900m, Proposed: 800m)]);
+
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1, "sanity seed");
+
+ // 1) Trong cùng Bước 1: Cấp 1 → Cấp 2.
+ var r1 = await ActAsync(f, As(s1c1), planId, "approve", "Bước 1 Cấp 1");
+ var p1 = await ReadPlanAsync(f, planId);
+ ShouldBeAt(p1, ContractSigningPlanPhase.ChoDuyet, 0, 2, "còn Cấp trong Bước ⇒ chỉ tăng Cấp, Bước giữ nguyên");
+ ResultShouldMirror(r1, p1);
+
+ // 2) Hết Cấp Bước 1 ⇒ Bước 2 (index 1) + Cấp RESET về 1.
+ var r2 = await ActAsync(f, As(s1c2), planId, "approve", "Bước 1 Cấp 2");
+ var p2 = await ReadPlanAsync(f, planId);
+ ShouldBeAt(p2, ContractSigningPlanPhase.ChoDuyet, 1, 1,
+ "sang Bước MỚI thì Cấp phải reset về 1; giữ nguyên 2 là Cấp 1 của Bước 2 không bao giờ được ký");
+ ResultShouldMirror(r2, p2);
+
+ // 3) Bước 2: Cấp 1 → Cấp 2.
+ await ActAsync(f, As(s2c1), planId, "approve", "Bước 2 Cấp 1");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 2, "trong Bước 2");
+
+ // 4) Reset LẦN HAI — một lần đúng có thể do hardcode, hai lần thì không.
+ await ActAsync(f, As(s2c2), planId, "approve", "Bước 2 Cấp 2");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 2, 1,
+ "reset Cấp lần 2 (Bước 3) — chứng đây là quy tắc, không phải trùng hợp ở Bước 2");
+
+ // 5) Bước 3 Cấp 1 → Cấp 2: chưa xong, TUYỆT ĐỐI chưa DaDuyet.
+ await ActAsync(f, As(s3c1), planId, "approve", "Bước 3 Cấp 1");
+ var p5 = await ReadPlanAsync(f, planId);
+ ShouldBeAt(p5, ContractSigningPlanPhase.ChoDuyet, 2, 2,
+ "còn Cấp cuối chưa ký ⇒ chưa được kết thúc (W3 đường THƯỜNG: CEO ký thật, không kết-thúc-sớm)");
+
+ // 6) Cấp cuối Bước cuối ⇒ terminal, HAI con-trỏ về null.
+ var r6 = await ActAsync(f, As(s3c2), planId, "approve", "CEO duyệt");
+ var p6 = await ReadPlanAsync(f, planId);
+ ShouldBeAt(p6, ContractSigningPlanPhase.DaDuyet, null, null,
+ "hết Bước cuối ⇒ DaDuyet và cả 2 con-trỏ = null (`ContractSigningPlan.cs:34` 'Null khi terminal'); "
+ + "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của ai đó");
+ ResultShouldMirror(r6, p6);
+ }
+
+ // ===================================================================
+ // (T3) Finalize chốt giá — acceptance #3 (spec §③-B `:30`).
+ //
+ // Hai thứ được đo cùng lúc:
+ // • `AND PlanId = @id`: phiếu CHỨNG NHÂN (planB, cùng quy trình, cùng đang chờ) phải
+ // BẤT ĐỘNG. Bug thật của lớp này là `Where(l => l.ApprovedAmount == null)` quên vế
+ // PlanId — nó chốt giá cho mọi phiếu đang mở trong hệ thống, im lặng và không hoàn lại được.
+ // • **2 WRITE-PATH** (spec F-B1): duyệt-cấp-cuối THƯỜNG *và* Admin duyệt-thay (bypass
+ // danh sách approver — khuôn nguồn copy `ContractWorkflowService.cs:255` `if (!isAdmin…)`).
+ // Helper `ApplyApprovedValuesOnFinalize` phải được gọi ở CẢ HAI; gọi một chỗ thì
+ // phiếu do Admin chốt sẽ ở DaDuyet mà không có giá — GĐ3 sinh HĐ 0đ.
+ //
+ // Bẫy nguồn số: `ProposedAmount` ≠ `PeReferenceAmount` ở mọi dòng ⇒ lấy nhầm cột là lộ
+ // ngay (`ApprovedAmount ??= ProposedAmount` — spec §②-3a). Dòng thứ 3 đã có giá sẵn ⇒
+ // đo đúng ngữ nghĩa `??=` (giữ, KHÔNG đè).
+ // ===================================================================
+ [Fact]
+ public async Task Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan()
+ {
+ using var f = new KhkkWorkflowFixture();
+ var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
+ var approver = await AddUserAsync(f, "CEO ký duyệt");
+ var adminUser = await AddUserAsync(f, "Quản trị hệ thống");
+ var admin = As(adminUser, AppRoles.Admin); // KHÔNG có chân trong quy trình
+
+ // 1 Bước × 1 Cấp ⇒ một lượt duyệt là terminal.
+ var wf = await SeedWorkflowAsync(f, "QT-KHKK-1CAP", steps: [[[approver.Id]]]);
+
+ var planA = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020",
+ ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ [
+ new(PeReference: 999m, Proposed: 111m),
+ new(PeReference: 888m, Proposed: 222m),
+ new(PeReference: 777m, Proposed: 333m, Approved: 300m), // đã có giá chốt sẵn
+ ]);
+ var planB = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021",
+ ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ [new(PeReference: 555m, Proposed: 55m), new(PeReference: 444m, Proposed: 44m)]);
+ var planC = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022",
+ ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ [new(PeReference: 222m, Proposed: 22m), new(PeReference: 111m, Proposed: 11m)]);
+
+ // ---- WRITE-PATH 1: approver Cấp cuối duyệt.
+ await ActAsync(f, As(approver), planA, "approve", "Chốt giá theo đề xuất BCH");
+
+ var a = await ReadPlanAsync(f, planA);
+ a.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Cấp cuối Bước cuối ⇒ terminal");
+ a.Lines.Should().NotContain(l => l.ApprovedAmount == null,
+ "acceptance: `Lines WHERE PlanId=@id AND ApprovedAmount IS NULL` = 0");
+
+ var byProposed = a.Lines.ToDictionary(l => l.ProposedAmount);
+ byProposed[111m].ApprovedAmount.Should().Be(111m,
+ "giá chốt lấy từ `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999 — "
+ + "lấy nhầm cột thì phiếu chốt bằng số tham chiếu cũ của phiếu Duyệt NCC");
+ byProposed[222m].ApprovedAmount.Should().Be(222m);
+ byProposed[333m].ApprovedAmount.Should().Be(300m,
+ "`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333 — đè là xoá số người có thẩm quyền đã sửa");
+
+ // ---- Chứng nhân: phiếu hàng xóm BẤT ĐỘNG (vế `AND PlanId=@id`).
+ var bAfter1 = await ReadPlanAsync(f, planB);
+ bAfter1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
+ "phiếu KHÁC tuyệt đối không được dính giá chốt — thiếu vế PlanId là chốt giá cả hệ thống");
+ bAfter1.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet, "và cũng không được nhảy phase");
+
+ // ---- WRITE-PATH 2: Admin duyệt thay (không nằm trong Cấp nào) — spec F-B1.
+ admin.Roles.Should().Contain(AppRoles.Admin, "actor của write-path 2 phải THỰC SỰ mang role Admin");
+ await ActAsync(f, admin, planC, "approve", "Admin duyệt thay CEO");
+
+ var c = await ReadPlanAsync(f, planC);
+ c.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Admin bypass danh sách approver (khuôn `ContractWorkflowService.cs:255`)");
+ c.Lines.Should().NotContain(l => l.ApprovedAmount == null,
+ "đường Admin cũng PHẢI đi qua choke-point chốt giá — đây là write-path thứ 2 mà "
+ + "grep literal không nhìn thấy (spec F-B1)");
+ c.Lines.Single(l => l.ProposedAmount == 22m).ApprovedAmount.Should().Be(22m);
+
+ // Chứng nhân lần 2 — sau CẢ HAI write-path vẫn phải sạch.
+ (await ReadPlanAsync(f, planB)).Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
+ "qua 2 đường finalize, phiếu chứng nhân vẫn không được đụng tới");
+ }
+
+ // ===================================================================
+ // (T4) Inbox — acceptance #4 (spec §③-B `:31`).
+ //
+ // 🔴 Test DUY NHẤT trong 6 cái KHÔNG chạm service ⇒ **XANH ngay đợt này** (query handler
+ // W2 đã wire: `ContractSigningPlanFeatures.cs:725-752`). Cố ý không ép đỏ. Nó gánh thêm
+ // vai trò lưới an toàn cho 5 test kia: dùng CHUNG bộ seed helper, nên nếu seed sai FK
+ // hay sai con-trỏ thì test này đỏ TRƯỚC và ta biết ngay là lỗi test chứ không phải lỗi sản phẩm.
+ //
+ // Ba lớp phủ: đúng-Cấp (A, B thấy — OR-of-N) · khác-Cấp (C KHÔNG thấy phiếu Cấp 1) ·
+ // ngoài-quy-trình + người-soạn (rỗng). Cộng phiếu nháp: KHÔNG vào hộp thư của ai.
+ // ===================================================================
+ [Fact]
+ public async Task Inbox_OnlyPendingLevelApprover_SeesPlan()
+ {
+ using var f = new KhkkWorkflowFixture();
+ var drafterUser = await AddUserAsync(f, "BCH lập kế hoạch");
+ var drafter = As(drafterUser);
+ var a = await AddUserAsync(f, "NV Cung ứng A");
+ var b = await AddUserAsync(f, "NV Cung ứng B");
+ var c = await AddUserAsync(f, "Trưởng phòng C");
+ var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
+
+ var wf = await SeedWorkflowAsync(f, "QT-KHKK-INBOX", steps:
+ [
+ [[a.Id, b.Id], [c.Id]],
+ ]);
+
+ LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
+ // Tên biến = Cấp mà phiếu ĐANG CHỜ: `pendingLevel1` đứng ở Cấp 1 (việc của A/B),
+ // `pendingLevel2` ở Cấp 2 (việc của C) — có ĐỦ CẢ HAI thì ca ÂM "cấp khác không thấy"
+ // mới đo được cả hai chiều.
+ // 🔧 [W3 đợt-3 sửa SEED] Dòng đầu trước đó là `0, 2` kèm chú "FAULT-INJECT tạm" — vết
+ // của lượt tự-thử-lưới còn sót, làm CẢ HAI phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B rỗng và
+ // C thấy 2 phiếu. Đỏ vì SEED, KHÔNG phải vì sản phẩm (query handler W2 không đổi một
+ // dòng nào ở đợt này). Mọi assert giữ NGUYÊN CHỮ.
+ var pendingLevel1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
+ ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
+ var pendingLevel2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
+ ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine);
+ var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
+ ContractSigningPlanPhase.DangSoanThao, null, null, oneLine);
+
+ // A và B cùng Cấp 1 (OR-of-N) ⇒ CẢ HAI cùng thấy đúng một phiếu.
+ (await InboxAsync(f, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
+ "approver của Cấp ĐANG CHỜ thấy phiếu; phiếu đang ở Cấp 2 thì không phải việc của A");
+ (await InboxAsync(f, As(b))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
+ "OR-of-N: người thứ hai cùng Cấp cũng phải thấy, nếu không thì cả Cấp phụ thuộc 1 người");
+
+ // C ở Cấp 2 — thấy ĐÚNG phiếu đang chờ Cấp 2, KHÔNG thấy phiếu đang ở Cấp 1 (ca ÂM).
+ (await InboxAsync(f, As(c))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel2 },
+ "cấp khác KHÔNG được thấy phiếu chưa tới lượt mình — thấy = duyệt vượt lượt ngay từ hộp thư");
+
+ (await InboxAsync(f, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc gì ở đây");
+ (await InboxAsync(f, drafter)).Should().BeEmpty(
+ "người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt', không phải 'phiếu của tôi'");
+
+ // Phiếu nháp không xuất hiện ở bất kỳ hộp thư nào (con-trỏ null).
+ var allInboxIds = (await InboxAsync(f, As(a))).Concat(await InboxAsync(f, As(b)))
+ .Concat(await InboxAsync(f, As(c))).Select(i => i.Id).ToList();
+ allInboxIds.Should().NotContain(draft, "phiếu Nháp chưa trình thì chưa chờ ai duyệt");
+ }
+
+ // ===================================================================
+ // (T5) UPSERT ý kiến — acceptance #5 (spec §③-B `:32`).
+ //
+ // "Duyệt 2 lần CÙNG MỘT CẤP" chỉ xảy ra theo đúng một đường ở W3 đường-THƯỜNG: ký →
+ // bị trả lại → gửi lại → ký lại. Dùng CHÍNH một người cho cả hai lượt để `LevelId` không
+ // đổi, nhờ vậy phép đo "1 row" là rõ nghĩa dưới mọi cách đọc.
+ //
+ // Nếu đợt-3 viết `Add(...)` thẳng thay vì UPSERT: hoặc nổ UNIQUE (PlanId, LevelId), hoặc
+ // đẻ dòng thứ hai — cả hai đều bị bắt ở đây. Nếu ghi đúng 1 row nhưng quên cập nhật
+ // `SignedByUserId`/`SignedByFullName`/`Comment` thì đó là chữ ký cũ đứng tên cho lần duyệt mới.
+ // ===================================================================
+ [Fact]
+ public async Task ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow()
+ {
+ using var f = new KhkkWorkflowFixture();
+ var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
+ var a = await AddUserAsync(f, "NV Cung ứng A");
+ var b = await AddUserAsync(f, "NV Cung ứng B");
+ var c = await AddUserAsync(f, "Trưởng phòng C");
+
+ var wf = await SeedWorkflowAsync(f, "QT-KHKK-UPSERT", steps:
+ [
+ [[a.Id, b.Id], [c.Id]],
+ ]);
+ var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
+ ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 500m, Proposed: 480m)]);
+ var levelOfA = await LevelIdOfAsync(f, a.Id);
+
+ // ---- Lượt 1: A ký, KHÔNG nhập ý kiến.
+ await ActAsync(f, As(a), planId, "approve", comment: null);
+
+ var first = await ReadOpinionsAsync(f, planId);
+ first.Should().ContainSingle("lượt ký đầu tiên tạo 1 dòng");
+ first[0].ApprovalWorkflowLevelId.Should().Be(levelOfA);
+ first[0].SignedAt.Should().Be(Clock2026,
+ "thời điểm ký lấy từ `IDateTime` được bơm vào service — dùng `DateTime.UtcNow` trực tiếp "
+ + "là không test được và lệch giờ máy chủ");
+ first[0].Comment.Should().NotBeNullOrWhiteSpace(
+ "ý kiến rỗng phải thành chữ thay thế (`ContractSigningPlanLevelOpinion.cs:26` 'hoặc placeholder', "
+ + "khuôn nguồn copy `ContractWorkflowService.cs:295-297`) — để trống thì màn Ý kiến hiện ô câm, "
+ + "không phân biệt được 'đã ký không ý kiến' với 'chưa ký'");
+
+ // ---- Trả lại rồi gửi lại ⇒ con-trỏ quay về Cấp 1, tới lượt A LẦN NỮA.
+ await ActAsync(f, As(c), planId, "return", "Thiếu bảng chiết tính — trả lại BCH");
+ (await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
+
+ await ActAsync(f, drafter, planId, "submit", "Đã bổ sung chiết tính");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ "gửi lại thì chạy LẠI từ Bước 1 Cấp 1 ⇒ đúng lượt A");
+
+ // ---- Lượt 2: CHÍNH A ký lại CÙNG Cấp đó, lần này có ý kiến.
+ await ActAsync(f, As(a), planId, "approve", "Đã rà lại đơn giá — thống nhất ký");
+
+ var second = await ReadOpinionsAsync(f, planId);
+ second.Should().ContainSingle(
+ "duyệt lần 2 cùng Cấp là CẬP NHẬT dòng cũ (UNIQUE PlanId × LevelId), KHÔNG đẻ dòng thứ 2");
+ second[0].ApprovalWorkflowLevelId.Should().Be(levelOfA, "vẫn là ý kiến của đúng Cấp đó");
+ second[0].Comment.Should().Be("Đã rà lại đơn giá — thống nhất ký",
+ "nội dung phải là ý kiến MỚI NHẤT (latest-write-wins) — giữ chữ cũ là hồ sơ nói sai sự thật");
+ second[0].SignedByUserId.Should().Be(a.Id, "người ký THẬT của lượt sau");
+ second[0].SignedByFullName.Should().Be(a.FullName,
+ "tên là snapshot chụp lúc ký (`ContractSigningPlanLevelOpinion.cs:29`) — bỏ trống thì bản in "
+ + "tờ FO-002.01 ra ô tên rỗng");
+ }
+
+ // ===================================================================
+ // (T6) Trả lại → gửi lại — acceptance #6 (spec §③-B `:33`).
+ //
+ // Mấu chốt: phiếu bị trả lại khi ĐÃ ĐI QUA HẾT Bước 1 và đang đứng ở Bước 2. Nếu đợt-3
+ // hiểu "gửi lại" là *nối tiếp chỗ đang dở* thì con-trỏ sẽ về (Bước 2, Cấp 1) — trông rất
+ // hợp lý, và hậu quả là bản sửa của BCH KHÔNG được Bước 1 xem lại lần nào. Vì thế phải
+ // trả lại đầu quy trình: (Bước 1, Cấp 1).
+ //
+ // Lượt duyệt số 6 (A ký lại được) là vế chứng minh vòng chạy lại là THẬT, không phải chỉ
+ // đặt lại hai con số rồi khoá.
+ // ===================================================================
+ [Fact]
+ public async Task SendBack_Resubmit_RestartsFromStepOneLevelOne()
+ {
+ using var f = new KhkkWorkflowFixture();
+ var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
+ var a = await AddUserAsync(f, "PMH cấp 1");
+ var b = await AddUserAsync(f, "PMH cấp 2");
+ var c = await AddUserAsync(f, "CCM Bước 2");
+
+ var wf = await SeedWorkflowAsync(f, "QT-KHKK-TRALAI", steps:
+ [
+ [[a.Id], [b.Id]], // Bước 1: 2 Cấp
+ [[c.Id]], // Bước 2: 1 Cấp
+ ]);
+
+ // Phiếu bắt đầu ở NHÁP, con-trỏ trống — đúng trạng thái sau khi W2 lập phiếu.
+ var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/050",
+ ContractSigningPlanPhase.DangSoanThao, null, null, [new(PeReference: 700m, Proposed: 650m)]);
+
+ // 1) Trình lần đầu: đặt con-trỏ về (Bước 1, Cấp 1).
+ var rSubmit = await ActAsync(f, drafter, planId, "submit", "Trình duyệt kế hoạch ký kết");
+ var afterSubmit = await ReadPlanAsync(f, planId);
+ ShouldBeAt(afterSubmit, ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ "trình = mở quy trình từ Bước 1 Cấp 1");
+ ResultShouldMirror(rSubmit, afterSubmit);
+
+ // 2-3) Đi hết Bước 1 để phiếu ĐANG ĐỨNG Ở BƯỚC 2 lúc bị trả lại.
+ await ActAsync(f, As(a), planId, "approve", "Bước 1 Cấp 1 duyệt");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2, "sang Cấp 2");
+ await ActAsync(f, As(b), planId, "approve", "Bước 1 Cấp 2 duyệt");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 1,
+ "hết Bước 1 ⇒ đang chờ Bước 2 Cấp 1 (đây là mốc để câu hỏi 'gửi lại về đâu' có nghĩa)");
+
+ // 4) Bước 2 trả lại.
+ var rReturn = await ActAsync(f, As(c), planId, "return", "Sai khối lượng hạng mục 3 — trả lại");
+ var afterReturn = await ReadPlanAsync(f, planId);
+ ShouldBeAt(afterReturn, ContractSigningPlanPhase.TraLai, null, null,
+ "Trả lại toàn phần: phase TraLai + XOÁ con-trỏ (`ContractSigningPlanWorkflowService.cs:116`) — "
+ + "để sót con-trỏ thì phiếu vừa nằm ở tay người soạn vừa nằm trong hộp thư người duyệt");
+ ResultShouldMirror(rReturn, afterReturn);
+ afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
+ "bị trả lại thì không có giá nào được chốt");
+
+ // 5) 🔴 Gửi lại: PHẢI về đầu quy trình, KHÔNG nối tiếp Bước 2.
+ await ActAsync(f, drafter, planId, "submit", "Đã sửa khối lượng, trình lại");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
+ "gửi lại chạy LẠI TỪ ĐẦU (Bước 1 Cấp 1); về (1,1) tức Bước 1 không hề xem bản sửa");
+
+ // 6) Vòng mới là thật: A có lượt lại và duyệt được.
+ await ActAsync(f, As(a), planId, "approve", "Xem lại bản sửa — đồng ý");
+ ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
+ "A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
+ }
+}