[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:
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier;
|
||||
using SolutionErp.Application.Master.Suppliers.Commands.DeleteSupplier;
|
||||
using SolutionErp.Application.Master.Suppliers.Commands.ImportSuppliers;
|
||||
using SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier;
|
||||
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
|
||||
@ -57,4 +58,25 @@ public class SuppliersController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new DeleteSupplierCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ========== Import Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked) ==========
|
||||
// Preview: parse + phân loại từng hàng (KHÔNG ghi DB). Header sai layout → LayoutValid=false.
|
||||
[Authorize(Roles = "Admin,CatalogManager")]
|
||||
[HttpPost("import/preview")]
|
||||
[RequestSizeLimit(25_000_000)]
|
||||
public async Task<ActionResult<SupplierImportPreviewDto>> ImportPreview(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { detail = "Chưa chọn file." });
|
||||
await using var stream = file.OpenReadStream();
|
||||
return Ok(await mediator.Send(new SupplierImportPreviewCommand(stream), ct));
|
||||
}
|
||||
|
||||
// Confirm: ALL-OR-NOTHING upsert (New → insert; existing case-insensitive-Code → fill-nulls).
|
||||
// Body JSON = { "rows": [ ... ] } (mảng SupplierImportRowDto round-trip từ preview).
|
||||
[Authorize(Roles = "Admin,CatalogManager")]
|
||||
[HttpPost("import/confirm")]
|
||||
public async Task<ActionResult<SupplierImportResultDto>> ImportConfirm(
|
||||
[FromBody] SupplierImportConfirmCommand cmd, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(cmd, ct));
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
using MediatR;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||
using SolutionErp.Application.Master.Suppliers.Import;
|
||||
|
||||
namespace SolutionErp.Application.Master.Suppliers.Commands.ImportSuppliers;
|
||||
|
||||
/// <summary>
|
||||
/// Commit các hàng đã preview (ALL-OR-NOTHING). Body JSON = { "rows": [ ... ] }.
|
||||
/// Actor resolve từ ICurrentUser (chỉ để log — audit CreatedBy/UpdatedBy do interceptor set).
|
||||
/// </summary>
|
||||
public sealed record SupplierImportConfirmCommand(IReadOnlyList<SupplierImportRowDto> Rows)
|
||||
: IRequest<SupplierImportResultDto>;
|
||||
|
||||
public sealed class SupplierImportConfirmCommandHandler(
|
||||
ISupplierExcelImportService importService,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<SupplierImportConfirmCommand, SupplierImportResultDto>
|
||||
{
|
||||
public Task<SupplierImportResultDto> Handle(SupplierImportConfirmCommand request, CancellationToken ct)
|
||||
{
|
||||
var actor = currentUser.FullName
|
||||
?? currentUser.Email
|
||||
?? currentUser.UserId?.ToString()
|
||||
?? "unknown";
|
||||
return importService.ConfirmAsync(request.Rows ?? Array.Empty<SupplierImportRowDto>(), actor, ct);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
using MediatR;
|
||||
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||
using SolutionErp.Application.Master.Suppliers.Import;
|
||||
|
||||
namespace SolutionErp.Application.Master.Suppliers.Commands.ImportSuppliers;
|
||||
|
||||
/// <summary>
|
||||
/// Preview import NCC từ file Excel (KHÔNG ghi DB). Stream do controller mở từ IFormFile
|
||||
/// (mirror UploadPurchaseEvaluationAttachmentCommand — command mang Stream).
|
||||
/// </summary>
|
||||
public sealed record SupplierImportPreviewCommand(Stream Xlsx) : IRequest<SupplierImportPreviewDto>;
|
||||
|
||||
public sealed class SupplierImportPreviewCommandHandler(ISupplierExcelImportService importService)
|
||||
: IRequestHandler<SupplierImportPreviewCommand, SupplierImportPreviewDto>
|
||||
{
|
||||
public Task<SupplierImportPreviewDto> Handle(SupplierImportPreviewCommand request, CancellationToken ct)
|
||||
=> importService.PreviewAsync(request.Xlsx, ct);
|
||||
}
|
||||
@ -31,5 +31,8 @@ public record SupplierDto(
|
||||
string? ReferralSource,
|
||||
string? OwnerPmh,
|
||||
SupplierStatus? Status,
|
||||
// Import provenance (Supplier Phase B — cột 29-30 file Excel nguồn), KHÁC audit CreatedAt/UpdatedAt.
|
||||
DateTime? SourceUpdatedAt,
|
||||
string? SourceUpdatedBy,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
using SolutionErp.Domain.Master;
|
||||
|
||||
namespace SolutionErp.Application.Master.Suppliers.Dtos;
|
||||
|
||||
// ============================================================================
|
||||
// Supplier Phase B — Upload Excel "Database NCC" (Approach A, layout-locked).
|
||||
// DTOs shared giữa preview + confirm. Records có property SETTABLE vì hàng import
|
||||
// được parse → classify → round-trip FE → confirm (mutable trong toàn pipeline).
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>Phân loại mỗi hàng sau khi preview classify.</summary>
|
||||
public enum RowImportStatus
|
||||
{
|
||||
New = 0, // Code chưa có trong DB (case-insensitive) → sẽ INSERT
|
||||
Update = 1, // Code đã có (case-insensitive) → sẽ FILL-NULLS (không đè non-null)
|
||||
Skip = 2, // hàng rỗng hoàn toàn → bỏ qua
|
||||
Error = 3, // thiếu Code hoặc Name → không import được
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1 hàng Excel đã parse + map sang field Supplier (27) + provenance (2) + chẩn đoán.
|
||||
/// <c>Status</c> = phân loại hàng (RowImportStatus). <c>SupplierStatus</c> = tình trạng NCC (cột 27).
|
||||
/// </summary>
|
||||
public sealed record SupplierImportRowDto
|
||||
{
|
||||
public int RowIndex { get; set; } // số dòng thật trên Excel (1-based)
|
||||
|
||||
// ---- 27 field map sang Supplier (thứ tự theo cột Excel) ----
|
||||
public string? PackageCategory { get; set; } // col 2
|
||||
public SupplierType Type { get; set; } = SupplierType.NhaCungCap; // col 3 (unknown → NhaCungCap, decision 5)
|
||||
public string? Code { get; set; } // col 4 "TÊN VIẾT TẮT" (upsert key, Trim)
|
||||
public string? Name { get; set; } // col 5
|
||||
public string? Address { get; set; } // col 6
|
||||
public string? OfficeAddress { get; set; } // col 7
|
||||
public string? Phone { get; set; } // col 8
|
||||
public string? Fax { get; set; } // col 9
|
||||
public string? BankAccount { get; set; } // col 10 (raw composite)
|
||||
public string? SecondaryBankAccount { get; set; } // col 11
|
||||
public string? TaxCode { get; set; } // col 12 (raw, nullable)
|
||||
public string? LegalRepresentative { get; set; } // col 13
|
||||
public string? LegalRepTitle { get; set; } // col 14
|
||||
public string? AuthorizationNote { get; set; } // col 15
|
||||
public string? LinkGuq { get; set; } // col 16 (raw NAS backslash)
|
||||
public string? LinkGpkd { get; set; } // col 17
|
||||
public string? LinkHsnl { get; set; } // col 18
|
||||
public string? ContactPerson { get; set; } // col 19
|
||||
public string? ContactTitle { get; set; } // col 20
|
||||
public string? ContactPhone { get; set; } // col 21
|
||||
public string? Email { get; set; } // col 22
|
||||
public string? MailingAddress { get; set; } // col 23
|
||||
public string? MailRecipient { get; set; } // col 24 (raw composite)
|
||||
public string? ReferralSource { get; set; } // col 25
|
||||
public string? OwnerPmh { get; set; } // col 26
|
||||
public SupplierStatus? SupplierStatus { get; set; } // col 27 (unknown → null)
|
||||
public string? Note { get; set; } // col 28
|
||||
|
||||
// ---- 2 import-provenance ----
|
||||
public DateTime? SourceUpdatedAt { get; set; } // col 29 (null nếu không parse được ngày)
|
||||
public string? SourceUpdatedBy { get; set; } // col 30
|
||||
|
||||
// ---- classify + diagnostics ----
|
||||
public RowImportStatus Status { get; set; }
|
||||
public List<string> Messages { get; set; } = new(); // cảnh báo truncate / default type / lý do lỗi
|
||||
public Guid? ExistingSupplierId { get; set; } // set khi Status=Update (hỗ trợ FE hiển thị "sẽ cập nhật")
|
||||
}
|
||||
|
||||
/// <summary>Kết quả preview (KHÔNG ghi DB). LayoutValid=false khi header-fingerprint không khớp.</summary>
|
||||
public sealed record SupplierImportPreviewDto
|
||||
{
|
||||
public List<SupplierImportRowDto> Rows { get; set; } = new();
|
||||
public int NewCount { get; set; }
|
||||
public int UpdateCount { get; set; }
|
||||
public int SkipCount { get; set; }
|
||||
public int ErrorCount { get; set; }
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public bool LayoutValid { get; set; } = true; // false → file bị từ chối (sai layout), Rows rỗng
|
||||
}
|
||||
|
||||
/// <summary>Kết quả confirm. Committed=false + Errors khi có hard-error (all-or-nothing, không ghi gì).</summary>
|
||||
public sealed record SupplierImportResultDto
|
||||
{
|
||||
public int Inserted { get; set; }
|
||||
public int Updated { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public bool Committed { get; set; } // true nếu đã SaveChanges; false nếu abort do hard-error
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||
|
||||
namespace SolutionErp.Application.Master.Suppliers.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Import NCC từ file Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked).
|
||||
/// Impl ở Infrastructure (ClosedXML). Inject IApplicationDbContext để testable.
|
||||
/// </summary>
|
||||
public interface ISupplierExcelImportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse + validate + classify từng hàng (KHÔNG ghi DB). Match Code case-insensitive
|
||||
/// (SQL Server unique-index CI) → existing = Update, mới = New, thiếu Code/Name = Error,
|
||||
/// hàng rỗng = Skip. Header sai layout → PreviewDto.LayoutValid=false.
|
||||
/// </summary>
|
||||
Task<SupplierImportPreviewDto> PreviewAsync(Stream xlsx, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// ALL-OR-NOTHING: re-validate; nếu có hard-error (thiếu Code/Name) → trả Errors, KHÔNG ghi gì.
|
||||
/// Ngược lại upsert (New → insert; existing case-insensitive-Code → fill-nulls-only) rồi
|
||||
/// SaveChanges 1 lần. <paramref name="actor"/> chỉ dùng để log (audit thật do interceptor set).
|
||||
/// </summary>
|
||||
Task<SupplierImportResultDto> ConfirmAsync(IReadOnlyList<SupplierImportRowDto> rows, string actor, CancellationToken ct = default);
|
||||
}
|
||||
@ -22,6 +22,7 @@ public class GetSupplierQueryHandler : IRequestHandler<GetSupplierQuery, Supplie
|
||||
x.PackageCategory, x.OfficeAddress, x.MailingAddress, x.Fax, x.BankAccount, x.SecondaryBankAccount,
|
||||
x.LegalRepresentative, x.LegalRepTitle, x.AuthorizationNote, x.LinkGuq, x.LinkGpkd, x.LinkHsnl,
|
||||
x.ContactTitle, x.ContactPhone, x.MailRecipient, x.ReferralSource, x.OwnerPmh, x.Status,
|
||||
x.SourceUpdatedAt, x.SourceUpdatedBy,
|
||||
x.CreatedAt, x.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,7 @@ public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, Pag
|
||||
x.PackageCategory, x.OfficeAddress, x.MailingAddress, x.Fax, x.BankAccount, x.SecondaryBankAccount,
|
||||
x.LegalRepresentative, x.LegalRepTitle, x.AuthorizationNote, x.LinkGuq, x.LinkGpkd, x.LinkHsnl,
|
||||
x.ContactTitle, x.ContactPhone, x.MailRecipient, x.ReferralSource, x.OwnerPmh, x.Status,
|
||||
x.SourceUpdatedAt, x.SourceUpdatedBy,
|
||||
x.CreatedAt, x.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
||||
@ -33,4 +33,9 @@ public class Supplier : AuditableEntity
|
||||
public string? ReferralSource { get; set; } // Nguồn giới thiệu
|
||||
public string? OwnerPmh { get; set; } // Người phụ trách (PMH)
|
||||
public SupplierStatus? Status { get; set; } // Tình trạng hiện tại (nullable = chưa phân loại)
|
||||
|
||||
// ---- Import provenance (Supplier Phase B — Upload Excel "Database NCC") ----
|
||||
// Nguồn cập nhật từ file Excel gốc (cột 29-30), KHÁC audit CreatedAt/UpdatedBy (do hệ thống set).
|
||||
public DateTime? SourceUpdatedAt { get; set; } // "NGÀY CẬP NHẬT CUỐI" trên file Excel nguồn
|
||||
public string? SourceUpdatedBy { get; set; } // "NGƯỜI CẬP NHẬT" trên file Excel nguồn
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Hrm.Services;
|
||||
using SolutionErp.Application.Master.Suppliers.Import;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
@ -41,6 +42,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<IChangelogService, ChangelogService>();
|
||||
services.AddScoped<ISupplierExcelImportService, SupplierExcelImportService>();
|
||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||
|
||||
// Phase 3 iteration 2 — SLA auto-approve background service
|
||||
|
||||
@ -41,6 +41,9 @@ public class SupplierConfiguration : IEntityTypeConfiguration<Supplier>
|
||||
b.Property(x => x.OwnerPmh).HasMaxLength(150);
|
||||
b.Property(x => x.Status).HasConversion<int>(); // nullable int enum
|
||||
|
||||
// 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
|
||||
b.HasIndex(x => x.Type);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSupplierImportSourceFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SourceUpdatedAt",
|
||||
table: "Suppliers",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SourceUpdatedBy",
|
||||
table: "Suppliers",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceUpdatedAt",
|
||||
table: "Suppliers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceUpdatedBy",
|
||||
table: "Suppliers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3327,6 +3327,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime?>("SourceUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("SourceUpdatedBy")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int?>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
@ -0,0 +1,474 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Master.Suppliers.Dtos;
|
||||
using SolutionErp.Application.Master.Suppliers.Import;
|
||||
using SolutionErp.Domain.Master;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Import NCC từ Excel "Database NCC" (Supplier Phase B, Approach A). Layout-locked THEO FILE NÀY.
|
||||
/// ClosedXML (0.105, đã có). Đọc theo CHỈ SỐ CỘT TUYỆT ĐỐI (row.Cell(i)) — KHÔNG CellsUsed()
|
||||
/// (bỏ ô rỗng → lệch cột). Header row = 4, data từ row 5.
|
||||
///
|
||||
/// 🔴 CRITICAL: dedup Code phải CASE-INSENSITIVE (unique-index SQL Server = CI). Nếu so Ordinal,
|
||||
/// "truonggiang" (file) vs "TRUONGGIANG" (DB) → miss → quyết INSERT → confirm nổ unique-violation
|
||||
/// → 500 cả batch. Dùng StringComparer.OrdinalIgnoreCase ở CẢ preview + confirm; store Code = .Trim()
|
||||
/// giữ nguyên hoa/thường để hiển thị.
|
||||
/// </summary>
|
||||
public sealed class SupplierExcelImportService(
|
||||
IApplicationDbContext db,
|
||||
ILogger<SupplierExcelImportService> logger) : ISupplierExcelImportService
|
||||
{
|
||||
private const int HeaderRow = 4;
|
||||
private const int DataStartRow = 5;
|
||||
private const int ColumnCount = 30;
|
||||
|
||||
// ⚠️⚠️ MUST-VERIFY: header row-4 chuẩn của file "Database NCC" thật (đang ở máy anh Kiệt, KHÔNG
|
||||
// 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.
|
||||
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
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ErrorLiterals = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ "#REF!", "#N/A", "#VALUE!", "#DIV/0!", "#NAME?", "#NULL!", "#NUM!" };
|
||||
|
||||
private static readonly string[] DateFormats =
|
||||
{ "dd/MM/yyyy", "d/M/yyyy", "dd/MM/yyyy HH:mm", "yyyy-MM-dd", "yyyy/MM/dd", "dd-MM-yyyy", "d-M-yyyy", "MM/dd/yyyy" };
|
||||
|
||||
// MaxLength per field (khớp SupplierConfiguration — EF = source of truth).
|
||||
private const int MaxCode = 50, MaxName = 200, MaxTaxCode = 20, MaxPhone = 30, MaxEmail = 100,
|
||||
MaxAddress = 500, MaxContactPerson = 200, MaxNote = 1000, MaxPackageCategory = 300,
|
||||
MaxOfficeAddress = 500, MaxMailingAddress = 500, MaxFax = 50, MaxBankAccount = 500,
|
||||
MaxSecondaryBankAccount = 500, MaxLegalRepresentative = 200, MaxLegalRepTitle = 150,
|
||||
MaxAuthorizationNote = 1000, MaxLink = 1000, MaxContactTitle = 150, MaxContactPhone = 50,
|
||||
MaxMailRecipient = 200, MaxReferralSource = 300, MaxOwnerPmh = 150, MaxSourceUpdatedBy = 200;
|
||||
|
||||
// ========================================================================
|
||||
// PREVIEW — parse + validate + classify. KHÔNG ghi DB.
|
||||
// ========================================================================
|
||||
public async Task<SupplierImportPreviewDto> PreviewAsync(Stream xlsx, CancellationToken ct = default)
|
||||
{
|
||||
var (layoutValid, rows, warnings) = ParseWorkbook(xlsx);
|
||||
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.
|
||||
var existing = await db.Suppliers.AsNoTracking().ToListAsync(ct);
|
||||
var existingByCode = BuildCiIndex(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)
|
||||
{
|
||||
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.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingByCode.TryGetValue(codeKey, out var ex))
|
||||
{
|
||||
row.Status = RowImportStatus.Update;
|
||||
row.ExistingSupplierId = ex.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Status = RowImportStatus.New;
|
||||
}
|
||||
}
|
||||
|
||||
preview.Rows = rows;
|
||||
preview.NewCount = rows.Count(x => x.Status == RowImportStatus.New);
|
||||
preview.UpdateCount = rows.Count(x => x.Status == RowImportStatus.Update);
|
||||
preview.SkipCount = rows.Count(x => x.Status == RowImportStatus.Skip);
|
||||
preview.ErrorCount = rows.Count(x => x.Status == RowImportStatus.Error);
|
||||
return preview;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CONFIRM — ALL-OR-NOTHING. Re-validate → hard-error abort / else upsert + 1 SaveChanges.
|
||||
// ========================================================================
|
||||
public async Task<SupplierImportResultDto> ConfirmAsync(
|
||||
IReadOnlyList<SupplierImportRowDto> rows, string actor, CancellationToken ct = default)
|
||||
{
|
||||
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.
|
||||
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 (hardErrors.Count > 0)
|
||||
{
|
||||
result.Errors = hardErrors;
|
||||
result.Committed = false; // commit nothing
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load existing TRACKED (để fill-nulls mutate trực tiếp). Query filter loại IsDeleted.
|
||||
var existing = await db.Suppliers.ToListAsync(ct);
|
||||
var existingByCode = BuildCiIndex(existing);
|
||||
var batchByCode = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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();
|
||||
|
||||
if (existingByCode.TryGetValue(key, out var ex))
|
||||
{
|
||||
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
|
||||
continue;
|
||||
}
|
||||
|
||||
var entity = NewSupplier(row);
|
||||
db.Suppliers.Add(entity);
|
||||
batchByCode[key] = entity;
|
||||
seen.Add(key);
|
||||
result.Inserted++;
|
||||
}
|
||||
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PARSE
|
||||
// ========================================================================
|
||||
private static (bool LayoutValid, List<SupplierImportRowDto> Rows, List<string> Warnings) ParseWorkbook(Stream xlsx)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
XLWorkbook wb;
|
||||
try { wb = new XLWorkbook(xlsx); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add("Không đọc được file (.xlsx). Chi tiết: " + ex.Message);
|
||||
return (false, new List<SupplierImportRowDto>(), warnings);
|
||||
}
|
||||
|
||||
using (wb)
|
||||
{
|
||||
IXLWorksheet ws;
|
||||
try { ws = wb.Worksheet(1); }
|
||||
catch { warnings.Add("File Excel không có sheet nào."); return (false, new(), warnings); }
|
||||
|
||||
// Header fingerprint — row 4, 30 cột tuyệt đối. Mismatch → từ chối (KHÔNG map để tránh sai cột).
|
||||
var actualTokens = new string[ColumnCount];
|
||||
for (int c = 1; c <= ColumnCount; c++)
|
||||
actualTokens[c - 1] = NormalizeHeader(ws.Cell(HeaderRow, c).GetString());
|
||||
var actual = string.Join(" | ", actualTokens);
|
||||
var expected = string.Join(" | ", ExpectedHeaderTokens.Select(NormalizeHeader));
|
||||
|
||||
if (!string.Equals(actual, expected, StringComparison.Ordinal))
|
||||
{
|
||||
warnings.Add("Sai layout Excel — từ chối file (không map để tránh sai cột). "
|
||||
+ "Nếu đây LÀ file chuẩn, cập nhật ExpectedHeaderTokens theo 'Header nhận được' dưới đây.");
|
||||
warnings.Add("Header nhận được: " + actual);
|
||||
warnings.Add("Header mong đợi: " + expected);
|
||||
return (false, new(), warnings);
|
||||
}
|
||||
|
||||
var rows = new List<SupplierImportRowDto>();
|
||||
int lastRow = ws.LastRowUsed()?.RowNumber() ?? (DataStartRow - 1);
|
||||
for (int r = DataStartRow; r <= lastRow; r++)
|
||||
rows.Add(MapRow(ws, r));
|
||||
return (true, rows, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static SupplierImportRowDto MapRow(IXLWorksheet ws, int r)
|
||||
{
|
||||
var m = new List<string>();
|
||||
return new SupplierImportRowDto
|
||||
{
|
||||
RowIndex = r,
|
||||
PackageCategory = Clamp(ReadRaw(ws, r, 2), MaxPackageCategory, "Gói thầu", r, m),
|
||||
Type = MapType(ReadRaw(ws, r, 3), r, m),
|
||||
Code = Clamp(ReadRaw(ws, r, 4)?.Trim(), MaxCode, "Tên viết tắt", r, m),
|
||||
Name = Clamp(ReadRaw(ws, r, 5), MaxName, "Tên NCC", r, m),
|
||||
Address = Clamp(ReadRaw(ws, r, 6), MaxAddress, "Địa chỉ", r, m),
|
||||
OfficeAddress = Clamp(ReadRaw(ws, r, 7), MaxOfficeAddress, "Địa chỉ VP", r, m),
|
||||
Phone = Clamp(ReadRaw(ws, r, 8), MaxPhone, "Điện thoại", r, m),
|
||||
Fax = Clamp(ReadRaw(ws, r, 9), MaxFax, "Fax", r, m),
|
||||
BankAccount = Clamp(ReadRaw(ws, r, 10), MaxBankAccount, "Số TK", r, m),
|
||||
SecondaryBankAccount = Clamp(ReadRaw(ws, r, 11), MaxSecondaryBankAccount, "Số TK phụ", r, m),
|
||||
TaxCode = Clamp(ReadRaw(ws, r, 12), MaxTaxCode, "MST", r, m),
|
||||
LegalRepresentative = Clamp(ReadRaw(ws, r, 13), MaxLegalRepresentative, "Người đại diện", r, m),
|
||||
LegalRepTitle = Clamp(ReadRaw(ws, r, 14), MaxLegalRepTitle, "Chức vụ đại diện", r, m),
|
||||
AuthorizationNote = Clamp(ReadRaw(ws, r, 15), MaxAuthorizationNote, "Giấy ủy quyền", r, m),
|
||||
LinkGuq = Clamp(ReadRaw(ws, r, 16), MaxLink, "Link GUQ", r, m),
|
||||
LinkGpkd = Clamp(ReadRaw(ws, r, 17), MaxLink, "Link GPKD", r, m),
|
||||
LinkHsnl = Clamp(ReadRaw(ws, r, 18), MaxLink, "Link HSNL", r, m),
|
||||
ContactPerson = Clamp(ReadRaw(ws, r, 19), MaxContactPerson, "Người liên hệ", r, m),
|
||||
ContactTitle = Clamp(ReadRaw(ws, r, 20), MaxContactTitle, "Chức vụ liên hệ", r, m),
|
||||
ContactPhone = Clamp(ReadRaw(ws, r, 21), MaxContactPhone, "SĐT liên hệ", r, m),
|
||||
Email = Clamp(ReadRaw(ws, r, 22), MaxEmail, "Email", r, m),
|
||||
MailingAddress = Clamp(ReadRaw(ws, r, 23), MaxMailingAddress, "Địa chỉ gửi thư", r, m),
|
||||
MailRecipient = Clamp(ReadRaw(ws, r, 24), MaxMailRecipient, "Người nhận thư", r, m),
|
||||
ReferralSource = Clamp(ReadRaw(ws, r, 25), MaxReferralSource, "Nguồn giới thiệu", r, m),
|
||||
OwnerPmh = Clamp(ReadRaw(ws, r, 26), MaxOwnerPmh, "Phụ trách", r, m),
|
||||
SupplierStatus = MapStatus(ReadRaw(ws, r, 27)),
|
||||
Note = Clamp(ReadRaw(ws, r, 28), MaxNote, "Ghi chú", r, m),
|
||||
SourceUpdatedAt = ReadDate(ws, r, 29),
|
||||
SourceUpdatedBy = Clamp(ReadRaw(ws, r, 30), MaxSourceUpdatedBy, "Người cập nhật", r, m),
|
||||
Messages = m,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// CELL READERS — absolute index, error-literal → null, giữ RAW (không normalize) cho path/composite.
|
||||
// ========================================================================
|
||||
private static string? ReadRaw(IXLWorksheet ws, int row, int col)
|
||||
{
|
||||
var cell = ws.Cell(row, col);
|
||||
if (cell.IsEmpty()) return null;
|
||||
if (cell.DataType == XLDataType.Error) return null;
|
||||
var s = cell.GetString(); // auto-decode shared-string
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
if (ErrorLiterals.Contains(s.Trim())) return null; // literal #REF! / #N/A / ...
|
||||
return s; // RAW: giữ NAS backslash + composite (không trim/upper)
|
||||
}
|
||||
|
||||
private static DateTime? ReadDate(IXLWorksheet ws, int row, int col)
|
||||
{
|
||||
var cell = ws.Cell(row, col);
|
||||
if (cell.IsEmpty() || cell.DataType == XLDataType.Error) return null;
|
||||
if (cell.DataType == XLDataType.DateTime && cell.TryGetValue<DateTime>(out var dt)) return dt;
|
||||
var s = cell.GetString();
|
||||
if (string.IsNullOrWhiteSpace(s) || ErrorLiterals.Contains(s.Trim())) return null;
|
||||
s = s.Trim();
|
||||
if (DateTime.TryParseExact(s, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var ex)) return ex;
|
||||
if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var iv)) return iv;
|
||||
return null; // unparseable → null (decision)
|
||||
}
|
||||
|
||||
private static string NormalizeHeader(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
var collapsed = Regex.Replace(raw.Replace("\n", " ").Replace("\r", " "), @"\s+", " ");
|
||||
// .Normalize(FormC) — de-risk file Excel thật lưu dấu tiếng Việt dạng NFD (close-review S112):
|
||||
// đưa cả header kỳ-vọng lẫn header-thật về cùng dạng canonical trước khi so Ordinal.
|
||||
return collapsed.Trim().ToUpperInvariant().Normalize(System.Text.NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// FIELD MAPPING
|
||||
// ========================================================================
|
||||
private static SupplierType MapType(string? raw, int rowIndex, List<string> messages)
|
||||
{
|
||||
var t = (raw ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (t.Length == 0) return SupplierType.NhaCungCap; // trống → default (decision 5), im lặng
|
||||
if (t.Contains("cả hai") || t.Contains("ca hai") || t.Contains("ntp/ncc") || t.Contains("ncc/ntp"))
|
||||
return SupplierType.CaHai;
|
||||
if (t == "ntp" || t.Contains("thầu phụ") || t.Contains("thau phu"))
|
||||
return SupplierType.NhaThauPhu;
|
||||
if (t == "ncc" || t.Contains("cung cấp") || t.Contains("cung cap"))
|
||||
return SupplierType.NhaCungCap;
|
||||
messages.Add($"Dòng {rowIndex}: PHÂN LOẠI '{raw}' không nhận dạng → mặc định NCC.");
|
||||
return SupplierType.NhaCungCap; // unknown → default (decision 5, không reject)
|
||||
}
|
||||
|
||||
private static SupplierStatus? MapStatus(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var s = StripLeadingNonLetter(raw).Trim().ToLowerInvariant();
|
||||
if (s.Length == 0) return null;
|
||||
if (s.Contains("đang hoạt động") || s.Contains("dang hoat dong") || s.Contains("hoạt động") || s.Contains("hoat dong"))
|
||||
return SupplierStatus.DangHoatDong;
|
||||
if (s.Contains("blacklist") || s.Contains("black"))
|
||||
return SupplierStatus.Blacklist;
|
||||
if (s.Contains("ngừng") || s.Contains("ngung"))
|
||||
return SupplierStatus.NgungHopTac;
|
||||
return null; // unknown → null (decision, không reject)
|
||||
}
|
||||
|
||||
private static string StripLeadingNonLetter(string v)
|
||||
{
|
||||
int i = 0;
|
||||
while (i < v.Length && !char.IsLetter(v[i])) i++; // char.IsLetter true cho chữ Việt Unicode
|
||||
return v.Substring(i);
|
||||
}
|
||||
|
||||
private static string? Clamp(string? v, int max, string field, int rowIndex, List<string> messages)
|
||||
{
|
||||
if (v is null || v.Length <= max) return v;
|
||||
messages.Add($"Dòng {rowIndex}: '{field}' dài {v.Length} ký tự > {max} — đã cắt bớt.");
|
||||
return v.Substring(0, max);
|
||||
}
|
||||
|
||||
private static void NormalizeLengths(SupplierImportRowDto r)
|
||||
{
|
||||
r.Code = Cut(r.Code, MaxCode); r.Name = Cut(r.Name, MaxName); r.TaxCode = Cut(r.TaxCode, MaxTaxCode);
|
||||
r.Phone = Cut(r.Phone, MaxPhone); r.Email = Cut(r.Email, MaxEmail); r.Address = Cut(r.Address, MaxAddress);
|
||||
r.ContactPerson = Cut(r.ContactPerson, MaxContactPerson); r.Note = Cut(r.Note, MaxNote);
|
||||
r.PackageCategory = Cut(r.PackageCategory, MaxPackageCategory); r.OfficeAddress = Cut(r.OfficeAddress, MaxOfficeAddress);
|
||||
r.MailingAddress = Cut(r.MailingAddress, MaxMailingAddress); r.Fax = Cut(r.Fax, MaxFax);
|
||||
r.BankAccount = Cut(r.BankAccount, MaxBankAccount); r.SecondaryBankAccount = Cut(r.SecondaryBankAccount, MaxSecondaryBankAccount);
|
||||
r.LegalRepresentative = Cut(r.LegalRepresentative, MaxLegalRepresentative); r.LegalRepTitle = Cut(r.LegalRepTitle, MaxLegalRepTitle);
|
||||
r.AuthorizationNote = Cut(r.AuthorizationNote, MaxAuthorizationNote); r.LinkGuq = Cut(r.LinkGuq, MaxLink);
|
||||
r.LinkGpkd = Cut(r.LinkGpkd, MaxLink); r.LinkHsnl = Cut(r.LinkHsnl, MaxLink);
|
||||
r.ContactTitle = Cut(r.ContactTitle, MaxContactTitle); r.ContactPhone = Cut(r.ContactPhone, MaxContactPhone);
|
||||
r.MailRecipient = Cut(r.MailRecipient, MaxMailRecipient); r.ReferralSource = Cut(r.ReferralSource, MaxReferralSource);
|
||||
r.OwnerPmh = Cut(r.OwnerPmh, MaxOwnerPmh); r.SourceUpdatedBy = Cut(r.SourceUpdatedBy, MaxSourceUpdatedBy);
|
||||
}
|
||||
|
||||
private static string? Cut(string? v, int max) => v is not null && v.Length > max ? v.Substring(0, max) : v;
|
||||
|
||||
// ========================================================================
|
||||
// UPSERT HELPERS
|
||||
// ========================================================================
|
||||
// FILL-NULLS (decision 4): chỉ set field ĐANG NULL trong DB, KHÔNG bao giờ đè non-null.
|
||||
// Bỏ Code/Name/Type (identity/required non-null) — mirror DbInitializer fill-nulls pattern.
|
||||
private static void FillNulls(Supplier e, SupplierImportRowDto r)
|
||||
{
|
||||
if (e.TaxCode is null && r.TaxCode is not null) e.TaxCode = r.TaxCode;
|
||||
if (e.Phone is null && r.Phone is not null) e.Phone = r.Phone;
|
||||
if (e.Email is null && r.Email is not null) e.Email = r.Email;
|
||||
if (e.Address is null && r.Address is not null) e.Address = r.Address;
|
||||
if (e.ContactPerson is null && r.ContactPerson is not null) e.ContactPerson = r.ContactPerson;
|
||||
if (e.Note is null && r.Note is not null) e.Note = r.Note;
|
||||
if (e.PackageCategory is null && r.PackageCategory is not null) e.PackageCategory = r.PackageCategory;
|
||||
if (e.OfficeAddress is null && r.OfficeAddress is not null) e.OfficeAddress = r.OfficeAddress;
|
||||
if (e.MailingAddress is null && r.MailingAddress is not null) e.MailingAddress = r.MailingAddress;
|
||||
if (e.Fax is null && r.Fax is not null) e.Fax = r.Fax;
|
||||
if (e.BankAccount is null && r.BankAccount is not null) e.BankAccount = r.BankAccount;
|
||||
if (e.SecondaryBankAccount is null && r.SecondaryBankAccount is not null) e.SecondaryBankAccount = r.SecondaryBankAccount;
|
||||
if (e.LegalRepresentative is null && r.LegalRepresentative is not null) e.LegalRepresentative = r.LegalRepresentative;
|
||||
if (e.LegalRepTitle is null && r.LegalRepTitle is not null) e.LegalRepTitle = r.LegalRepTitle;
|
||||
if (e.AuthorizationNote is null && r.AuthorizationNote is not null) e.AuthorizationNote = r.AuthorizationNote;
|
||||
if (e.LinkGuq is null && r.LinkGuq is not null) e.LinkGuq = r.LinkGuq;
|
||||
if (e.LinkGpkd is null && r.LinkGpkd is not null) e.LinkGpkd = r.LinkGpkd;
|
||||
if (e.LinkHsnl is null && r.LinkHsnl is not null) e.LinkHsnl = r.LinkHsnl;
|
||||
if (e.ContactTitle is null && r.ContactTitle is not null) e.ContactTitle = r.ContactTitle;
|
||||
if (e.ContactPhone is null && r.ContactPhone is not null) e.ContactPhone = r.ContactPhone;
|
||||
if (e.MailRecipient is null && r.MailRecipient is not null) e.MailRecipient = r.MailRecipient;
|
||||
if (e.ReferralSource is null && r.ReferralSource is not null) e.ReferralSource = r.ReferralSource;
|
||||
if (e.OwnerPmh is null && r.OwnerPmh is not null) e.OwnerPmh = r.OwnerPmh;
|
||||
if (e.Status is null && r.SupplierStatus is not null) e.Status = r.SupplierStatus;
|
||||
if (e.SourceUpdatedAt is null && r.SourceUpdatedAt is not null) e.SourceUpdatedAt = r.SourceUpdatedAt;
|
||||
if (e.SourceUpdatedBy is null && r.SourceUpdatedBy is not null) e.SourceUpdatedBy = r.SourceUpdatedBy;
|
||||
}
|
||||
|
||||
private static Supplier NewSupplier(SupplierImportRowDto r) => new()
|
||||
{
|
||||
Code = r.Code!.Trim(), // store trimmed, giữ hoa/thường gốc để hiển thị
|
||||
Name = r.Name!,
|
||||
Type = r.Type,
|
||||
TaxCode = r.TaxCode,
|
||||
Phone = r.Phone,
|
||||
Email = r.Email,
|
||||
Address = r.Address,
|
||||
ContactPerson = r.ContactPerson,
|
||||
Note = r.Note,
|
||||
PackageCategory = r.PackageCategory,
|
||||
OfficeAddress = r.OfficeAddress,
|
||||
MailingAddress = r.MailingAddress,
|
||||
Fax = r.Fax,
|
||||
BankAccount = r.BankAccount,
|
||||
SecondaryBankAccount = r.SecondaryBankAccount,
|
||||
LegalRepresentative = r.LegalRepresentative,
|
||||
LegalRepTitle = r.LegalRepTitle,
|
||||
AuthorizationNote = r.AuthorizationNote,
|
||||
LinkGuq = r.LinkGuq,
|
||||
LinkGpkd = r.LinkGpkd,
|
||||
LinkHsnl = r.LinkHsnl,
|
||||
ContactTitle = r.ContactTitle,
|
||||
ContactPhone = r.ContactPhone,
|
||||
MailRecipient = r.MailRecipient,
|
||||
ReferralSource = r.ReferralSource,
|
||||
OwnerPmh = r.OwnerPmh,
|
||||
Status = r.SupplierStatus,
|
||||
SourceUpdatedAt = r.SourceUpdatedAt,
|
||||
SourceUpdatedBy = r.SourceUpdatedBy,
|
||||
};
|
||||
|
||||
private static Dictionary<string, Supplier> BuildCiIndex(IEnumerable<Supplier> suppliers)
|
||||
{
|
||||
var dict = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var s in suppliers)
|
||||
{
|
||||
var key = s.Code?.Trim();
|
||||
if (string.IsNullOrEmpty(key)) continue;
|
||||
dict[key] = s; // last-wins; dup CI không xảy ra do filtered-unique index
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static bool IsRowEmpty(SupplierImportRowDto r) =>
|
||||
string.IsNullOrWhiteSpace(r.Code) && string.IsNullOrWhiteSpace(r.Name) &&
|
||||
string.IsNullOrWhiteSpace(r.PackageCategory) && string.IsNullOrWhiteSpace(r.Address) &&
|
||||
string.IsNullOrWhiteSpace(r.OfficeAddress) && string.IsNullOrWhiteSpace(r.Phone) &&
|
||||
string.IsNullOrWhiteSpace(r.Fax) && string.IsNullOrWhiteSpace(r.BankAccount) &&
|
||||
string.IsNullOrWhiteSpace(r.SecondaryBankAccount) && string.IsNullOrWhiteSpace(r.TaxCode) &&
|
||||
string.IsNullOrWhiteSpace(r.LegalRepresentative) && string.IsNullOrWhiteSpace(r.LegalRepTitle) &&
|
||||
string.IsNullOrWhiteSpace(r.AuthorizationNote) && string.IsNullOrWhiteSpace(r.LinkGuq) &&
|
||||
string.IsNullOrWhiteSpace(r.LinkGpkd) && string.IsNullOrWhiteSpace(r.LinkHsnl) &&
|
||||
string.IsNullOrWhiteSpace(r.ContactPerson) && string.IsNullOrWhiteSpace(r.ContactTitle) &&
|
||||
string.IsNullOrWhiteSpace(r.ContactPhone) && string.IsNullOrWhiteSpace(r.Email) &&
|
||||
string.IsNullOrWhiteSpace(r.MailingAddress) && string.IsNullOrWhiteSpace(r.MailRecipient) &&
|
||||
string.IsNullOrWhiteSpace(r.ReferralSource) && string.IsNullOrWhiteSpace(r.OwnerPmh) &&
|
||||
string.IsNullOrWhiteSpace(r.Note) && string.IsNullOrWhiteSpace(r.SourceUpdatedBy) &&
|
||||
r.SupplierStatus is null && r.SourceUpdatedAt is null;
|
||||
}
|
||||
Reference in New Issue
Block a user