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