[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:
@ -44,7 +44,13 @@ public class SupplierConfiguration : IEntityTypeConfiguration<Supplier>
|
||||
// Import provenance (Supplier Phase B). SourceUpdatedAt = DateTime? (no length). SourceUpdatedBy = text.
|
||||
b.Property(x => x.SourceUpdatedBy).HasMaxLength(200);
|
||||
|
||||
b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 47 (gotcha #57 EXT) — soft-deleted slot reusable, khớp HasQueryFilter !IsDeleted app-check
|
||||
// Publish state (Mig 64, S113) — bool NOT NULL default false. Code IsRequired giữ, nhưng "" hợp lệ
|
||||
// cho draft-row (import thiếu Mã NCC) → unique-index đổi filter loại "" (bên dưới).
|
||||
b.Property(x => x.IsPublic).HasDefaultValue(false);
|
||||
|
||||
// Mig 64: unique Code filtered `[IsDeleted]=0 AND [Code]<>''` — draft-row Code="" coexist, chỉ
|
||||
// enforce unique cho Code non-empty (đã publish/publish-able). Was `[IsDeleted]=0` (Mig 47).
|
||||
b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0 AND [Code] <> ''");
|
||||
b.HasIndex(x => x.Type);
|
||||
|
||||
b.HasQueryFilter(x => !x.IsDeleted);
|
||||
|
||||
@ -2543,6 +2543,7 @@ public static class DbInitializer
|
||||
foreach (var s in suppliersToSeed)
|
||||
{
|
||||
if (existingSupplierCodes.Contains(s.Code)) continue;
|
||||
s.IsPublic = true; // Mig 64 (S113) — sample NCC đều có Code → công bố
|
||||
db.Suppliers.Add(s);
|
||||
addedSuppliers++;
|
||||
}
|
||||
@ -2811,9 +2812,11 @@ public static class DbInitializer
|
||||
if (e.ReferralSource is null && s.ReferralSource is not null) { e.ReferralSource = s.ReferralSource; changed = true; }
|
||||
if (e.OwnerPmh is null && s.OwnerPmh is not null) { e.OwnerPmh = s.OwnerPmh; changed = true; }
|
||||
if (e.Status is null && s.Status is not null) { e.Status = s.Status; changed = true; }
|
||||
if (!e.IsPublic) { e.IsPublic = true; changed = true; } // Mig 64 (S113) — 4 NCC real live, luôn công bố
|
||||
if (changed) filledSuppliers++;
|
||||
continue;
|
||||
}
|
||||
s.IsPublic = true; // Mig 64 (S113) — 4 NCC real đều có Code → công bố
|
||||
db.Suppliers.Add(s);
|
||||
addedSuppliers++;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSupplierPublishState : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Suppliers_Code",
|
||||
table: "Suppliers");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsPublic",
|
||||
table: "Suppliers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// Backfill: NCC hiện có (22 prod) đang live — PE/Contract tham chiếu → công bố hết
|
||||
// (nếu để nháp sẽ ẩn NCC đang dùng + vỡ picker). Idempotent (set 1 mọi hàng).
|
||||
migrationBuilder.Sql("UPDATE Suppliers SET IsPublic = 1;");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suppliers_Code",
|
||||
table: "Suppliers",
|
||||
column: "Code",
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0 AND [Code] <> ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Suppliers_Code",
|
||||
table: "Suppliers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsPublic",
|
||||
table: "Suppliers");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suppliers_Code",
|
||||
table: "Suppliers",
|
||||
column: "Code",
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3266,6 +3266,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("LegalRepTitle")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
@ -3354,7 +3359,7 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
.HasFilter("[IsDeleted] = 0 AND [Code] <> ''");
|
||||
|
||||
b.HasIndex("Type");
|
||||
|
||||
|
||||
@ -32,39 +32,41 @@ public sealed class SupplierExcelImportService(
|
||||
// trong repo lúc scaffold). Đây là BEST-GUESS theo mapping cột. Khi upload thật lần đầu mà báo
|
||||
// "Sai layout", COPY chuỗi "Header nhận được" trong Warnings dán vào đây (mỗi token 1 cột, đúng
|
||||
// 30 phần tử). NormalizeHeader() sẽ upper + gộp-space nên viết thường/HOA đều được.
|
||||
// 30 token row-4 BYTE-EXACT theo file "Database NCC" thật (S113 re-bake, spec §②). Giữ NGUYÊN
|
||||
// ký tự \n trong ô + trailing-space (c7) → single-source cho CẢ validator (NormalizeHeader collapse
|
||||
// \n→space) LẪN BuildTemplate() (ghi \n vào cell = header xuống dòng giống hệt file gốc, 0 drift).
|
||||
private static readonly string[] ExpectedHeaderTokens =
|
||||
{
|
||||
// 30 token row-4 THẬT của file "Database NCC" (bake close-review S112 = RealFileHeaderTokens trong test).
|
||||
"STT", // 1
|
||||
"GÓI THẦU", // 2 → PackageCategory
|
||||
"PHÂN LOẠI (NTP/NCC/Cả hai)", // 3 → Type
|
||||
"TÊN VIẾT TẮT (Dùng trong HĐ)", // 4 → Code (upsert key)
|
||||
"TÊN CÔNG TY (Đầy đủ, đúng pháp lý)", // 5 → Name
|
||||
"ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)", // 6 → Address
|
||||
"ĐỊA CHỈ VĂN PHÒNG (nếu có)", // 7 → OfficeAddress
|
||||
"SỐ ĐIỆN THOẠI CÔNG TY", // 8 → Phone
|
||||
"FAX", // 9 → Fax
|
||||
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý)", // 10 → BankAccount
|
||||
"SỐ TK PHỤ (nếu có)", // 11 → SecondaryBankAccount
|
||||
"MÃ SỐ THUẾ", // 12 → TaxCode
|
||||
"NGƯỜI ĐẠI DIỆN PHÁP LUẬT", // 13 → LegalRepresentative
|
||||
"CHỨC VỤ ĐẠI DIỆN", // 14 → LegalRepTitle
|
||||
"GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)", // 15 → AuthorizationNote
|
||||
"Link GUQ", // 16 → LinkGuq
|
||||
"Link GPKD", // 17 → LinkGpkd
|
||||
"Link HSNL", // 18 → LinkHsnl
|
||||
"NGƯỜI LIÊN HỆ CHÍNH", // 19 → ContactPerson
|
||||
"CHỨC VỤ NGƯỜI LH", // 20 → ContactTitle
|
||||
"SĐT CHÍNH", // 21 → ContactPhone
|
||||
"EMAIL", // 22 → Email
|
||||
"ĐỊA CHỈ GỬI THƯ", // 23 → MailingAddress
|
||||
"NGƯỜI NHẬN THƯ/ SDT", // 24 → MailRecipient
|
||||
"NGUỒN GIỚI THIỆU", // 25 → ReferralSource
|
||||
"NGƯỜI PHỤ TRÁCH (PMH)", // 26 → OwnerPmh
|
||||
"TÌNH TRẠNG HIỆN TẠI", // 27 → Status
|
||||
"GHI CHÚ / LÝ DO BLACKLIST", // 28 → Note
|
||||
"NGÀY CẬP NHẬT CUỐI", // 29 → SourceUpdatedAt
|
||||
"NGƯỜI CẬP NHẬT", // 30 → SourceUpdatedBy
|
||||
"STT", // 1
|
||||
"GÓI THẦU", // 2 → PackageCategory
|
||||
"PHÂN LOẠI\n(NTP/NCC/Cả hai)", // 3 → Type
|
||||
"TÊN VIẾT TẮT\n(Dùng trong HĐ)", // 4 → Code (upsert key)
|
||||
"TÊN CÔNG TY\n(Đầy đủ, đúng pháp lý)", // 5 → Name
|
||||
"ĐỊA CHỈ XUẤT HÓA ĐƠN\n(Địa chỉ đăng ký kinh doanh)", // 6 → Address
|
||||
"ĐỊA CHỈ VĂN PHÒNG \n(nếu có)", // 7 → OfficeAddress (trailing-space trước \n)
|
||||
"SỐ ĐIỆN THOẠI\nCÔNG TY", // 8 → Phone
|
||||
"FAX", // 9 → Fax
|
||||
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG\n(Đầy đủ, đúng pháp lý)", // 10 → BankAccount
|
||||
"SỐ TK PHỤ\n(nếu có)", // 11 → SecondaryBankAccount
|
||||
"MÃ SỐ THUẾ", // 12 → TaxCode
|
||||
"NGƯỜI ĐẠI DIỆN\nPHÁP LUẬT", // 13 → LegalRepresentative
|
||||
"CHỨC VỤ\nĐẠI DIỆN", // 14 → LegalRepTitle
|
||||
"GIẤY ỦY QUYỀN\n(số, ngày, người ủy quyền)", // 15 → AuthorizationNote
|
||||
"Link GUQ", // 16 → LinkGuq
|
||||
"Link GPKD", // 17 → LinkGpkd
|
||||
"Link HSNL", // 18 → LinkHsnl
|
||||
"NGƯỜI LIÊN HỆ\nCHÍNH", // 19 → ContactPerson
|
||||
"CHỨC VỤ\nNGƯỜI LH", // 20 → ContactTitle
|
||||
"SĐT CHÍNH", // 21 → ContactPhone
|
||||
"EMAIL", // 22 → Email
|
||||
"ĐỊA CHỈ GỬI THƯ", // 23 → MailingAddress
|
||||
"NGƯỜI NHẬN THƯ/\nSDT", // 24 → MailRecipient
|
||||
"NGUỒN\nGIỚI THIỆU", // 25 → ReferralSource
|
||||
"NGƯỜI PHỤ TRÁCH\n(PMH)", // 26 → OwnerPmh
|
||||
"TÌNH TRẠNG\nHIỆN TẠI", // 27 → Status
|
||||
"GHI CHÚ / LÝ DO\nBLACKLIST", // 28 → Note
|
||||
"NGÀY CẬP NHẬT\nCUỐI", // 29 → SourceUpdatedAt
|
||||
"NGƯỜI CẬP NHẬT", // 30 → SourceUpdatedBy
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ErrorLiterals = new(StringComparer.OrdinalIgnoreCase)
|
||||
@ -90,28 +92,44 @@ public sealed class SupplierExcelImportService(
|
||||
var preview = new SupplierImportPreviewDto { Warnings = warnings, LayoutValid = layoutValid };
|
||||
if (!layoutValid) return preview; // file bị từ chối (sai layout) — Rows rỗng
|
||||
|
||||
// Load TẤT CẢ NCC hiện có → CI dict (collation-independent, dataset nhỏ ~vài chục). Chống critical-bug.
|
||||
// Load TẤT CẢ NCC hiện có → 2 CI dict (MST + Code). Dataset nhỏ ~vài chục, collation-independent.
|
||||
var existing = await db.Suppliers.AsNoTracking().ToListAsync(ct);
|
||||
var existingByCode = BuildCiIndex(existing);
|
||||
var existingByMst = BuildMstIndex(existing);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (IsRowEmpty(row)) { row.Status = RowImportStatus.Skip; continue; }
|
||||
|
||||
var codeKey = row.Code?.Trim();
|
||||
var nameBlank = string.IsNullOrWhiteSpace(row.Name);
|
||||
if (string.IsNullOrWhiteSpace(codeKey) || nameBlank)
|
||||
// v2: CHỈ Name bắt buộc. Blank Code KHÔNG còn là lỗi (= draft ẩn, publish tay sau).
|
||||
if (string.IsNullOrWhiteSpace(row.Name))
|
||||
{
|
||||
row.Status = RowImportStatus.Error;
|
||||
if (string.IsNullOrWhiteSpace(codeKey)) row.Messages.Add("Thiếu 'Tên viết tắt' (Code) — bắt buộc.");
|
||||
if (nameBlank) row.Messages.Add("Thiếu 'Tên nhà cung cấp' (Name) — bắt buộc.");
|
||||
row.Messages.Add("Thiếu 'Tên nhà cung cấp' (Name) — bắt buộc.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingByCode.TryGetValue(codeKey, out var ex))
|
||||
var mstKey = NormalizeMst(row.TaxCode);
|
||||
if (mstKey is null)
|
||||
{
|
||||
row.MstMissing = true; // cảnh báo MỀM — KHÔNG chặn (R6 mềm)
|
||||
row.Messages.Add("⚠️ Chưa có MST — không kiểm tra được trùng, có thể bị trùng.");
|
||||
}
|
||||
|
||||
var codeKey = string.IsNullOrWhiteSpace(row.Code) ? null : row.Code!.Trim();
|
||||
if (codeKey is null)
|
||||
row.Messages.Add("Chưa nhập Mã NCC — sẽ lưu nháp (ẩn), công bố sau.");
|
||||
|
||||
// Dedup precedence: MST (ưu tiên) → Code (backstop) → New.
|
||||
if (mstKey is not null && existingByMst.TryGetValue(mstKey, out var exM))
|
||||
{
|
||||
row.Status = RowImportStatus.Update;
|
||||
row.ExistingSupplierId = ex.Id;
|
||||
row.ExistingSupplierId = exM.Id;
|
||||
}
|
||||
else if (codeKey is not null && existingByCode.TryGetValue(codeKey, out var exC))
|
||||
{
|
||||
row.Status = RowImportStatus.Update;
|
||||
row.ExistingSupplierId = exC.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -135,65 +153,93 @@ public sealed class SupplierExcelImportService(
|
||||
{
|
||||
var result = new SupplierImportResultDto();
|
||||
|
||||
// Pass 1 — hard-error scan (Code/Name). Hàng rỗng = skip (không lỗi). Bất kỳ hard-error → abort.
|
||||
// Pass 1 — hard-error scan (CHỈ Name; v2 blank Code = draft hợp lệ). Hàng rỗng = skip. Bất kỳ lỗi → abort.
|
||||
var hardErrors = new List<string>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (IsRowEmpty(row)) continue;
|
||||
var missing = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(row.Code)) missing.Add("Tên viết tắt");
|
||||
if (string.IsNullOrWhiteSpace(row.Name)) missing.Add("Tên nhà cung cấp");
|
||||
if (missing.Count > 0) hardErrors.Add($"Dòng {row.RowIndex}: thiếu {string.Join(" + ", missing)}.");
|
||||
if (string.IsNullOrWhiteSpace(row.Name))
|
||||
hardErrors.Add($"Dòng {row.RowIndex}: thiếu Tên nhà cung cấp.");
|
||||
}
|
||||
if (hardErrors.Count > 0)
|
||||
{
|
||||
result.Errors = hardErrors;
|
||||
result.Committed = false; // commit nothing
|
||||
result.Committed = false; // commit nothing (all-or-nothing)
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load existing TRACKED (để fill-nulls mutate trực tiếp). Query filter loại IsDeleted.
|
||||
// Load existing TRACKED (fill-nulls mutate trực tiếp). Query filter loại IsDeleted. 2 index: MST + Code.
|
||||
var existing = await db.Suppliers.ToListAsync(ct);
|
||||
var existingByCode = BuildCiIndex(existing);
|
||||
var existingByMst = BuildMstIndex(existing);
|
||||
var batchByCode = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var batchByMst = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
|
||||
var countedUpdates = new HashSet<Supplier>(); // existing đã tính Updated → tránh đếm 2 lần khi 2 dòng cùng match
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (IsRowEmpty(row)) { result.Skipped++; continue; }
|
||||
|
||||
NormalizeLengths(row); // safety-net: clamp về maxlen (chống 500 nếu client bỏ qua preview)
|
||||
var key = row.Code!.Trim();
|
||||
NormalizeLengths(row); // safety-net: clamp maxlen (chống 500 nếu client bỏ qua preview)
|
||||
var codeKey = string.IsNullOrWhiteSpace(row.Code) ? null : row.Code!.Trim();
|
||||
var mstKey = NormalizeMst(row.TaxCode);
|
||||
|
||||
if (existingByCode.TryGetValue(key, out var ex))
|
||||
// Resolve target theo precedence: existing-MST → existing-Code → batch-MST → batch-Code.
|
||||
Supplier? target = null;
|
||||
var isExisting = false;
|
||||
if (mstKey is not null && existingByMst.TryGetValue(mstKey, out var exM)) { target = exM; isExisting = true; }
|
||||
else if (codeKey is not null && existingByCode.TryGetValue(codeKey, out var exC)) { target = exC; isExisting = true; }
|
||||
else if (mstKey is not null && batchByMst.TryGetValue(mstKey, out var bM)) { target = bM; }
|
||||
else if (codeKey is not null && batchByCode.TryGetValue(codeKey, out var bC)) { target = bC; }
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
FillNulls(ex, row);
|
||||
if (seen.Add(key)) result.Updated++;
|
||||
else result.Skipped++; // Code trùng lần 2 trong file → gộp fill-nulls, không đếm lại
|
||||
continue;
|
||||
}
|
||||
if (batchByCode.TryGetValue(key, out var added))
|
||||
{
|
||||
FillNulls(added, row);
|
||||
result.Skipped++; // Code trùng bản ghi vừa thêm trong batch
|
||||
FillNulls(target, row); // #73-safe: chỉ điền field đang null, KHÔNG đè non-null (không đụng IsPublic)
|
||||
if (isExisting)
|
||||
{
|
||||
if (countedUpdates.Add(target)) result.Updated++;
|
||||
else result.Skipped++; // existing đã tính Updated ở dòng trước → gộp, không đếm lại
|
||||
}
|
||||
else result.Skipped++; // gộp vào bản ghi vừa thêm trong batch
|
||||
continue;
|
||||
}
|
||||
|
||||
// New. Blank Code (thiếu Mã NCC) → lưu NHÁP IsPublic=false (R4); có Code → public-able.
|
||||
var entity = NewSupplier(row);
|
||||
entity.IsPublic = !string.IsNullOrWhiteSpace(row.Code);
|
||||
db.Suppliers.Add(entity);
|
||||
batchByCode[key] = entity;
|
||||
seen.Add(key);
|
||||
if (codeKey is not null) batchByCode[codeKey] = entity;
|
||||
if (mstKey is not null) batchByMst[mstKey] = entity; // blank-MST KHÔNG vào batchByMst → 2 blank-MST = 2 insert
|
||||
result.Inserted++;
|
||||
if (!entity.IsPublic) result.DraftCount++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct); // single atomic commit (interceptor set CreatedBy/UpdatedBy)
|
||||
result.Committed = true;
|
||||
logger.LogInformation(
|
||||
"Supplier Excel import by {Actor}: inserted={Inserted}, updated={Updated}, skipped={Skipped}",
|
||||
actor, result.Inserted, result.Updated, result.Skipped);
|
||||
"Supplier Excel import by {Actor}: inserted={Inserted}, updated={Updated}, skipped={Skipped}, draft={Draft}",
|
||||
actor, result.Inserted, result.Updated, result.Skipped, result.DraftCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TEMPLATE — .xlsx mẫu trống (30 token header ROW 4, freeze row 4). Single-source ExpectedHeaderTokens (0 drift).
|
||||
// ========================================================================
|
||||
public byte[] BuildTemplate()
|
||||
{
|
||||
using var wb = new XLWorkbook();
|
||||
var ws = wb.Worksheets.Add("Sheet1");
|
||||
for (int c = 1; c <= ColumnCount; c++)
|
||||
ws.Cell(HeaderRow, c).Value = ExpectedHeaderTokens[c - 1]; // ROW 4; row 1-3 để trống (giống file gốc)
|
||||
var header = ws.Row(HeaderRow);
|
||||
header.Style.Font.Bold = true;
|
||||
header.Style.Alignment.WrapText = true; // \n trong ô hiện xuống dòng
|
||||
ws.SheetView.FreezeRows(HeaderRow);
|
||||
using var ms = new MemoryStream();
|
||||
wb.SaveAs(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PARSE
|
||||
// ========================================================================
|
||||
@ -413,7 +459,7 @@ public sealed class SupplierExcelImportService(
|
||||
|
||||
private static Supplier NewSupplier(SupplierImportRowDto r) => new()
|
||||
{
|
||||
Code = r.Code!.Trim(), // store trimmed, giữ hoa/thường gốc để hiển thị
|
||||
Code = (r.Code ?? string.Empty).Trim(), // null-safe (S113): ô Mã NCC trống → parser null → "" (nháp R4), tránh NRE-abort-batch; hoa/thường gốc giữ
|
||||
Name = r.Name!,
|
||||
Type = r.Type,
|
||||
TaxCode = r.TaxCode,
|
||||
@ -450,12 +496,34 @@ public sealed class SupplierExcelImportService(
|
||||
foreach (var s in suppliers)
|
||||
{
|
||||
var key = s.Code?.Trim();
|
||||
if (string.IsNullOrEmpty(key)) continue;
|
||||
if (string.IsNullOrEmpty(key)) continue; // Code="" (draft) KHÔNG index → không dedup theo Code rỗng
|
||||
dict[key] = s; // last-wins; dup CI không xảy ra do filtered-unique index
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
// MST index cho dedup MST-primary (R5). NormalizeMst = Trim + bỏ MỌI whitespace nội, CI.
|
||||
// Blank MST → KHÔNG index (2 NCC không-MST không coi là trùng nhau).
|
||||
private static Dictionary<string, Supplier> BuildMstIndex(IEnumerable<Supplier> suppliers)
|
||||
{
|
||||
var dict = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var s in suppliers)
|
||||
{
|
||||
var key = NormalizeMst(s.TaxCode);
|
||||
if (key is null) continue;
|
||||
dict[key] = s; // last-wins (TaxCode KHÔNG có unique constraint — dup hiếm, không crash)
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
// Chuẩn hóa MST: "0312 251 859" → "0312251859" (khớp dù file ghi có/không dấu cách). null nếu rỗng.
|
||||
private static string? NormalizeMst(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var stripped = Regex.Replace(raw, @"\s+", "");
|
||||
return stripped.Length == 0 ? null : stripped;
|
||||
}
|
||||
|
||||
private static bool IsRowEmpty(SupplierImportRowDto r) =>
|
||||
string.IsNullOrWhiteSpace(r.Code) && string.IsNullOrWhiteSpace(r.Name) &&
|
||||
string.IsNullOrWhiteSpace(r.PackageCategory) && string.IsNullOrWhiteSpace(r.Address) &&
|
||||
|
||||
Reference in New Issue
Block a user