[CLAUDE] Supplier: Excel-import Phase B (upload NCC preview/confirm) + Mig 63
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m25s

Import Excel 'Database NCC': upload→preview classify (New/Update/Skip/Error)→confirm all-or-nothing upsert-by-Code (OrdinalIgnoreCase dedup, fill-nulls-safe). Mig 63 +SourceUpdatedAt/By (2 nullable). Parser layout-locked header-fingerprint NFC-normalized + absolute-cell-index + #REF!-null + NAS-backslash-raw. Type-lạ→NhaCungCap. FE dialog fe-admin+fe-user byte-identical. 10 test SupplierExcelImportServiceTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 16:13:46 +07:00
parent a829d0df99
commit e100ef065b
20 changed files with 8268 additions and 14 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 Phase B — SupplierExcelImportService (import NCC từ file "Database NCC").
// TEST-BEFORE-MERGE: CRITICAL-ALGO (parser layout-locked + upsert) + BUG-FIX class
// (case-collation dedup) → tests bắt buộc trước commit (docs/rules.md §7).
//
// Test theo CODE trên đĩa (S34 single-source-of-truth). KHÔNG sửa production code —
// nếu test lộ bug prod thật → REPORT em main, không tự fix.
//
// 🔴 BUG-CLASS (Case 1): unique-index Code ở SQL Server = case-INSENSITIVE. Nếu service
// so Ordinal, "truonggiang" (file) ≠ "TRUONGGIANG" (DB) → miss → quyết INSERT →
// ConfirmAsync nổ unique-violation → 500 cả batch. Service dùng OrdinalIgnoreCase ở
// cả preview + confirm để chặn. Test chứng minh dedup CI hoạt động (chỉ 1 NCC, update
// tại chỗ, không insert hàng thứ 2).
// LƯU Ý collation: SQLite (fixture) mặc định BINARY (case-SENSITIVE) → DB-level unique
// KHÔNG bắt "truonggiang" vs "TRUONGGIANG". Đó chính là điểm test: dedup phải nằm ở
// SERVICE (in-memory OrdinalIgnoreCase), KHÔNG dựa DB — nếu dựa DB thì prod SQL Server
// sẽ ném 500. Test này xanh chứng tỏ service không dựa DB.
//
// ⚠️ SPEC-DRIFT đã biết (không phải bug tôi phát hiện): service.ExpectedHeaderTokens hiện
// là BEST-GUESS, CHƯA khớp 30 token row-4 thật của file (sẽ sửa ở close-review adjust).
// → Các test cần "valid layout" (Case 4-7) DỰNG header từ chính ExpectedHeaderTokens
// của service (đọc qua REFLECTION) nên LUÔN đồng bộ — không vỡ khi tokens được sửa.
// → Case 9 (PreviewAsync_RealFileRow4Tokens...) nạp 30 token THẬT và khẳng định
// PreviewAsync đồng ý với so-khớp-fingerprint trực tiếp: hiện tại lệch → LayoutValid=false;
// khi tokens sửa = token thật → tự động khẳng định LayoutValid=true (không phải sửa test).
// ============================================================================
public class SupplierExcelImportServiceTests
{
private const int HeaderRow = 4;
private static SupplierExcelImportService NewService(TestApplicationDbContext db)
=> new(db, NullLogger<SupplierExcelImportService>.Instance);
// ========================================================================
// Case 1 (NON-NEGOTIABLE) — case-collation dedup qua ConfirmAsync.
// ========================================================================
[Fact]
public async Task ConfirmAsync_LowercaseCode_MatchesExistingUppercase_UpdatesInPlace_NoDuplicateInsert()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
db.Suppliers.Add(new Supplier
{
Code = "TRUONGGIANG",
Name = "Công ty TNHH Trường Giang",
Type = SupplierType.NhaCungCap,
Phone = null, // để trống → fill-nulls có việc làm, chứng minh cùng-1-row bị update
Address = null,
});
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto>
{
new() { RowIndex = 5, Code = "truonggiang", Name = "Trường Giang", Phone = "0909", Address = "123 Hà Nội" },
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue();
result.Inserted.Should().Be(0, "Code chữ thường khớp CI với NCC đã có → KHÔNG insert bản ghi mới");
result.Updated.Should().Be(1, "khớp case-insensitive → fill-nulls update tại chỗ");
(await db.Suppliers.CountAsync()).Should().Be(1,
"chỉ đúng 1 NCC — dedup OrdinalIgnoreCase chặn hàng thứ 2 (nếu so Ordinal → prod SQL Server nổ unique-violation 500)");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1, "không có bản ghi phantom nào kể cả đã soft-delete");
var only = await db.Suppliers.SingleAsync();
only.Code.Should().Be("TRUONGGIANG", "Code gốc giữ nguyên hoa/thường — update entity cũ, không thay bằng entity mới");
only.Phone.Should().Be("0909", "fill-null: Phone đang null → được điền (chứng minh chính row cũ bị update)");
only.Address.Should().Be("123 Hà Nội", "fill-null: Address đang null → được điền");
}
// ========================================================================
// Case 1b — PreviewAsync phân loại hàng chữ thường là Update (không phải New).
// ========================================================================
[Fact]
public async Task PreviewAsync_LowercaseCode_OfExistingUppercase_ClassifiedAsUpdate_NotNew()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
db.Suppliers.Add(new Supplier
{
Code = "TRUONGGIANG",
Name = "Công ty Trường Giang",
Type = SupplierType.NhaCungCap,
});
await db.SaveChangesAsync(CancellationToken.None);
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
ws.Cell(5, 3).Value = "NCC";
ws.Cell(5, 4).Value = "truonggiang";
ws.Cell(5, 5).Value = "Trường Giang";
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue();
var row = preview.Rows.Single();
row.Status.Should().Be(RowImportStatus.Update, "Code chữ thường khớp CI với 'TRUONGGIANG' → Update, KHÔNG phải New");
row.ExistingSupplierId.Should().NotBeNull("Update phải trỏ tới NCC đã tồn tại");
preview.UpdateCount.Should().Be(1);
preview.NewCount.Should().Be(0);
}
// ========================================================================
// Case 2 (NON-NEGOTIABLE) — fill-nulls SAFE (decision 4): không đè non-null, chỉ điền null.
// ========================================================================
[Fact]
public async Task ConfirmAsync_FillNulls_DoesNotOverwriteNonNull_FillsOnlyNull()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
db.Suppliers.Add(new Supplier
{
Code = "X",
Name = "X Co",
Type = SupplierType.NhaCungCap,
Phone = "0900", // non-null → PHẢI giữ nguyên
Address = null, // null → được điền
});
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto>
{
new() { RowIndex = 5, Code = "X", Name = "X Co", Phone = "9999", Address = "Hà Nội" },
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1);
result.Committed.Should().BeTrue();
var x = await db.Suppliers.SingleAsync();
x.Phone.Should().Be("0900", "Phone đang non-null KHÔNG bị đè bởi '9999' (fill-nulls SAFE)");
x.Address.Should().Be("Hà Nội", "Address đang null → được điền");
}
// ========================================================================
// Case 3 (NON-NEGOTIABLE) — all-or-nothing: 1 hard-error → abort cả batch, DB không đổi.
// ========================================================================
[Fact]
public async Task ConfirmAsync_AnyHardErrorRow_AbortsWholeBatch_NothingWritten()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
new() { RowIndex = 5, Code = "A", Name = "Alpha" }, // hợp lệ
new() { RowIndex = 6, Code = "B", Name = " " }, // hard-error: thiếu Name
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeFalse("có hard-error → all-or-nothing abort, KHÔNG ghi gì");
result.Errors.Should().NotBeEmpty("phải liệt kê dòng lỗi");
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync())
.Should().Be(0, "kể cả hàng hợp lệ 'A' cũng KHÔNG được ghi (all-or-nothing)");
}
// ========================================================================
// Case 4 (decision 5) — PHÂN LOẠI lạ → Type mặc định NhaCungCap + cảnh báo; type biết → map đúng.
// Đi qua PARSE path (MapType private, chỉ chạy khi parse xlsx).
// ========================================================================
[Fact]
public async Task PreviewAsync_UnrecognizedType_DefaultsToNhaCungCap_WithWarning_KnownTypeStillMapped()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
// row 5 — PHÂN LOẠI không nhận dạng
ws.Cell(5, 3).Value = "Loại không xác định";
ws.Cell(5, 4).Value = "U1";
ws.Cell(5, 5).Value = "NCC lạ";
// row 6 — "Cả hai" (positive control: mapper KHÔNG phải luôn default)
ws.Cell(6, 3).Value = "Cả hai";
ws.Cell(6, 4).Value = "U2";
ws.Cell(6, 5).Value = "NCC cả hai";
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue();
preview.Rows.Should().HaveCount(2);
var r5 = preview.Rows.Single(x => x.RowIndex == 5);
r5.Type.Should().Be(SupplierType.NhaCungCap, "PHÂN LOẠI lạ → mặc định NCC (decision 5, KHÔNG reject)");
r5.Messages.Should().Contain(m => m.Contains("không nhận dạng"),
"có cảnh báo mềm — chứng minh đã đi nhánh unknown, không chỉ là default của DTO");
var r6 = preview.Rows.Single(x => x.RowIndex == 6);
r6.Type.Should().Be(SupplierType.CaHai, "positive control: 'Cả hai' map đúng CaHai");
}
// ========================================================================
// Case 5 — strip emoji đầu chuỗi tình trạng: "✅ Đang hoạt động" → DangHoatDong (parse → persist).
// ========================================================================
[Fact]
public async Task PreviewThenConfirm_StatusWithEmojiPrefix_StripsAndPersistsDangHoatDong()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
ws.Cell(5, 4).Value = "EMO1";
ws.Cell(5, 5).Value = "NCC Emoji";
ws.Cell(5, 27).Value = "✅ Đang hoạt động";
});
var svc = NewService(db);
var preview = await svc.PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue();
var row = preview.Rows.Single();
row.SupplierStatus.Should().Be(SupplierStatus.DangHoatDong,
"emoji + khoảng trắng đầu bị strip → còn 'Đang hoạt động'");
var result = await svc.ConfirmAsync(preview.Rows, "tester", CancellationToken.None);
result.Inserted.Should().Be(1);
var persisted = await db.Suppliers.SingleAsync();
persisted.Status.Should().Be(SupplierStatus.DangHoatDong, "tình trạng đã strip được persist đúng");
}
// ========================================================================
// Case 6 — valid layout (header từ ExpectedHeaderTokens qua reflection) → parse field
// theo CHỈ SỐ CỘT TUYỆT ĐỐI; LinkGuq giữ RAW backslash NAS.
// ========================================================================
[Fact]
public async Task PreviewAsync_ValidLayout_ParsesFieldsByAbsoluteIndex_IncludingRawBackslashLink()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
const string nasLink = @"\\NAS-SOL\GUQ\truong-giang.pdf";
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
ws.Cell(5, 2).Value = "Gói thầu A";
ws.Cell(5, 3).Value = "NCC";
ws.Cell(5, 4).Value = "TG-01";
ws.Cell(5, 5).Value = "Công ty Trường Giang";
ws.Cell(5, 16).Value = nasLink;
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue(
"header row-4 khớp ExpectedHeaderTokens (đọc qua reflection → luôn đồng bộ kể cả sau khi close-review sửa tokens)");
preview.Rows.Should().ContainSingle();
var row = preview.Rows.Single();
row.PackageCategory.Should().Be("Gói thầu A", "col 2 → PackageCategory");
row.Type.Should().Be(SupplierType.NhaCungCap, "col 3 'NCC' → NhaCungCap");
row.Code.Should().Be("TG-01", "col 4 → Code");
row.Name.Should().Be("Công ty Trường Giang", "col 5 → Name");
row.LinkGuq.Should().Be(nasLink, "col 16 → LinkGuq giữ RAW backslash NAS — KHÔNG trim/normalize/escape");
row.Status.Should().Be(RowImportStatus.New, "DB rỗng → hàng mới");
}
// ========================================================================
// Case 6b — header sai → từ chối file (LayoutValid=false, Rows rỗng, có cảnh báo).
// ========================================================================
[Fact]
public async Task PreviewAsync_WrongHeader_RejectsFile_LayoutInvalid_NoRows_WithWarning()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var wrongHeader = Enumerable.Range(1, 30).Select(i => $"CỘT SAI {i}").ToArray();
using var stream = BuildWorkbook(wrongHeader, ws =>
{
ws.Cell(5, 4).Value = "X1";
ws.Cell(5, 5).Value = "Sẽ bị bỏ vì layout sai";
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeFalse("fingerprint header không khớp → từ chối cả file");
preview.Rows.Should().BeEmpty("KHÔNG map hàng nào để tránh lệch cột");
preview.Warnings.Should().Contain(w => w.Contains("Sai layout"), "phải nêu lý do sai layout cho user");
}
// ========================================================================
// Case 9 (spec-drift documentation) — 30 token row-4 THẬT của file "Database NCC".
// Khẳng định PreviewAsync ĐỒNG Ý với so-khớp-fingerprint trực tiếp (real vs ExpectedHeaderTokens
// hiện tại). Robust 2 chiều: hiện lệch → LayoutValid=false; sau khi tokens sửa = token thật →
// tự động khẳng định LayoutValid=true (KHÔNG cần sửa test).
// ========================================================================
[Fact]
public async Task PreviewAsync_RealFileRow4Tokens_FingerprintConsistentWithCurrentExpected()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var real = RealFileHeaderTokens();
var expected = ReflectExpectedHeaderTokens();
real.Should().HaveCount(30, "sanity: đúng 30 cột row-4 file thật");
// shouldMatch tính bằng CHÍNH cách service normalize (replicate NormalizeHeader) →
// không thể mis-predict.
bool shouldMatch = NormalizeJoin(real) == NormalizeJoin(expected);
using var stream = BuildWorkbook(real, ws =>
{
ws.Cell(5, 4).Value = "RT-01";
ws.Cell(5, 5).Value = "NCC token thật";
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().Be(shouldMatch,
"PreviewAsync PHẢI đồng ý với so-khớp-fingerprint trực tiếp. Hiện tại ExpectedHeaderTokens là BEST-GUESS "
+ "≠ token thật → shouldMatch=false → LayoutValid=false. Khi close-review sửa ExpectedHeaderTokens = token thật "
+ "→ shouldMatch=true → test tự chuyển sang khẳng định LayoutValid=true (không cần sửa test).");
if (!shouldMatch)
preview.Rows.Should().BeEmpty("layout bị từ chối → không map hàng nào");
}
// ========================================================================
// Case 7 — chỉ số cột tuyệt đối: ô rỗng giữa (FAX col 9) KHÔNG làm dồn cột sau; literal #REF! → null.
// ========================================================================
[Fact]
public async Task PreviewAsync_EmptyMiddleCell_NoColumnDrift_AndErrorLiteralBecomesNull()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
ws.Cell(5, 4).Value = "DRIFT-1";
ws.Cell(5, 5).Value = "NCC drift";
ws.Cell(5, 8).Value = "PHONE-0908"; // col 8 → Phone
// col 9 (FAX) BỎ TRỐNG cố ý
ws.Cell(5, 10).Value = "BANK-ALPHA"; // col 10 → BankAccount
ws.Cell(5, 11).Value = "BANK-BETA"; // col 11 → SecondaryBankAccount
ws.Cell(5, 12).Value = "TAX-3133"; // col 12 → TaxCode
ws.Cell(5, 22).Value = "#REF!"; // col 22 → Email (literal lỗi Excel)
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue();
var row = preview.Rows.Single();
row.Phone.Should().Be("PHONE-0908", "col 8 → Phone");
row.Fax.Should().BeNull("ô FAX (col 9) rỗng → null");
row.BankAccount.Should().Be("BANK-ALPHA", "chỉ số cột tuyệt đối → col 10 KHÔNG dồn trái dù col 9 rỗng");
row.SecondaryBankAccount.Should().Be("BANK-BETA", "col 11 vẫn đúng chỗ");
row.TaxCode.Should().Be("TAX-3133",
"col 12 vẫn rơi đúng TaxCode — không lệch cột (bug-class 'CellsUse() bỏ ô rỗng' đã tránh)");
row.Email.Should().BeNull("literal '#REF!' → null (ErrorLiterals guard)");
}
// ========================================================================
// Helpers
// ========================================================================
// Đọc ExpectedHeaderTokens (private static readonly string[]) qua reflection → dựng header
// đúng-với-service để test valid-layout luôn đồng bộ (kể cả sau khi tokens được sửa).
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 để test đồng bộ header)");
return (string[])f!.GetValue(null)!;
}
// Dựng .xlsx in-memory: 30 header token ở row 4, data từ row 5 (writeData set từng ô tuyệt đối).
private static MemoryStream BuildWorkbook(string[] headerRow4, Action<IXLWorksheet> writeData)
{
var ms = new MemoryStream();
using (var wb = new XLWorkbook())
{
var ws = wb.AddWorksheet("Database NCC");
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;
}
// Replicate y hệt SupplierExcelImportService.NormalizeHeader + join fingerprint → so khớp
// deterministic (Case 9), không mis-predict hành vi service.
private static string NormalizeJoin(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();
}
return string.Join(" | ", tokens.Select(Norm));
}
// 30 token row-4 THẬT của file "Database NCC" (theo brief close-review). EXACT strings.
private static string[] RealFileHeaderTokens() =>
[
"STT", // 1
"GÓI THẦU", // 2
"PHÂN LOẠI (NTP/NCC/Cả hai)", // 3
"TÊN VIẾT TẮT (Dùng trong HĐ)", // 4
"TÊN CÔNG TY (Đầy đủ, đúng pháp lý)", // 5
"ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)", // 6
"ĐỊA CHỈ VĂN PHÒNG (nếu có)", // 7
"SỐ ĐIỆN THOẠI CÔNG TY", // 8
"FAX", // 9
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý)", // 10
"SỐ TK PHỤ (nếu có)", // 11
"MÃ SỐ THUẾ", // 12
"NGƯỜI ĐẠI DIỆN PHÁP LUẬT", // 13
"CHỨC VỤ ĐẠI DIỆN", // 14
"GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)", // 15
"Link GUQ", // 16
"Link GPKD", // 17
"Link HSNL", // 18
"NGƯỜI LIÊN HỆ CHÍNH", // 19
"CHỨC VỤ NGƯỜI LH", // 20
"SĐT CHÍNH", // 21
"EMAIL", // 22
"ĐỊA CHỈ GỬI THƯ", // 23
"NGƯỜI NHẬN THƯ/ SDT", // 24
"NGUỒN GIỚI THIỆU", // 25
"NGƯỜI PHỤ TRÁCH (PMH)", // 26
"TÌNH TRẠNG HIỆN TẠI", // 27
"GHI CHÚ / LÝ DO BLACKLIST", // 28
"NGÀY CẬP NHẬT CUỐI", // 29
"NGƯỜI CẬP NHẬT", // 30
];
}