[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

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:
pqhuy1987
2026-07-12 16:13:46 +07:00
parent a829d0df99
commit e100ef065b
20 changed files with 8268 additions and 14 deletions

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);