[CLAUDE] Supplier: import v2 — Mig 64 publish/draft + dedup-MST + template + file mẫu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m14s

- Mig 64 AddSupplierPublishState: +IsPublic (draft/public); backfill 22 prod->public; filtered-unique doi [Code]<>'' (cho phep nhieu nhap Code=''); no new table (89).
- Import v2: dedup MST-primary + Code-backstop (chong 500 unique-violation); re-bake 30 token header THAT byte-exact (LayoutValid khop file that); nhap Ma NCC per-row (=Code); thieu Ma NCC->nhap (IsPublic=false); MST thieu->canh bao mem (MstMissing).
- PublishSupplierCommand rieng (ne #73 clobber); GET /suppliers/import/template (BE-gen xlsx 30-col).
- FE 2-app SHA-mirror: badge Public/Nhap; nut Cong bo; filter; nut Tai file mau; cot Ma editable; canh bao MST. Picker PE + tao HD loc published=true (an nhap -> ma HD khong dinh Code rong). CreateSupplier set IsPublic=true.
- authz D3: import/preview/confirm/template/publish = Policy Suppliers.Update (khop FE PermissionGuard, het 403).
- Tests +19 (477 PASS): dedup T1-T7, publish-guard, list-filter, all-or-nothing, LayoutValid. Fix null-Code NRE (path R4 loi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 19:03:37 +07:00
parent 41f29acf68
commit 5fa11b588a
28 changed files with 7659 additions and 105 deletions

View File

@ -0,0 +1,452 @@
using System.Reflection;
using System.Text.RegularExpressions;
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using SolutionErp.Application.Master.Suppliers.Dtos;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Services;
// ============================================================================
// Supplier import v2 (S113, owner anh Kiệt) — dedup MST-primary + Code-backstop,
// draft-on-import (IsPublic), all-or-nothing, re-baked header tokens.
// TEST-BEFORE-MERGE class: dedup = CRITICAL-ALGO (chống 500 unique-violation) +
// case-collation BUG-CLASS (S112 lesson). Test theo CODE trên đĩa (S34 single-source),
// KHÔNG sửa production — bug lộ ra → REPORT em-main.
//
// 🔑 Dedup precedence (SupplierExcelImportService.ConfirmAsync + PreviewAsync):
// existing-MST → existing-Code → batch-MST → batch-Code → New.
// - NormalizeMst = Trim + strip MỌI whitespace nội, OrdinalIgnoreCase. Blank MST → null
// (KHÔNG index) ⇒ 2 blank-MST KHÔNG coi là trùng nhau.
// - Code index OrdinalIgnoreCase, blank Code → không index. Update = FillNulls (#73-safe,
// KHÔNG đè non-null, KHÔNG đụng Code/Name/Type/IsPublic).
//
// ⚠️ SQLite fixture = BINARY collation (case-SENSITIVE). Dedup CI phải nằm ở SERVICE
// (in-memory OrdinalIgnoreCase), KHÔNG dựa DB. Test T5 (code-backstop CI) xanh = chứng
// minh service KHÔNG dựa DB collation (nếu dựa → prod SQL Server CI-unique nổ 500).
//
// ✅ FIXED S113 (NRE null-Code): NewSupplier cũ `Code = r.Code!.Trim()` NRE khi row.Code == null
// (path R4 "thiếu Mã NCC → nháp": parser sinh Code=null cho ô rỗng; preview classify New/draft).
// Fix em-main = `Code = (r.Code ?? "").Trim()` → null + "" đồng nhất → Code="" + IsPublic=false
// (draft). Test cuối `...NullCode_SavedAsDraft` chốt hành vi ĐÚNG (NotThrow + nháp).
// ============================================================================
public class SupplierImportV2DedupTests
{
private const int HeaderRow = 4;
private static SupplierExcelImportService NewService(TestApplicationDbContext db)
=> new(db, NullLogger<SupplierExcelImportService>.Instance);
private static SupplierImportRowDto Row(int idx, string? code, string name,
string? taxCode = null, string? phone = null) =>
new() { RowIndex = idx, Code = code, Name = name, TaxCode = taxCode, Phone = phone };
private static void SeedExisting(TestApplicationDbContext db, string code, string name,
string? taxCode = null, string? phone = null, bool isPublic = true)
{
db.Suppliers.Add(new Supplier
{
Id = Guid.NewGuid(), Code = code, Name = name, Type = SupplierType.NhaCungCap,
TaxCode = taxCode, Phone = phone, IsPublic = isPublic,
});
}
// ========================================================================
// T1 — dedup by MST-exact: Update existing, KHÔNG insert (dù Code khác).
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstExactMatch_UpdatesExisting_NoInsert_EvenWhenCodeDiffers()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "EXA", name: "Existing A", taxCode: "0311111111", phone: null);
await db.SaveChangesAsync(CancellationToken.None);
// Row có Code KHÁC ("ZZZ") nhưng MST TRÙNG → dedup phải bắt theo MST.
var rows = new List<SupplierImportRowDto> { Row(5, "ZZZ", "Import row", taxCode: "0311111111", phone: "0909") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue();
result.Updated.Should().Be(1, "MST trùng → Update existing");
result.Inserted.Should().Be(0, "KHÔNG insert bản ghi thứ 2 (dedup MST-primary)");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
var only = await db.Suppliers.SingleAsync();
only.Code.Should().Be("EXA", "FillNulls KHÔNG đụng Code — match qua MST, giữ Code gốc");
only.Phone.Should().Be("0909", "fill-null: Phone đang null → điền (chứng minh đúng row cũ bị update)");
}
// ========================================================================
// T2 — NormalizeMst: "0312 251 859" (có dấu cách) khớp "0312251859" trong DB.
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstWithInnerSpaces_NormalizedMatch_UpdatesExisting()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "EXB", name: "Existing B", taxCode: "0312251859");
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto> { Row(5, "Y", "Import", taxCode: " 0312 251 859 ") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1, "MST strip-whitespace → '0312251859' khớp → Update");
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
}
// ========================================================================
// T3 — 2 row đều blank-MST → 2 insert (KHÔNG merge). Blank MST không phải khóa dedup.
// ========================================================================
[Fact]
public async Task ConfirmAsync_TwoBlankMstRows_DistinctCode_InsertsBoth_NoMerge()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "AA", "Alpha", taxCode: null), // blank MST
Row(6, "BB", "Beta", taxCode: " "), // whitespace → NormalizeMst = null (blank)
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Inserted.Should().Be(2, "2 row blank-MST + Code khác nhau → KHÔNG gộp, insert cả 2");
result.Updated.Should().Be(0);
result.Skipped.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(2,
"blank-MST KHÔNG vào batchByMst → 2 NCC không-MST KHÔNG bị coi trùng nhau");
}
// ========================================================================
// T4 — 2 row CÙNG MST non-blank trong 1 batch → 1 insert + row sau fill-nulls (skip).
// ========================================================================
[Fact]
public async Task ConfirmAsync_TwoRowsSameMstInBatch_SingleInsert_SecondFillsNulls()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "C1", "First", taxCode: "MST9", phone: null), // insert, Phone null
Row(6, "C2", "Second", taxCode: "MST9", phone: "0999"), // cùng MST → gộp vào row1
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Inserted.Should().Be(1, "cùng MST trong batch → chỉ 1 insert");
result.Skipped.Should().Be(1, "row thứ 2 gộp vào bản ghi vừa thêm (fill-nulls), KHÔNG đếm Updated");
result.Updated.Should().Be(0, "gộp trong batch (chưa persist) → không tính Updated");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
var only = await db.Suppliers.SingleAsync();
only.Code.Should().Be("C1", "giữ Code của row đầu (FillNulls không đụng Code)");
only.Phone.Should().Be("0999", "row2 fill-null Phone vào bản ghi batch → chứng minh gộp thật");
}
// ========================================================================
// T5 — no-MST + Code khớp existing (CI) → Update-by-Code backstop (chống 500).
// SQLite BINARY: "backstop" != "BACKSTOP" ở DB → dedup phải ở SERVICE (OrdinalIgnoreCase).
// ========================================================================
[Fact]
public async Task ConfirmAsync_NoMst_CodeMatchesExistingCaseInsensitive_UpdatesByBackstop_NoInsert()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "BACKSTOP", name: "Existing", taxCode: null);
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto> { Row(5, "backstop", "Import", taxCode: null) };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1, "Code khớp CI (service OrdinalIgnoreCase) → Update backstop");
result.Inserted.Should().Be(0,
"KHÔNG insert 'backstop' cạnh 'BACKSTOP' — nếu dựa DB collation SQLite thì sẽ insert (prod SQL Server CI nổ 500)");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
}
// ========================================================================
// T6 — MST→A nhưng Code→B (khác entity): MST-precedence WINS. Update A, B untouched.
// KHÔNG silent-clobber: FillNulls không đụng Code nên A.Code giữ nguyên; B không bị chạm.
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstMatchesA_CodeMatchesB_MstPrecedenceWins_UpdatesA_BUntouched()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "AAA", name: "Alpha", taxCode: "MST-AAA", phone: null);
SeedExisting(db, code: "BBB", name: "Beta", taxCode: "MST-BBB", phone: null);
await db.SaveChangesAsync(CancellationToken.None);
// Row: Code 'bbb' (CI→B) nhưng MST 'MST-AAA' (→A). Precedence MST → target A.
var rows = new List<SupplierImportRowDto> { Row(5, "bbb", "Import", taxCode: "MST-AAA", phone: "P-NEW") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1, "MST-precedence → chỉ A được Update");
result.Inserted.Should().Be(0, "không tạo entity mới");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(2, "vẫn đúng 2 NCC (A + B)");
var a = await db.Suppliers.SingleAsync(x => x.TaxCode == "MST-AAA");
a.Code.Should().Be("AAA", "A.Code KHÔNG bị đổi thành 'bbb' — FillNulls không đụng Code (no silent-clobber)");
a.Phone.Should().Be("P-NEW", "A (khớp MST) nhận fill-null Phone");
var b = await db.Suppliers.SingleAsync(x => x.TaxCode == "MST-BBB");
b.Code.Should().Be("BBB", "B KHÔNG bị chạm dù Code row khớp B (MST thắng)");
b.Phone.Should().BeNull("B untouched — không nhận Phone của row");
}
// ========================================================================
// T6-preview — cùng kịch bản qua PreviewAsync: classify Update + trỏ ExistingSupplierId=A.
// ========================================================================
[Fact]
public async Task PreviewAsync_MstMatchesA_CodeMatchesB_ClassifiesUpdateTargetingA()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var aId = Guid.NewGuid();
db.Suppliers.Add(new Supplier { Id = aId, Code = "AAA", Name = "Alpha", Type = SupplierType.NhaCungCap, TaxCode = "MST-AAA", IsPublic = true });
db.Suppliers.Add(new Supplier { Id = Guid.NewGuid(), Code = "BBB", Name = "Beta", Type = SupplierType.NhaCungCap, TaxCode = "MST-BBB", IsPublic = true });
await db.SaveChangesAsync(CancellationToken.None);
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
ws.Cell(5, 4).Value = "bbb"; // Code → B
ws.Cell(5, 5).Value = "Import";
ws.Cell(5, 12).Value = "MST-AAA"; // MST → A
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue();
var row = preview.Rows.Single();
row.Status.Should().Be(RowImportStatus.Update, "MST-precedence → Update");
row.ExistingSupplierId.Should().Be(aId, "trỏ tới A (khớp MST), KHÔNG phải B (khớp Code)");
}
// ========================================================================
// T7 — MST→A ∧ Code→A (cùng entity): 1 Update, nhất quán, no conflict.
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstAndCodeBothMatchSameEntity_SingleUpdate_NoInsert()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "SAME", name: "Existing", taxCode: "MST-S", phone: null);
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto> { Row(5, "same", "Import", taxCode: "MST-S", phone: "P7") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1);
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
(await db.Suppliers.SingleAsync()).Phone.Should().Be("P7");
}
// ========================================================================
// T8 — draft-on-import: blank Code → IsPublic=false (nháp) + DraftCount; có Code → IsPublic=true.
// Blank Code dùng "" (empty) — path được service HỖ TRỢ (null NREs, xem test bug bên dưới).
// ========================================================================
[Fact]
public async Task ConfirmAsync_BlankCodeRow_IsDraft_CodedRow_IsPublic()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "", "Draft NCC"), // blank Code → nháp
Row(6, "PUB1", "Public NCC"), // có Code → publish-able
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue();
result.Inserted.Should().Be(2);
result.DraftCount.Should().Be(1, "chỉ row blank-Code tính là nháp");
var draft = await db.Suppliers.SingleAsync(x => x.Name == "Draft NCC");
draft.IsPublic.Should().BeFalse("thiếu Mã NCC → IsPublic=false (R4)");
var pub = await db.Suppliers.SingleAsync(x => x.Name == "Public NCC");
pub.IsPublic.Should().BeTrue("có Mã NCC → IsPublic=true");
}
// ========================================================================
// T14 — blank Code KHÔNG còn là hard-error (v2 đổi S112): batch commit, row thành nháp.
// ========================================================================
[Fact]
public async Task ConfirmAsync_BlankCode_IsNotHardError_CommitsAsDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "", "Only-name NCC"), // KHÔNG Code, CÓ Name → hợp lệ (draft)
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue("blank Code KHÔNG abort batch (v2)");
result.Errors.Should().BeEmpty();
result.Inserted.Should().Be(1);
(await db.Suppliers.SingleAsync()).IsPublic.Should().BeFalse();
}
// ========================================================================
// T14b — blank Name VẪN là hard-error → all-or-nothing abort (kể cả draft row hợp lệ cùng batch).
// ========================================================================
[Fact]
public async Task ConfirmAsync_BlankName_StillHardError_AbortsWholeBatch_IncludingValidDraftRow()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "", "Valid draft"), // hợp lệ (blank Code OK)
Row(6, "HASCODE", " "), // hard-error: thiếu Name
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeFalse("1 hard-error (thiếu Name) → all-or-nothing abort");
result.Errors.Should().NotBeEmpty();
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync())
.Should().Be(0, "kể cả 'Valid draft' cũng KHÔNG ghi (all-or-nothing)");
}
// ========================================================================
// T15 — ExpectedHeaderTokens re-baked = ĐÚNG 30 token; header dựng từ chính tokens (reflection)
// → LayoutValid=true. Real-file tokens (dạng \n→space) cũng LayoutValid=true (normalize collapse).
// ========================================================================
[Fact]
public async Task PreviewAsync_ReBakedExpectedTokens_ThirtyTokens_LayoutValidTrue_And_RealTokensAlsoValid()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var expected = ReflectExpectedHeaderTokens();
expected.Should().HaveCount(30, "re-bake phải đúng 30 token row-4");
// (a) header = chính ExpectedHeaderTokens (service phải chấp nhận file có header khớp hệt).
using var streamExpected = BuildWorkbook(expected, ws =>
{
ws.Cell(5, 4).Value = "RT-01";
ws.Cell(5, 5).Value = "NCC token thật";
});
var pExpected = await NewService(db).PreviewAsync(streamExpected, CancellationToken.None);
pExpected.LayoutValid.Should().BeTrue("header = ExpectedHeaderTokens → LayoutValid=true");
pExpected.Rows.Should().ContainSingle();
// (b) faithful real-file tokens (\n thay bằng space) — NormalizeHeader collapse \n → phải khớp.
var real = RealFileHeaderTokensSingleSpace();
NormalizeJoinFaithful(real).Should().Be(NormalizeJoinFaithful(expected),
"sau NormalizeHeader (collapse \\n/space + FormC), real == expected → re-bake khớp file thật");
using var streamReal = BuildWorkbook(real, ws =>
{
ws.Cell(5, 4).Value = "RT-02";
ws.Cell(5, 5).Value = "NCC real";
});
var pReal = await NewService(db).PreviewAsync(streamReal, CancellationToken.None);
pReal.LayoutValid.Should().BeTrue("real tokens (space form) normalize khớp → LayoutValid=true");
}
// ========================================================================
// ✅ FIXED S113 — new-row Code=null → lưu NHÁP, KHÔNG NRE.
// Path R4 "thiếu Mã NCC → nháp": parser sinh null cho ô cột-4 rỗng; preview classify New/draft.
// NewSupplier cũ `Code = r.Code!.Trim()` → NRE-abort-batch (500). Fix: `(r.Code ?? "").Trim()`.
// null + "" giờ đồng nhất → Code="" + IsPublic=false (draft, ẩn khỏi filtered-unique [Code]<>'').
// ========================================================================
[Fact]
public async Task ConfirmAsync_NewRow_NullCode_SavedAsDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto> { Row(5, null, "NCC thiếu Mã NCC") };
var act = async () => await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
await act.Should().NotThrowAsync("S113 fix: Code=null → \"\" → nháp, KHÔNG còn NRE");
var saved = await db.Suppliers.IgnoreQueryFilters().SingleAsync();
saved.Code.Should().BeEmpty("null Code → \"\" sau trim");
saved.IsPublic.Should().BeFalse("thiếu Mã NCC → nháp/ẩn (R4)");
}
// ========================================================================
// Helpers
// ========================================================================
private static string[] ReflectExpectedHeaderTokens()
{
var f = typeof(SupplierExcelImportService)
.GetField("ExpectedHeaderTokens", BindingFlags.NonPublic | BindingFlags.Static);
f.Should().NotBeNull("service phải có static ExpectedHeaderTokens (đọc qua reflection)");
return (string[])f!.GetValue(null)!;
}
private static MemoryStream BuildWorkbook(string[] headerRow4, Action<IXLWorksheet> writeData)
{
var ms = new MemoryStream();
using (var wb = new XLWorkbook())
{
var ws = wb.AddWorksheet("Sheet1");
for (int c = 1; c <= headerRow4.Length; c++)
ws.Cell(HeaderRow, c).Value = headerRow4[c - 1];
writeData(ws);
wb.SaveAs(ms);
}
ms.Position = 0;
return ms;
}
// Faithful replicate SupplierExcelImportService.NormalizeHeader (INCLUDING .Normalize(FormC),
// khác test S112 cũ thiếu FormC) → so-khớp deterministic, không mis-predict.
private static string NormalizeJoinFaithful(IEnumerable<string> tokens)
{
static string Norm(string raw)
{
if (string.IsNullOrEmpty(raw)) return string.Empty;
var collapsed = Regex.Replace(raw.Replace("\n", " ").Replace("\r", " "), @"\s+", " ");
return collapsed.Trim().ToUpperInvariant().Normalize(System.Text.NormalizationForm.FormC);
}
return string.Join(" | ", tokens.Select(Norm));
}
// 30 token row-4 file "Database NCC" thật — dạng single-space (\n gốc thay bằng space).
// NormalizeHeader collapse \n→space nên phải khớp ExpectedHeaderTokens (dạng \n) sau normalize.
private static string[] RealFileHeaderTokensSingleSpace() =>
[
"STT",
"GÓI THẦU",
"PHÂN LOẠI (NTP/NCC/Cả hai)",
"TÊN VIẾT TẮT (Dùng trong HĐ)",
"TÊN CÔNG TY (Đầy đủ, đúng pháp lý)",
"ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)",
"ĐỊA CHỈ VĂN PHÒNG (nếu có)",
"SỐ ĐIỆN THOẠI CÔNG TY",
"FAX",
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý)",
"SỐ TK PHỤ (nếu có)",
"MÃ SỐ THUẾ",
"NGƯỜI ĐẠI DIỆN PHÁP LUẬT",
"CHỨC VỤ ĐẠI DIỆN",
"GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)",
"Link GUQ",
"Link GPKD",
"Link HSNL",
"NGƯỜI LIÊN HỆ CHÍNH",
"CHỨC VỤ NGƯỜI LH",
"SĐT CHÍNH",
"EMAIL",
"ĐỊA CHỈ GỬI THƯ",
"NGƯỜI NHẬN THƯ/ SDT",
"NGUỒN GIỚI THIỆU",
"NGƯỜI PHỤ TRÁCH (PMH)",
"TÌNH TRẠNG HIỆN TẠI",
"GHI CHÚ / LÝ DO BLACKLIST",
"NGÀY CẬP NHẬT CUỐI",
"NGƯỜI CẬP NHẬT",
];
}