[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
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:
@ -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
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user