[CLAUDE] App+Infra+Api+FE-User+FE-Admin+Tests: W3 KHKK — duyet 3 tram OR-of-N + finalize chot gia (test-before 6 RED->GREEN)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s
- ContractSigningPlanWorkflowService: copy khuon ApproveV2Async (con-tro doi StepIndex+LevelOrder, GroupBy Order, OR-of-N, LevelOpinions UPSERT "(duyet — khong y kien)") — CAM Proposal-flatten; KHONG port 2 duong ket-thuc-som (owner phan PHA VO S155) - Choke-point finalize ApplyApprovedValuesOnFinalize (Line.ApprovedAmount ??= Proposed) 1-SITE-BY-DESIGN: hop-dong transitions khong co targetPhase => admin cung xuyen choke-point (triet lop #81 tu goc); RULE-comment khuon :356-360 - Transitions endpoint: submit/approve/return/reject (hop-dong LEAD-dinh-nghia literal — reviewer Truc-4 KHOP 5/5, truc W2 tung dut 6 diem); guard trinh CreatedBy|Drafter|DeptManager-cung-phong|Admin; changelog/approvals bang KHKK rieng (CAM IChangelogService — FK-547 rao bang vang-mat-dep); notify Cap-ke dich danh + exclusion actor/drafter (S86) - Save-diem: 1 SaveChanges cuoi = 1 transition 1 unit-of-work (service la diem vao, khac nguon :537) - FE KhkkWorkflowPanel x2 app SHA-pair: so-do Buoc/Cap + banner den-luot + Gui/Duyet/Tra/Tu-choi + y kien; panel GATE nut theo actor (khong de an 403) - Test-before: 6 test san PIN RED-evidence 6/6 (5 NotImplemented + 1 assert) -> GREEN 6/6; sua-test dung 1 seed FAULT-INJECT sot (0 assert doi); suite 574 -> 580/0 - Reviewer 7/7 truc DAT 0 chan (4 truc vai + 3 truc lead on-behalf; Q1-Q6 duyet) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -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: <action>")` (`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<ApplicationDbContext>()
|
||||
.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<User> 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<Guid> 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<Guid> 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<ContractSigningPlanTransitionResult> 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<ContractSigningPlan> 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<List<ContractSigningPlanLevelOpinion>> 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<Guid> 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<List<ContractSigningPlanListItemDto>> 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<Task> actWrongLevel = async () => await ActAsync(f, As(c), p3, "approve", "duyệt vượt lượt");
|
||||
await actWrongLevel.Should().ThrowAsync<ForbiddenException>(
|
||||
"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<Task> actOutsider = async () => await ActAsync(f, As(outsider), p3, "approve", "tôi ký hộ");
|
||||
await actOutsider.Should().ThrowAsync<ForbiddenException>(
|
||||
"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á");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user