[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,155 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Master.Suppliers.Commands.PublishSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// ============================================================================
// Supplier import v2 (S113) — PublishSupplierCommand + ListSuppliers/GetSupplier IsPublic.
// Publish-gate = SECURITY/correctness (cổng công bố NCC) → test-before-merge (docs/rules.md §7).
// Test theo CODE trên đĩa (S34) — handlers chỉ cần IApplicationDbContext → new trực tiếp với fix.Db.
//
// 🔑 PublishSupplierCommand (KHÔNG dùng UpdateSupplier, tránh #73 clobber):
// - MaNcc gửi (non-null): set Code=Trim; unique-check `x.Code == newCode && x.Id != id` (khi len>0
// & khác Code cũ) → ConflictException nếu trùng.
// - Gate D2: Publish=true ∧ Code rỗng → ConflictException "Cần Mã NCC...".
// - IsPublic = Publish.
//
// ⚠️ Unique-check publish dùng `x.Code == newCode` (DB collation), KHÁC import service
// (OrdinalIgnoreCase in-mem). SQLite BINARY → chỉ bắt trùng ĐÚNG hoa/thường. Prod SQL Server
// CI-collation mới bắt CI-dup. Test T9b dùng trùng ĐÚNG-case (deterministic trên SQLite).
// (Observation, KHÔNG phải bug — prod collation CI xử lý; flag để biết asymmetry.)
// ============================================================================
public class SupplierPublishAndListTests
{
private static Supplier NewSupplier(string code, string name, bool isPublic,
SupplierType type = SupplierType.NhaCungCap) =>
new() { Id = Guid.NewGuid(), Code = code, Name = name, Type = type, IsPublic = isPublic };
// ---- T9 — publish có Mã NCC → set Code + IsPublic=true ----
[Fact]
public async Task PublishSupplier_WithMaNcc_SetsCode_AndIsPublicTrue()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var draft = NewSupplier("", "Draft NCC", isPublic: false); // nháp, chưa có Mã NCC
db.Suppliers.Add(draft);
await db.SaveChangesAsync(CancellationToken.None);
await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: "NEW01", Publish: true), CancellationToken.None);
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == draft.Id);
reloaded.Code.Should().Be("NEW01", "MaNcc gửi → set Code");
reloaded.IsPublic.Should().BeTrue("Publish=true + có Code → công bố");
}
// ---- T9b — publish với Mã NCC trùng (đúng hoa/thường) NCC khác → ConflictException ----
[Fact]
public async Task PublishSupplier_MaNccCollidesExactCaseWithOther_ThrowsConflict_NoChange()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var other = NewSupplier("TAKEN", "Other", isPublic: true);
var draft = NewSupplier("", "Draft", isPublic: false);
db.Suppliers.AddRange(other, draft);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: "TAKEN", Publish: true), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*đã tồn tại*", "Mã NCC trùng NCC khác → chặn (unique-check)");
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == draft.Id);
reloaded.Code.Should().Be("", "throw trước khi gán → draft KHÔNG đổi");
reloaded.IsPublic.Should().BeFalse();
}
// ---- T10 — publish khi Code rỗng (không gửi MaNcc) → ConflictException "Cần Mã NCC" ----
[Fact]
public async Task PublishSupplier_PublishTrue_ButCodeEmpty_ThrowsConflict_CanMaNcc()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var draft = NewSupplier("", "Draft NCC", isPublic: false);
db.Suppliers.Add(draft);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: null, Publish: true), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Cần Mã NCC*", "gate D2: công bố bắt buộc có Mã NCC");
(await db.Suppliers.SingleAsync(x => x.Id == draft.Id)).IsPublic
.Should().BeFalse("gate chặn → vẫn nháp");
}
// ---- T11 — unpublish (Publish=false) LUÔN cho, IsPublic=false, giữ Code ----
[Fact]
public async Task PublishSupplier_Unpublish_AlwaysAllowed_SetsIsPublicFalse_KeepsCode()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pub = NewSupplier("KEEP", "Public NCC", isPublic: true);
db.Suppliers.Add(pub);
await db.SaveChangesAsync(CancellationToken.None);
await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(pub.Id, MaNcc: null, Publish: false), CancellationToken.None);
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == pub.Id);
reloaded.IsPublic.Should().BeFalse("ẩn luôn được, KHÔNG cần Mã NCC");
reloaded.Code.Should().Be("KEEP", "unpublish KHÔNG đụng Code");
}
// ---- T12 — ListSuppliers Published filter: true / false / null=all ----
[Fact]
public async Task ListSuppliers_PublishedFilter_FiltersByIsPublic_NullReturnsAll()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
db.Suppliers.AddRange(
NewSupplier("P1", "Pub 1", isPublic: true),
NewSupplier("P2", "Pub 2", isPublic: true),
NewSupplier("", "Draft 1", isPublic: false));
await db.SaveChangesAsync(CancellationToken.None);
var handler = new ListSuppliersQueryHandler(db);
var published = await handler.Handle(new ListSuppliersQuery(Published: true) { PageSize = 100 }, CancellationToken.None);
published.Total.Should().Be(2, "Published=true → chỉ NCC đã công bố");
published.Items.Should().OnlyContain(x => x.IsPublic);
var drafts = await handler.Handle(new ListSuppliersQuery(Published: false) { PageSize = 100 }, CancellationToken.None);
drafts.Total.Should().Be(1, "Published=false → chỉ nháp");
drafts.Items.Should().OnlyContain(x => !x.IsPublic);
var all = await handler.Handle(new ListSuppliersQuery(Published: null) { PageSize = 100 }, CancellationToken.None);
all.Total.Should().Be(3, "null → tất cả (admin quản lý thấy cả nháp)");
}
// ---- T13 — SupplierDto.IsPublic project đúng (GetSupplier single-item projection) ----
[Fact]
public async Task GetSupplier_ProjectsIsPublic_ForBothPublicAndDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pub = NewSupplier("G1", "Public", isPublic: true);
var draft = NewSupplier("", "Draft", isPublic: false);
db.Suppliers.AddRange(pub, draft);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetSupplierQueryHandler(db);
(await handler.Handle(new GetSupplierQuery(pub.Id), CancellationToken.None)).IsPublic
.Should().BeTrue("DTO project IsPublic=true cho NCC công bố");
(await handler.Handle(new GetSupplierQuery(draft.Id), CancellationToken.None)).IsPublic
.Should().BeFalse("DTO project IsPublic=false cho nháp");
}
}

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",
];
}