[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*) - FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list - Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau) - tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Routing; // HttpMethodAttribute — đọc verb thật của action (W2 KHKK)
|
||||
using SolutionErp.Api.Controllers;
|
||||
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
|
||||
|
||||
@ -321,4 +322,121 @@ public class AuthorizePolicyRegressionTests
|
||||
"GET my-dashboard KHÔNG được gate Policy — mỗi user chỉ thấy số của chính mình. " +
|
||||
"Thêm policy sẽ khóa dashboard cá nhân của role không có Reports.Read (S118 giữ mở có chủ đích).");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [W2 KHKK — S161 2026-07-29] ContractSigningPlansController (GĐ2 "Kế hoạch ký kết HĐ").
|
||||
//
|
||||
// Shape KHÁC 3 controller ở trên — đừng ép về cùng một kiểu:
|
||||
// class-level CÓ Policy `KeHoachKyKet.Read` (mọi endpoint tối thiểu Read) + per-action
|
||||
// override GHI ĐỦ 15/15 action, KỂ CẢ action chỉ-đọc (viết lại Read cho tường minh).
|
||||
// Lý do ghi thành lời ở `ContractSigningPlansController.cs:12-19`.
|
||||
//
|
||||
// Test này quét TOÀN BỘ action (không liệt tên tay) vì rủi ro thật của module mới là
|
||||
// action thứ 16 thêm sau này QUÊN attribute — liệt tay thì action mới không bao giờ bị soi.
|
||||
// 3 lớp răng, mỗi lớp bắt một hỏng khác nhau:
|
||||
// (a) THIẾU attribute → endpoint tụt xuống class-level Read ⇒ ai đọc được cũng GHI được;
|
||||
// (b) SAI tên policy → `Program.cs` chỉ sinh policy cho key ∈ `MenuKeys.All`; tên lạ
|
||||
// = 500 lúc chạy (KHÔNG phải 403) — attribute vẫn "trông đúng";
|
||||
// (c) verb ghi mà policy Read → 403 không bao giờ xảy ra: user chỉ-xem GHI được vào phiếu.
|
||||
// ===================================================================
|
||||
|
||||
/// Bộ action của controller = method public khai TRỰC TIẾP trên class + có `[HttpXxx]`.
|
||||
/// (Lọc theo HttpMethodAttribute để bỏ property/ctor/record lồng bên trong.)
|
||||
private static (string Name, string Verb, AuthorizeAttribute? Authz)[] GetActionsWithVerb(Type controllerType)
|
||||
=> controllerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Select(m => new
|
||||
{
|
||||
m.Name,
|
||||
Http = m.GetCustomAttributes<HttpMethodAttribute>(inherit: false).FirstOrDefault(),
|
||||
Authz = m.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault(),
|
||||
})
|
||||
.Where(x => x.Http is not null)
|
||||
.Select(x => (x.Name, x.Http!.HttpMethods.First().ToUpperInvariant(), x.Authz))
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy()
|
||||
{
|
||||
var type = typeof(ContractSigningPlansController);
|
||||
|
||||
// ---- (0) Class-level: 2 tầng — tầng 1 CÓ Policy Read (cố ý, khác ApprovalWorkflowsV2).
|
||||
var classAttr = GetClassLevelAuthorize(type);
|
||||
classAttr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
|
||||
classAttr!.Policy.Should().Be("KeHoachKyKet.Read",
|
||||
"tầng 1 của authz 2 tầng: MỌI endpoint KHKK tối thiểu phải có quyền đọc menu này. " +
|
||||
"Đây KHÔNG phải bẫy gotcha #44 (silent 403) vì module có menu key riêng — user được " +
|
||||
"cấp KeHoachKyKet mới thấy màn hình này, không phải mọi Drafter như ApprovalWorkflowsV2.");
|
||||
|
||||
var actions = GetActionsWithVerb(type);
|
||||
|
||||
// ---- Neo chống VACUOUS: foreach trên mảng rỗng luôn xanh. 15 action đo từ đĩa
|
||||
// 2026-07-29 (5 GET đọc + POST/PUT/DELETE phiếu + 3 dossier-item + 4 attachment).
|
||||
actions.Should().HaveCountGreaterThanOrEqualTo(15,
|
||||
"quét được {0} action — dưới 15 nghĩa là phép quét hỏng (đổi BindingFlags/attribute) " +
|
||||
"chứ không phải controller teo lại; lúc đó mọi assert dưới đây thành vô nghĩa",
|
||||
actions.Length);
|
||||
|
||||
// Verb GHI = mọi thứ không phải GET/HEAD. Không hardcode danh sách endpoint.
|
||||
var writeVerbs = new[] { "POST", "PUT", "PATCH", "DELETE" };
|
||||
|
||||
foreach (var (name, verb, authz) in actions)
|
||||
{
|
||||
// ---- (a) MỌI action phải có attribute riêng (kể cả GET — luật của module này).
|
||||
authz.Should().NotBeNull(
|
||||
"action {0} ({1}) thiếu [Authorize(Policy = ...)] — nó sẽ tụt xuống class-level " +
|
||||
"`KeHoachKyKet.Read`, nghĩa là ai ĐỌC được cũng GHI được (gotcha #82)", name, verb);
|
||||
authz!.Policy.Should().NotBeNullOrEmpty(
|
||||
"action {0} có [Authorize] TRẦN — `[Authorize]` không Policy = mọi user đăng nhập " +
|
||||
"lọt qua tầng 2 (bài F-1b S155)", name);
|
||||
|
||||
var parts = authz.Policy!.Split('.');
|
||||
parts.Should().HaveCount(2,
|
||||
"policy \"{0}\" của {1} phải đúng dạng {{menuKey}}.{{action}}", authz.Policy, name);
|
||||
|
||||
// ---- (b) Tên policy phải là tên CÓ THẬT trong bộ sinh policy của Program.cs.
|
||||
parts[0].Should().Be(MenuKeys.KeHoachKyKet,
|
||||
"action {0} phải gate bằng menu key của CHÍNH module này — Δ W1 chốt prefix " +
|
||||
"`KeHoachKyKet` (spec cũ ghi `ContractSigningPlans`, đã bỏ); gate nhầm key module " +
|
||||
"khác = cấp quyền chéo", name);
|
||||
MenuKeys.All.Should().Contain(parts[0],
|
||||
"policy \"{0}\" của {1} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không " +
|
||||
"sinh policy đó ⇒ 500 lúc chạy chứ không phải 403", authz.Policy, name);
|
||||
MenuKeys.Actions.Should().Contain(parts[1],
|
||||
"policy \"{0}\" của {1} dùng action không có trong MenuKeys.Actions", authz.Policy, name);
|
||||
|
||||
// ---- (c) Verb GHI KHÔNG được gate bằng Read (403 sẽ không bao giờ xảy ra).
|
||||
if (writeVerbs.Contains(verb))
|
||||
{
|
||||
parts[1].Should().NotBe("Read",
|
||||
"{0} là {1} (ghi) mà chỉ gate `Read` ⇒ user chỉ-xem VẪN ghi được vào phiếu", name, verb);
|
||||
parts[1].Should().BeOneOf(["Create", "Update", "Delete"],
|
||||
"verb ghi {0} phải gate Create/Update/Delete", name);
|
||||
}
|
||||
else
|
||||
{
|
||||
parts[1].Should().Be("Read",
|
||||
"{0} là {1} (đọc) — gate mạnh hơn Read sẽ khóa nhầm người chỉ có quyền xem " +
|
||||
"(403 silent, gotcha #44)", name, verb);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (d) Chốt 3 endpoint mấu chốt bằng TÊN — nếu ai đổi POST phiếu sang `Update`
|
||||
// thì luật (c) ở trên vẫn xanh (Update cũng là verb ghi hợp lệ).
|
||||
var byName = actions.ToDictionary(a => a.Name, a => a.Authz?.Policy);
|
||||
byName[nameof(ContractSigningPlansController.Create)].Should().Be("KeHoachKyKet.Create",
|
||||
"POST tạo phiếu = quyền Create");
|
||||
byName[nameof(ContractSigningPlansController.UpdateDraft)].Should().Be("KeHoachKyKet.Update",
|
||||
"PUT sửa nháp = quyền Update");
|
||||
byName[nameof(ContractSigningPlansController.Delete)].Should().Be("KeHoachKyKet.Delete",
|
||||
"DELETE phiếu = quyền Delete RIÊNG (không gộp Update) — xoá là hành vi khác sửa");
|
||||
|
||||
// Ghi vào phiếu (căn cứ b.8-9 / đính kèm) = Update chứ KHÔNG Create: "tạo" ở module
|
||||
// này nghĩa là tạo PHIẾU. Chốt lại để không ai "sửa cho nhất quán" nhầm hướng.
|
||||
byName[nameof(ContractSigningPlansController.CreateDossierItem)].Should().Be("KeHoachKyKet.Update",
|
||||
"thêm căn cứ = ghi vào phiếu đã có ⇒ Update; gate Create sẽ chặn oan người được giao " +
|
||||
"quyền sửa phiếu nhưng không được lập phiếu mới");
|
||||
byName[nameof(ContractSigningPlansController.UploadAttachment)].Should().Be("KeHoachKyKet.Update",
|
||||
"upload đính kèm = ghi vào phiếu đã có ⇒ Update");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,563 @@
|
||||
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;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Contracts; // ChangelogAction (enum dùng chung 3 module)
|
||||
using SolutionErp.Domain.Identity; // AppRoles
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [W2 KHKK — S161 2026-07-29] CRUD phiếu "Kế hoạch ký kết HĐ" (GĐ2) — test-AFTER
|
||||
// (feature mới, `docs/rules.md §7`), chốt acceptance spec W2 §③-B.
|
||||
//
|
||||
// Code under test: `Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`
|
||||
// Create :292-400 (4 rào theo thứ tự + codegen + auto-sinh Lines)
|
||||
// Delete :1098-1142 (owner ∥ Admin + allow-list phase + thứ tự Remove/Add)
|
||||
//
|
||||
// ── 2 quyết định hạ tầng, cả hai đều là ĐIỀU KIỆN để phép đo có nghĩa ──────────
|
||||
//
|
||||
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng SqliteDbFixture/IdentityFixture).
|
||||
// `TestApplicationDbContext` mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá
|
||||
// **CỨNG** (bài S155). Đo "xoá xong lập lại được" trên xoá-cứng là đo NHẦM cơ chế:
|
||||
// rào (iii) của Create dựa vào GLOBAL QUERY FILTER che row `IsDeleted=1`
|
||||
// (`:323-325`), chứ không phải row biến mất khỏi bảng. Không có interceptor thì
|
||||
// test vẫn XANH kể cả khi filter bị gỡ — đúng kiểu vacuous.
|
||||
// Không cần Identity stack: 2 handler dưới test chỉ nhận `db + currentUser (+ codeGen)`.
|
||||
//
|
||||
// (2) MỖI lệnh chạy trên MỘT DbContext MỚI (`NewDb()`), dùng chung 1 connection —
|
||||
// mô phỏng scoped-per-request của prod. Nếu tái dùng 1 context cho cả seed lẫn
|
||||
// lệnh, các con (Lines/Changelogs) đang được TRACK sẽ bị EF cascade client-side
|
||||
// ngay tại `Remove()` — một đường đi KHÔNG hề xảy ra trong prod (ở đó context mới
|
||||
// chỉ nạp mỗi header). Sai lệch này chính là thứ đẻ ra bug T26 S155, nên nó phải
|
||||
// được mô phỏng cho ĐÚNG chứ không được làm cho tiện.
|
||||
public class ContractSigningPlanCrudTests
|
||||
{
|
||||
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ===================================================================
|
||||
// Fixture: SQLite in-memory + AuditingInterceptor (xoá mềm THẬT).
|
||||
// ===================================================================
|
||||
private sealed class KhkkFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _conn;
|
||||
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||
|
||||
/// Người đang thao tác — đổi được giữa chừng để đóng vai user khác.
|
||||
public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "BCH lập kế hoạch", "bch@test.local");
|
||||
|
||||
public KhkkFixture()
|
||||
{
|
||||
_conn = new SqliteConnection("DataSource=:memory:");
|
||||
_conn.Open();
|
||||
using var boot = NewDb();
|
||||
boot.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// Context MỚI cho mỗi "request" (xem ghi chú (2) đầu file).
|
||||
/// 🔴 `actor` phải là ĐÚNG cái `ICurrentUser` mà handler nhận: trong prod cả
|
||||
/// interceptor lẫn handler đều resolve CÙNG một `ICurrentUser` scoped. Nếu để
|
||||
/// interceptor dính cứng một người còn handler chạy bằng người khác thì
|
||||
/// `DeletedBy`/`UpdatedBy` đo ra người sai — và tệ hơn, nó sai theo hướng
|
||||
/// TRÔNG NHƯ ĐÚNG (vẫn có giá trị, vẫn là một Guid user thật).
|
||||
public TestApplicationDbContext NewDb(ICurrentUser? actor = null)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_conn)
|
||||
.EnableSensitiveDataLogging()
|
||||
.AddInterceptors(new AuditingInterceptor(actor ?? Actor, Clock))
|
||||
.Options;
|
||||
return new TestApplicationDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _conn.Dispose();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed: 1 PE + N nhà cung cấp + M hạng mục báo giá + 1 quy trình duyệt.
|
||||
// ===================================================================
|
||||
private sealed record SeededPe(
|
||||
Guid PeId,
|
||||
Guid ProjectId,
|
||||
Guid DepartmentId,
|
||||
Guid PeDrafterId,
|
||||
Guid WorkflowId,
|
||||
List<Guid> MasterSupplierIds, // Id ở bảng Suppliers (master)
|
||||
List<Guid> PeSupplierRowIds); // Id ở bảng PurchaseEvaluationSuppliers (ROW của phiếu)
|
||||
|
||||
/// <param name="quotes">quotes[hạng mục][NCC] = ThanhTien.</param>
|
||||
/// <param name="selected">selected[hạng mục][NCC] = IsSelected; null ⇒ mặc định "quote của winner được chọn".</param>
|
||||
private static async Task<SeededPe> SeedPeAsync(
|
||||
KhkkFixture f,
|
||||
int supplierCount,
|
||||
int[] winnerIndexes,
|
||||
decimal[][] quotes,
|
||||
bool[][]? selected = null,
|
||||
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
|
||||
ApprovalWorkflowApplicableType wfType = ApprovalWorkflowApplicableType.ContractSigningPlan)
|
||||
{
|
||||
await using var db = f.NewDb();
|
||||
|
||||
// Quy trình duyệt V2 — BẮT BUỘC là row thật: `ContractSigningPlan.ApprovalWorkflowId`
|
||||
// là FK Restrict (`ContractSigningPlanConfiguration.cs:39-42`), Guid rác = SQLite lỗi 19.
|
||||
// KHÔNG cần Step/Level (Level.ApproverUserId mới là FK sang Users) — W2 chưa duyệt.
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-KHKK-V2",
|
||||
Version = 1,
|
||||
ApplicableType = wfType,
|
||||
Name = "Quy trình duyệt kế hoạch ký kết",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
var peId = Guid.NewGuid();
|
||||
var masterIds = new List<Guid>();
|
||||
var rowIds = new List<Guid>();
|
||||
var peSuppliers = new List<PurchaseEvaluationSupplier>();
|
||||
|
||||
for (var i = 0; i < supplierCount; i++)
|
||||
{
|
||||
var master = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = $"NCC{i + 1}",
|
||||
Name = $"Nhà cung cấp {i + 1}",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
db.Suppliers.Add(master);
|
||||
|
||||
var row = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(), // ≠ master.Id — chính là cái bẫy w.Id vs w.SupplierId
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = master.Id,
|
||||
Order = i,
|
||||
IsWinner = winnerIndexes.Contains(i),
|
||||
};
|
||||
masterIds.Add(master.Id);
|
||||
rowIds.Add(row.Id);
|
||||
peSuppliers.Add(row);
|
||||
}
|
||||
|
||||
// Người soạn PE (QS) CỐ Ý khác người lập kế hoạch (BCH) — dùng để chứng D2.
|
||||
var peDrafterId = Guid.NewGuid();
|
||||
var projectId = Guid.NewGuid();
|
||||
var departmentId = Guid.NewGuid();
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = peId,
|
||||
MaPhieu = "PE-KHKK-001",
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = phase,
|
||||
TenGoiThau = "Cung cấp và lắp dựng cốp pha",
|
||||
ProjectId = projectId,
|
||||
DepartmentId = departmentId,
|
||||
DrafterUserId = peDrafterId,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
};
|
||||
pe.Suppliers.AddRange(peSuppliers);
|
||||
|
||||
for (var d = 0; d < quotes.Length; d++)
|
||||
{
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Cốp pha",
|
||||
NoiDung = $"Hạng mục {d + 1}",
|
||||
Order = d,
|
||||
ThanhTienNganSach = 1_000_000_000m, // ngân sách CỐ Ý lệch hẳn báo giá
|
||||
};
|
||||
for (var s = 0; s < supplierCount; s++)
|
||||
{
|
||||
detail.Quotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = peSuppliers[s].Id,
|
||||
ThanhTien = quotes[d][s],
|
||||
IsSelected = selected is not null ? selected[d][s] : winnerIndexes.Contains(s),
|
||||
});
|
||||
}
|
||||
pe.Details.Add(detail);
|
||||
}
|
||||
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
return new SeededPe(peId, projectId, departmentId, peDrafterId, wf.Id, masterIds, rowIds);
|
||||
}
|
||||
|
||||
// ---- Chạy lệnh qua handler THẬT, mỗi lệnh 1 context mới ----
|
||||
|
||||
private static async Task<CreateContractSigningPlanResult> CreateAsync(
|
||||
KhkkFixture f, Guid peId, Guid workflowId, string? ghiChu = null)
|
||||
{
|
||||
await using var db = f.NewDb();
|
||||
var handler = new CreateContractSigningPlanCommandHandler(
|
||||
db, f.Actor, new ContractSigningPlanCodeGenerator(db, f.Clock));
|
||||
return await handler.Handle(
|
||||
new CreateContractSigningPlanCommand(peId, workflowId, ghiChu), CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task DeleteAsync(KhkkFixture f, Guid planId, string? reason, TestCurrentUser actor)
|
||||
{
|
||||
await using var db = f.NewDb(actor); // interceptor + handler CÙNG một actor (như prod)
|
||||
var handler = new DeleteContractSigningPlanCommandHandler(db, actor);
|
||||
await handler.Handle(new DeleteContractSigningPlanCommand(planId, reason), CancellationToken.None);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (1) Tên PIN theo spec §③-B — PE chưa duyệt xong thì KHÔNG lập được kế hoạch.
|
||||
//
|
||||
// Rào này là rào ĐẦU TIÊN (`:312-313`) nên nó cũng gác luôn 3 rào sau: sai ở đây
|
||||
// là phiếu GĐ2 chốt giá trên một phiếu Duyệt NCC còn đang tranh luận.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CreateContractSigningPlan_FromNonApprovedPe_Throws()
|
||||
{
|
||||
using var f = new KhkkFixture();
|
||||
|
||||
// PE ChoDuyet — MỌI thứ khác đã hợp lệ (có winner, có báo giá được chọn, workflow
|
||||
// đúng loại) ⇒ nếu ném thì chỉ có thể vì phase.
|
||||
var seeded = await SeedPeAsync(f,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: [0],
|
||||
quotes: [[100m, 7m]],
|
||||
phase: PurchaseEvaluationPhase.ChoDuyet);
|
||||
|
||||
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
|
||||
(await act.Should().ThrowAsync<ConflictException>(
|
||||
"phiếu Duyệt NCC chưa ở trạng thái Đã duyệt thì chưa có giá chốt để lập kế hoạch"))
|
||||
.WithMessage("*Đã duyệt*");
|
||||
|
||||
await using (var db = f.NewDb())
|
||||
{
|
||||
db.ContractSigningPlans.IgnoreQueryFilters().Count()
|
||||
.Should().Be(0, "ném xong KHÔNG được để lại phiếu nửa vời");
|
||||
|
||||
// Rào phase đứng TRƯỚC codegen (`:312` vs `:339`) ⇒ không đốt số thứ tự.
|
||||
// Nếu ai đổi thứ tự, mã KHKK sẽ có lỗ mà chẳng phiếu nào mang.
|
||||
db.WorkflowAppCodeSequences.Any(s => s.Prefix == "KHKK/2026")
|
||||
.Should().BeFalse("lỗi ở rào đầu thì chưa được cấp phát mã nào");
|
||||
}
|
||||
|
||||
// ---- VẾ ĐỐI CHỨNG (bắt buộc): cùng bộ seed, ĐỔI ĐÚNG 1 BIẾN = phase → phải CHẠY.
|
||||
// Thiếu vế này, test trên vẫn xanh trong cái thế giới mà seed hỏng sẵn (FK/required
|
||||
// thiếu) — lúc đó nó đo lỗi của chính mình chứ không đo rào.
|
||||
using var ok = new KhkkFixture();
|
||||
var okSeed = await SeedPeAsync(ok,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: [0],
|
||||
quotes: [[100m, 7m]],
|
||||
phase: PurchaseEvaluationPhase.DaDuyet);
|
||||
|
||||
var result = await CreateAsync(ok, okSeed.PeId, okSeed.WorkflowId);
|
||||
result.LineCount.Should().Be(1, "cùng seed đó với PE Đã duyệt thì lập được kế hoạch bình thường");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (2) Happy path — acceptance §③-B: N Lines = số winner, `PeReferenceAmount` =
|
||||
// SUM báo giá ĐƯỢC CHỌN của ĐÚNG NCC đó, mã khớp `^KHKK/\d{4}/\d{3}$`.
|
||||
//
|
||||
// Seed cài sẵn 3 cái bẫy, mỗi cái bắt một lỗi khác nhau:
|
||||
// • NCC thua (idx 2) có báo giá `IsSelected = true` và số TO → lọt = quên lọc winner;
|
||||
// • winner 1 có 1 hạng mục `IsSelected = false` → cộng = quên lọc IsSelected;
|
||||
// • Id ROW ≠ Id master supplier → dùng nhầm = sai NCC hoàn toàn
|
||||
// (bẫy `w.Id` vs `w.SupplierId` mà lane BE tự khai ở sub-md §1).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CreateContractSigningPlan_HappyPath_LinesMatchWinners_AndPeReferenceAmountIsSelectedQuoteSum()
|
||||
{
|
||||
using var f = new KhkkFixture();
|
||||
|
||||
// 3 NCC: 0 + 1 trúng, 2 thua. 2 hạng mục.
|
||||
// winner0: 100 + 40 = 140 (cả 2 được chọn)
|
||||
// winner1: 200 + [60] = 200 (hạng mục 2 KHÔNG được chọn)
|
||||
// loser2 : 999 + 888 (được chọn hết — mồi nhử)
|
||||
var seeded = await SeedPeAsync(f,
|
||||
supplierCount: 3,
|
||||
winnerIndexes: [0, 1],
|
||||
quotes:
|
||||
[
|
||||
[100m, 200m, 999m],
|
||||
[40m, 60m, 888m],
|
||||
],
|
||||
selected:
|
||||
[
|
||||
[true, true, true],
|
||||
[true, false, true],
|
||||
]);
|
||||
|
||||
var result = await CreateAsync(f, seeded.PeId, seeded.WorkflowId, "Kế hoạch ký kết đợt 1");
|
||||
|
||||
// ---- Kết quả trả về (đúng thứ FE đọc để điều hướng sau khi POST).
|
||||
result.LineCount.Should().Be(2, "2 NCC trúng thầu ⇒ 2 dòng giá");
|
||||
result.MaKeHoach.Should().MatchRegex(@"^KHKK/\d{4}/\d{3}$", "acceptance §③-B");
|
||||
result.MaKeHoach.Should().Be("KHKK/2026/001",
|
||||
"regex một mình vẫn xanh khi năm bị hardcode hoặc seq đứng yên — chốt luôn giá trị tuyệt đối");
|
||||
|
||||
await using var db = f.NewDb();
|
||||
var plan = await db.ContractSigningPlans.AsNoTracking()
|
||||
.Include(p => p.Lines)
|
||||
.SingleAsync(p => p.Id == result.Id);
|
||||
|
||||
plan.Phase.Should().Be(ContractSigningPlanPhase.DangSoanThao, "lập xong là NHÁP, chưa trình");
|
||||
plan.MaKeHoach.Should().Be(result.MaKeHoach, "mã trong DB phải khớp mã trả về");
|
||||
plan.ApprovalWorkflowId.Should().Be(seeded.WorkflowId, "quy trình pin ngay lúc tạo");
|
||||
plan.GhiChu.Should().Be("Kế hoạch ký kết đợt 1");
|
||||
|
||||
// ---- Denorm từ PE.
|
||||
plan.PurchaseEvaluationId.Should().Be(seeded.PeId);
|
||||
plan.ProjectId.Should().Be(seeded.ProjectId, "dự án lấy từ phiếu nguồn");
|
||||
plan.DepartmentId.Should().Be(seeded.DepartmentId, "phòng lấy từ phiếu nguồn");
|
||||
|
||||
// ---- D2: người soạn kế hoạch = NGƯỜI ĐANG TẠO, không phải người soạn PE.
|
||||
// 2 vế: bằng người tạo ∧ KHÁC người soạn PE. Chỉ có vế đầu thì trong thế giới
|
||||
// "copy pe.DrafterUserId" test vẫn có thể xanh nếu hai người trùng nhau.
|
||||
plan.DrafterUserId.Should().Be(f.Actor.UserId!.Value, "BCH lập kế hoạch là chủ phiếu GĐ2");
|
||||
plan.DrafterUserId.Should().NotBe(seeded.PeDrafterId,
|
||||
"người soạn phiếu Duyệt NCC (QS) là vai KHÁC — copy sang là trao nhầm quyền sửa/xoá");
|
||||
|
||||
// ---- Lines: đúng 2 NCC trúng, trỏ Id MASTER (không phải Id row của phiếu PE).
|
||||
var lines = plan.Lines.OrderBy(l => l.PeReferenceAmount).ToList();
|
||||
lines.Select(l => l.SupplierId).Should().BeEquivalentTo(
|
||||
new[] { seeded.MasterSupplierIds[0], seeded.MasterSupplierIds[1] },
|
||||
"dòng giá phải trỏ NCC ở danh mục master");
|
||||
lines.Select(l => l.SupplierId).Should().NotContain(seeded.PeSupplierRowIds[0],
|
||||
"SupplierId là `w.SupplierId` (master) chứ KHÔNG phải `w.Id` (row của phiếu PE) — "
|
||||
+ "nhầm 2 cái này thì dòng giá trỏ vào một NCC không tồn tại, tên hiện rỗng ở FE");
|
||||
lines.Should().NotContain(l => l.SupplierId == seeded.MasterSupplierIds[2],
|
||||
"NCC thua thầu KHÔNG được có dòng giá dù báo giá của nó vẫn IsSelected");
|
||||
|
||||
// ---- Số tiền chốt: từng đồng, không dùng biểu thức lặp lại logic sản xuất.
|
||||
var bySupplier = plan.Lines.ToDictionary(l => l.SupplierId);
|
||||
bySupplier[seeded.MasterSupplierIds[0]].PeReferenceAmount.Should().Be(140m,
|
||||
"winner0 = 100 + 40 (cả 2 hạng mục được chọn)");
|
||||
bySupplier[seeded.MasterSupplierIds[1]].PeReferenceAmount.Should().Be(200m,
|
||||
"winner1 = 200; hạng mục 60 KHÔNG được chọn nên KHÔNG cộng — nếu ra 260 là quên lọc IsSelected");
|
||||
|
||||
// Ngân sách hạng mục là 1 tỷ/dòng: nếu ai lỡ lấy ThanhTienNganSach thì con số sẽ
|
||||
// nhảy sang hàng tỷ, không thể trùng 140/200 do ngẫu nhiên.
|
||||
plan.Lines.Sum(l => l.PeReferenceAmount).Should().Be(340m);
|
||||
|
||||
// ---- D4: mồi ProposedAmount = số tham chiếu (W2 chưa có đường sửa dòng giá).
|
||||
plan.Lines.Should().OnlyContain(l => l.ProposedAmount == l.PeReferenceAmount,
|
||||
"đề xuất mồi bằng giá đã duyệt ở PE — để 0 thì cả phiếu là 0 mà W2 không ai sửa được");
|
||||
plan.Lines.Should().OnlyContain(l => l.ApprovedAmount == null, "giá chốt chỉ có ở W3");
|
||||
|
||||
// ---- Vết audit: 1 dòng nhật ký Insert, có trỏ ngược về phiếu nguồn.
|
||||
var logs = await db.ContractSigningPlanChangelogs.AsNoTracking()
|
||||
.Where(c => c.ContractSigningPlanId == plan.Id).ToListAsync();
|
||||
logs.Should().ContainSingle();
|
||||
logs[0].Action.Should().Be(ChangelogAction.Insert);
|
||||
logs[0].EntityType.Should().Be(ContractSigningPlanEntityType.Header);
|
||||
logs[0].PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
|
||||
logs[0].UserId.Should().Be(f.Actor.UserId);
|
||||
logs[0].ContextNote.Should().Contain(seeded.PeId.ToString(),
|
||||
"nhật ký phải truy ngược được về phiếu Duyệt NCC nguồn");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (3) 1 phiếu PE = 1 kế hoạch SỐNG (`:323-328`) — acceptance "tạo lần 2 → 409".
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CreateContractSigningPlan_SecondTimeForSamePe_Throws_AndDoesNotBurnCode()
|
||||
{
|
||||
using var f = new KhkkFixture();
|
||||
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||
|
||||
var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
first.MaKeHoach.Should().Be("KHKK/2026/001");
|
||||
|
||||
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
await act.Should().ThrowAsync<ConflictException>(
|
||||
"1 phiếu Duyệt NCC chỉ được có 1 kế hoạch ký kết đang sống");
|
||||
|
||||
await using var db = f.NewDb();
|
||||
db.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Should().Be(1, "lần 2 không được đẻ thêm phiếu");
|
||||
|
||||
// Rào (iii) đứng TRƯỚC codegen ⇒ lần thất bại không nuốt mất số 002.
|
||||
db.WorkflowAppCodeSequences.Single(s => s.Prefix == "KHKK/2026").LastSeq
|
||||
.Should().Be(1, "lần tạo hỏng KHÔNG được tiêu một số thứ tự (mã sẽ thủng lỗ chỗ)");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (4) Rào workflow-type — TRỌN 2 nhánh (`:197-200`).
|
||||
//
|
||||
// Đây là chỗ dễ "copy nửa guard" nhất: bản gốc Proposal có 2 nhánh, chép thiếu nhánh
|
||||
// NotFound thì một Guid quy trình rác đi thẳng vào phiếu, và chỉ vỡ ở W3 lúc duyệt.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CreateContractSigningPlan_WorkflowGuard_WrongType_Conflict_And_Unknown_NotFound()
|
||||
{
|
||||
// (a) Quy trình CÓ THẬT nhưng thuộc module khác (Duyệt NCC) → Conflict.
|
||||
using var wrong = new KhkkFixture();
|
||||
var wrongSeed = await SeedPeAsync(wrong,
|
||||
supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]],
|
||||
wfType: ApprovalWorkflowApplicableType.DuyetNcc);
|
||||
|
||||
var actWrong = async () => await CreateAsync(wrong, wrongSeed.PeId, wrongSeed.WorkflowId);
|
||||
(await actWrong.Should().ThrowAsync<ConflictException>(
|
||||
"quy trình của module khác không được pin vào phiếu KHKK"))
|
||||
.WithMessage("*Kế hoạch ký kết*");
|
||||
|
||||
// (b) Quy trình KHÔNG tồn tại → NotFound (KHÔNG được im lặng cho qua).
|
||||
using var missing = new KhkkFixture();
|
||||
var okSeed = await SeedPeAsync(missing, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||
|
||||
var actMissing = async () => await CreateAsync(missing, okSeed.PeId, Guid.NewGuid());
|
||||
await actMissing.Should().ThrowAsync<NotFoundException>(
|
||||
"Guid quy trình rác phải chặn NGAY — lọt vào phiếu thì tới W3 mới vỡ, lúc đó phiếu đã có mã");
|
||||
|
||||
await using var db = missing.NewDb();
|
||||
db.ContractSigningPlans.IgnoreQueryFilters().Count().Should().Be(0);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (5) Chưa chọn NCC trúng thầu (`:317-318`) — phiếu 0 dòng giá là phiếu vô nghĩa.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task CreateContractSigningPlan_NoWinner_Throws()
|
||||
{
|
||||
using var f = new KhkkFixture();
|
||||
var seeded = await SeedPeAsync(f,
|
||||
supplierCount: 3,
|
||||
winnerIndexes: [], // PE đã duyệt nhưng chưa tick NCC nào trúng
|
||||
quotes: [[100m, 200m, 300m]],
|
||||
selected: [[true, true, true]]); // có báo giá được chọn — vẫn KHÔNG đủ
|
||||
|
||||
var act = async () => await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
(await act.Should().ThrowAsync<ConflictException>(
|
||||
"không có NCC trúng thầu thì không có dòng giá nào để chốt"))
|
||||
.WithMessage("*trúng thầu*");
|
||||
|
||||
await using var db = f.NewDb();
|
||||
db.ContractSigningPlans.IgnoreQueryFilters().Count()
|
||||
.Should().Be(0, "KHÔNG được tạo phiếu rỗng 0 dòng");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (6) Acceptance §③-B: "DELETE ở Nháp → 204; tạo lại cho CÙNG phiếu PE → 201".
|
||||
//
|
||||
// Đo 3 điều CÙNG LÚC, vì chỉ đo "tạo lại được" là đo yếu nhất trong ba:
|
||||
// (a) xoá là xoá MỀM (row còn, `IsDeleted=1`) — nếu là xoá cứng thì màn "Đã xoá" rỗng;
|
||||
// (b) rào "1 PE = 1 kế hoạch sống" thoát được nhờ GLOBAL QUERY FILTER, không phải
|
||||
// nhờ row biến mất ⇒ đây mới là cửa thoát khoá 409 vĩnh viễn (M6);
|
||||
// (c) 🔴 dòng nhật ký "xoá" PHẢI tồn tại — bug T26 (S155) là: `Add(changelog)` đặt
|
||||
// TRƯỚC `Remove(cha)` thì EF cascade client-side tại `Remove()` DETACH luôn
|
||||
// dòng đang `Added` ⇒ SaveChanges ghi 0 row, IM LẶNG, không lỗi. Bản W2 đã đảo
|
||||
// đúng thứ tự (`:1126-1128`); vế (c) khoá lại để không ai "dọn cho gọn" ngược về.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task DeleteContractSigningPlan_SoftDeletes_KeepsChangelog_ThenCreateAgainSucceeds()
|
||||
{
|
||||
using var f = new KhkkFixture();
|
||||
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||
|
||||
var first = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
await DeleteAsync(f, first.Id, "Lập nhầm phiếu nguồn", f.Actor);
|
||||
|
||||
await using (var db = f.NewDb())
|
||||
{
|
||||
// (a) xoá MỀM: khuất khỏi mọi query thường, nhưng row vẫn nằm đó.
|
||||
db.ContractSigningPlans.Count(p => p.Id == first.Id)
|
||||
.Should().Be(0, "phiếu đã xoá không được hiện ở danh sách");
|
||||
var raw = db.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
|
||||
.Single(p => p.Id == first.Id);
|
||||
raw.IsDeleted.Should().BeTrue("`Remove()` + AuditingInterceptor = xoá MỀM, không xoá cứng");
|
||||
raw.DeletedAt.Should().NotBeNull();
|
||||
raw.DeletedBy.Should().Be(f.Actor.UserId);
|
||||
raw.MaKeHoach.Should().Be("KHKK/2026/001", "mã cũ vẫn giữ trong row đã xoá");
|
||||
|
||||
// (c) vết xoá phải SỐNG SÓT qua lượt cascade — regression bug T26 S155.
|
||||
var logs = db.ContractSigningPlanChangelogs.AsNoTracking()
|
||||
.Where(c => c.ContractSigningPlanId == first.Id).ToList();
|
||||
logs.Should().HaveCount(2, "1 dòng lúc lập + 1 dòng lúc xoá");
|
||||
var del = logs.Single(l => l.Action == ChangelogAction.Delete);
|
||||
del.ContextNote.Should().Be("Lập nhầm phiếu nguồn", "lý do xoá nằm ở nhật ký");
|
||||
del.PhaseAtChange.Should().Be(ContractSigningPlanPhase.DangSoanThao);
|
||||
del.UserId.Should().Be(f.Actor.UserId);
|
||||
}
|
||||
|
||||
// (b) Cửa thoát: lập LẠI cho CHÍNH phiếu PE đó.
|
||||
var second = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
second.Id.Should().NotBe(first.Id);
|
||||
second.MaKeHoach.Should().Be("KHKK/2026/002", "phiếu mới lấy số tiếp theo, không tái dùng mã cũ");
|
||||
second.LineCount.Should().Be(1);
|
||||
|
||||
await using var check = f.NewDb();
|
||||
check.ContractSigningPlans.Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Should().Be(1, "chỉ 1 kế hoạch SỐNG cho phiếu PE này");
|
||||
check.ContractSigningPlans.IgnoreQueryFilters().Count(p => p.PurchaseEvaluationId == seeded.PeId)
|
||||
.Should().Be(2, "cộng cả phiếu đã xoá mềm là 2 row vật lý");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (7) Rào xoá: chủ phiếu ∥ Admin, và CHỈ ở {Nháp, Từ chối} (`:1107-1118`).
|
||||
// Phiếu đang duyệt phải Trả lại/Từ chối trước — xoá giữa chừng là mất lượt
|
||||
// của người đang chờ duyệt (đường xoá-khi-đang-duyệt là việc của W3).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task DeleteContractSigningPlan_GuardedByOwnerAndPhase()
|
||||
{
|
||||
using var f = new KhkkFixture();
|
||||
var seeded = await SeedPeAsync(f, supplierCount: 2, winnerIndexes: [0], quotes: [[100m, 7m]]);
|
||||
var created = await CreateAsync(f, seeded.PeId, seeded.WorkflowId);
|
||||
|
||||
// (a) người lạ (không phải chủ phiếu, không Admin) → Forbidden.
|
||||
var stranger = new TestCurrentUser(Guid.NewGuid(), "Người lạ", "la@test.local");
|
||||
var actStranger = async () => await DeleteAsync(f, created.Id, null, stranger);
|
||||
await actStranger.Should().ThrowAsync<ForbiddenException>(
|
||||
"chỉ người soạn kế hoạch hoặc Admin được xoá");
|
||||
|
||||
await using (var db = f.NewDb())
|
||||
{
|
||||
db.ContractSigningPlans.Count(p => p.Id == created.Id)
|
||||
.Should().Be(1, "chặn xong phiếu phải còn NGUYÊN (ném rồi mà vẫn ghi = tệ hơn không rào)");
|
||||
|
||||
// (b) đẩy phiếu sang Đã gửi duyệt (W2 chưa có transition ⇒ set thẳng DB).
|
||||
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
|
||||
plan.Phase = ContractSigningPlanPhase.ChoDuyet;
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var actPhase = async () => await DeleteAsync(f, created.Id, null, f.Actor);
|
||||
(await actPhase.Should().ThrowAsync<ConflictException>(
|
||||
"phiếu đang chờ duyệt thì chủ phiếu cũng không được rút êm"))
|
||||
.WithMessage("*Nháp*");
|
||||
|
||||
// (c) VẾ THUẬN: Admin không-phải-chủ-phiếu + đưa phiếu về Từ chối (nằm trong
|
||||
// allow-list) → xoá ĐƯỢC. Không có vế này thì 2 vế trên vẫn xanh trong thế
|
||||
// giới mà handler ném với MỌI đầu vào.
|
||||
await using (var db = f.NewDb())
|
||||
{
|
||||
var plan = db.ContractSigningPlans.Single(p => p.Id == created.Id);
|
||||
plan.Phase = ContractSigningPlanPhase.TuChoi;
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
|
||||
await DeleteAsync(f, created.Id, "Dọn phiếu bị từ chối", admin);
|
||||
|
||||
await using var final = f.NewDb();
|
||||
final.ContractSigningPlans.Count(p => p.Id == created.Id).Should().Be(0);
|
||||
final.ContractSigningPlans.IgnoreQueryFilters().AsNoTracking()
|
||||
.Single(p => p.Id == created.Id).DeletedBy
|
||||
.Should().Be(admin.UserId, "người xoá thật được ghi lại là Admin, không phải chủ phiếu");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user