[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

@ -5,9 +5,11 @@ 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.PublishSupplier;
using SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier;
using SolutionErp.Application.Master.Suppliers.Dtos;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplierImportTemplate;
using SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
using SolutionErp.Domain.Master;
@ -18,13 +20,14 @@ namespace SolutionErp.Api.Controllers;
[Authorize]
public class SuppliersController(IMediator mediator) : ControllerBase
{
// published: null = tất cả (admin quản lý thấy cả nháp) · true = chỉ NCC đã công bố (picker/consumer) · false = chỉ nháp.
[HttpGet]
public async Task<ActionResult<PagedResult<SupplierDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] string? sortBy = null, [FromQuery] bool sortDesc = true,
[FromQuery] SupplierType? type = null,
[FromQuery] SupplierType? type = null, [FromQuery] bool? published = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListSuppliersQuery(type) { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
=> Ok(await mediator.Send(new ListSuppliersQuery(type, published) { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplierDto>> Get(Guid id, CancellationToken ct)
@ -59,9 +62,28 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return NoContent();
}
// ========== Import Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked) ==========
// [S113 D3] Công bố / ẩn 1 NCC (import v2). NHÁP (thiếu Mã NCC) → publish tay ở đây.
// KHÔNG dùng UpdateSupplierCommand (blind absolute-set → clobber #73). Body { maNcc?, publish }.
[Authorize(Policy = "Suppliers.Update")]
[HttpPost("{id:guid}/publish")]
public async Task<IActionResult> Publish(Guid id, [FromBody] PublishSupplierBody body, CancellationToken ct)
{
await mediator.Send(new PublishSupplierCommand(id, body.MaNcc, body.Publish), ct);
return NoContent();
}
// ========== Import Excel "Database NCC" (Supplier import v2 — layout-locked ROW 4, 30 cột) ==========
// [S113 D3] authz: policy quyền Suppliers Update (danh mục), khớp FE PermissionGuard (hết 403 non-admin).
// Tải file mẫu (.xlsx trống, 30 token header ROW 4). Single-source ExpectedHeaderTokens (0 drift).
[Authorize(Policy = "Suppliers.Update")]
[HttpGet("import/template")]
public async Task<IActionResult> ImportTemplate(CancellationToken ct)
=> File(await mediator.Send(new GetSupplierImportTemplateQuery(), ct),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Mau-Database-NCC.xlsx");
// Preview: parse + phân loại từng hàng (KHÔNG ghi DB). Header sai layout → LayoutValid=false.
[Authorize(Roles = "Admin,CatalogManager")]
[Authorize(Policy = "Suppliers.Update")]
[HttpPost("import/preview")]
[RequestSizeLimit(25_000_000)]
public async Task<ActionResult<SupplierImportPreviewDto>> ImportPreview(IFormFile file, CancellationToken ct)
@ -72,9 +94,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return Ok(await mediator.Send(new SupplierImportPreviewCommand(stream), ct));
}
// Confirm: ALL-OR-NOTHING upsert (New → insert; existing case-insensitive-Code → fill-nulls).
// Confirm: ALL-OR-NOTHING upsert (dedup MST-primary + Code-backstop; blank Code = draft ẩn).
// Body JSON = { "rows": [ ... ] } (mảng SupplierImportRowDto round-trip từ preview).
[Authorize(Roles = "Admin,CatalogManager")]
[Authorize(Policy = "Suppliers.Update")]
[HttpPost("import/confirm")]
public async Task<ActionResult<SupplierImportResultDto>> ImportConfirm(
[FromBody] SupplierImportConfirmCommand cmd, CancellationToken ct)

View File

@ -112,6 +112,7 @@ public class CreateSupplierCommandHandler : IRequestHandler<CreateSupplierComman
ReferralSource = request.ReferralSource,
OwnerPmh = request.OwnerPmh,
Status = request.Status,
IsPublic = true, // S113: "Thêm NCC" tay luôn có Code (validator NotEmpty) → công bố ngay (R7 badge đúng + hiện trong picker); CHỈ import mới landing nháp
};
_db.Suppliers.Add(entity);
await _db.SaveChangesAsync(ct);

View File

@ -0,0 +1,57 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.Master.Suppliers.Commands.PublishSupplier;
/// <summary>
/// Công bố / ẩn 1 NCC (Supplier import v2, S113). KHÔNG dùng UpdateSupplierCommand (blind absolute-set
/// mọi field → clobber data nháp, gotcha #73). Chỉ set Mã NCC (nếu gửi) + IsPublic.
/// <para>Gate D2: publish=true bắt buộc Code non-empty (thiếu Mã NCC → không công bố được).</para>
/// </summary>
/// <param name="Id">Supplier cần công bố/ẩn.</param>
/// <param name="MaNcc">Mã NCC mới (null = giữ nguyên Code hiện tại). Nếu gửi non-empty → unique-CI-check.</param>
/// <param name="Publish">true = công bố · false = ẩn (đưa về nháp).</param>
public sealed record PublishSupplierCommand(Guid Id, string? MaNcc, bool Publish) : IRequest;
/// <summary>Body cho endpoint POST /suppliers/{id}/publish (Id lấy từ route).</summary>
public sealed record PublishSupplierBody(string? MaNcc, bool Publish);
public sealed class PublishSupplierCommandValidator : AbstractValidator<PublishSupplierCommand>
{
public PublishSupplierCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.MaNcc).MaximumLength(50); // khớp Supplier.Code HasMaxLength(50)
}
}
public sealed class PublishSupplierCommandHandler(IApplicationDbContext db)
: IRequestHandler<PublishSupplierCommand>
{
public async Task Handle(PublishSupplierCommand request, CancellationToken ct)
{
var entity = await db.Suppliers.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Supplier", request.Id);
// Đặt Mã NCC nếu gửi (blank = xóa Code → ẩn). Unique-CI-check chỉ khi non-empty
// (Code="" bị loại khỏi filtered-unique index nên không cần check).
if (request.MaNcc is not null)
{
var newCode = request.MaNcc.Trim();
if (newCode.Length > 0 && newCode != entity.Code &&
await db.Suppliers.AnyAsync(x => x.Code == newCode && x.Id != entity.Id, ct))
throw new ConflictException($"Mã NCC '{newCode}' đã tồn tại.");
entity.Code = newCode;
}
// Gate D2: công bố bắt buộc có Mã NCC.
if (request.Publish && string.IsNullOrWhiteSpace(entity.Code))
throw new ConflictException("Cần Mã NCC để công bố nhà cung cấp.");
entity.IsPublic = request.Publish;
await db.SaveChangesAsync(ct);
}
}

View File

@ -35,4 +35,6 @@ public record SupplierDto(
DateTime? SourceUpdatedAt,
string? SourceUpdatedBy,
DateTime CreatedAt,
DateTime? UpdatedAt);
DateTime? UpdatedAt,
// Publish state (Mig 64, S113) — append CUỐI positional record. false = nháp/ẩn (thiếu Mã NCC).
bool IsPublic);

View File

@ -11,10 +11,10 @@ namespace SolutionErp.Application.Master.Suppliers.Dtos;
/// <summary>Phân loại mỗi hàng sau khi preview classify.</summary>
public enum RowImportStatus
{
New = 0, // Code chưa trong DB (case-insensitive) → sẽ INSERT
Update = 1, // Code đã có (case-insensitive) → sẽ FILL-NULLS (không đè non-null)
New = 0, // MST + Code đều chưa match trong DB → sẽ INSERT (blank Code = draft ẩn)
Update = 1, // match theo MST (ưu tiên) hoặc Code → 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
Error = 3, // thiếu Name → không import được (v2: blank Code KHÔNG còn là lỗi, = draft hợp lệ)
}
/// <summary>
@ -62,6 +62,7 @@ public sealed record SupplierImportRowDto
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")
public bool MstMissing { get; set; } // true khi cột 12 (MST) rỗng — cảnh báo mềm (không chặn), FE render ⚠️
}
/// <summary>Kết quả preview (KHÔNG ghi DB). LayoutValid=false khi header-fingerprint không khớp.</summary>
@ -82,6 +83,7 @@ public sealed record SupplierImportResultDto
public int Inserted { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int DraftCount { get; set; } // số NCC MỚI lưu nháp (blank Code → IsPublic=false) trong lần import này
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

@ -16,9 +16,16 @@ public interface ISupplierExcelImportService
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).
/// ALL-OR-NOTHING: re-validate; nếu có hard-error (thiếu Name) → trả Errors, KHÔNG ghi gì.
/// Ngược lại upsert (dedup MST-primary + Code-backstop; New → insert, blank Code = draft IsPublic=false;
/// match → 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);
/// <summary>
/// Sinh file .xlsx mẫu trống đúng layout (30 token header vào ROW 4, freeze row 4) từ
/// single-source ExpectedHeaderTokens → 0 drift với validator. Người dùng tải về, điền, upload lại.
/// </summary>
byte[] BuildTemplate();
}

View File

@ -23,6 +23,6 @@ public class GetSupplierQueryHandler : IRequestHandler<GetSupplierQuery, Supplie
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);
x.CreatedAt, x.UpdatedAt, x.IsPublic);
}
}

View File

@ -0,0 +1,17 @@
using MediatR;
using SolutionErp.Application.Master.Suppliers.Import;
namespace SolutionErp.Application.Master.Suppliers.Queries.GetSupplierImportTemplate;
/// <summary>
/// Tải file .xlsx mẫu "Database NCC" (30 cột, header ROW 4) — Supplier import v2 (S113).
/// Bytes sinh từ ISupplierExcelImportService.BuildTemplate() (single-source token, 0 drift).
/// </summary>
public sealed record GetSupplierImportTemplateQuery : IRequest<byte[]>;
public sealed class GetSupplierImportTemplateQueryHandler(ISupplierExcelImportService importService)
: IRequestHandler<GetSupplierImportTemplateQuery, byte[]>
{
public Task<byte[]> Handle(GetSupplierImportTemplateQuery request, CancellationToken ct)
=> Task.FromResult(importService.BuildTemplate());
}

View File

@ -7,7 +7,8 @@ using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
public record ListSuppliersQuery(SupplierType? Type = null) : PagedRequest, IRequest<PagedResult<SupplierDto>>;
// Published: null = tất cả (admin quản lý, thấy cả nháp) · true = chỉ NCC đã công bố (picker/consumer) · false = chỉ nháp.
public record ListSuppliersQuery(SupplierType? Type = null, bool? Published = null) : PagedRequest, IRequest<PagedResult<SupplierDto>>;
public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, PagedResult<SupplierDto>>
{
@ -22,6 +23,12 @@ public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, Pag
if (request.Type is not null)
query = query.Where(x => x.Type == request.Type);
if (request.Published is not null)
{
var pub = request.Published.Value; // local → EF dịch được (tránh nullable bool trong expr)
query = query.Where(x => x.IsPublic == pub);
}
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
@ -51,7 +58,7 @@ public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, Pag
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))
x.CreatedAt, x.UpdatedAt, x.IsPublic))
.ToListAsync(ct);
return new PagedResult<SupplierDto>(items, total, request.Page, request.PageSize);

View File

@ -38,4 +38,9 @@ public class Supplier : AuditableEntity
// 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
// ---- Publish state (Supplier import v2, Mig 64 — S113) ----
// Cổng công bố: NCC nhập từ import thiếu Mã NCC → lưu NHÁP (IsPublic=false, ẩn khỏi picker).
// Publish qua PublishSupplierCommand (gate Code non-empty). KHÁC SupplierStatus (business-status c27).
public bool IsPublic { get; set; } // default false; backfill prod = true (Mig 64)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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", // 2PackageCategory
"PHÂN LOẠI (NTP/NCC/Cả hai)", // 3Type
"TÊN VIẾT TẮT (Dùng trong HĐ)", // 4Code (upsert key)
"TÊN CÔNG TY (Đầy đủ, đúng pháp lý)", // 5Name
"ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)", // 6 → Address
"ĐỊA CHỈ VĂN PHÒNG (nếu có)", // 7OfficeAddress
"SỐ ĐIỆN THOẠI CÔNG TY", // 8Phone
"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ó)", // 11SecondaryBankAccount
"MÃ SỐ THUẾ", // 12TaxCode
"NGƯỜI ĐẠI DIỆN PHÁP LUẬT", // 13 → LegalRepresentative
"CHỨC VỤ ĐẠI DIỆN", // 14LegalRepTitle
"GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)", // 15AuthorizationNote
"Link GUQ", // 16 → LinkGuq
"Link GPKD", // 17 → LinkGpkd
"Link HSNL", // 18LinkHsnl
"NGƯỜI LIÊN HỆ CHÍNH", // 19 → ContactPerson
"CHỨC VỤ NGƯỜI LH", // 20 → ContactTitle
"SĐT CHÍNH", // 21ContactPhone
"EMAIL", // 22Email
"ĐỊA CHỈ GỬI THƯ", // 23 → MailingAddress
"NGƯỜI NHẬN THƯ/ SDT", // 24MailRecipient
"NGUỒN GIỚI THIỆU", // 25ReferralSource
"NGƯỜI PHỤ TRÁCH (PMH)", // 26OwnerPmh
"TÌNH TRẠNG HIỆN TẠI", // 27Status
"GHI CHÚ / LÝ DO BLACKLIST", // 28Note
"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)", // 3Type
"TÊN VIẾT TẮT\n(Dùng trong HĐ)", // 4Code (upsert key)
"TÊN CÔNG TY\n(Đầy đủ, đúng pháp lý)", // 5Name
"ĐỊA CHỈ XUẤT HÓA ĐƠN\n(Địa chỉ đăng ký kinh doanh)", // 6Address
"ĐỊA CHỈ VĂN PHÒNG \n(nếu có)", // 7OfficeAddress (trailing-space trước \n)
"SỐ ĐIỆN THOẠI\nCÔNG TY", // 8Phone
"FAX", // 9Fax
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG\n(Đầy đủ, đúng pháp lý)", // 10BankAccount
"SỐ TK PHỤ\n(nếu có)", // 11SecondaryBankAccount
"SỐ THUẾ", // 12TaxCode
"NGƯỜI ĐẠI DIỆN\nPHÁP LUẬT", // 13LegalRepresentative
"CHỨC VỤ\nĐẠI DIỆN", // 14 → LegalRepTitle
"GIẤY ỦY QUYỀN\n(số, ngày, người ủy quyền)", // 15AuthorizationNote
"Link GUQ", // 16LinkGuq
"Link GPKD", // 17 → LinkGpkd
"Link HSNL", // 18 → LinkHsnl
"NGƯỜI LIÊN HỆ\nCHÍNH", // 19ContactPerson
"CHỨC VỤ\nNGƯỜI LH", // 20 → ContactTitle
"SĐT CHÍNH", // 21 → ContactPhone
"EMAIL", // 22Email
"ĐỊA CHỈ GỬI THƯ", // 23MailingAddress
"NGƯỜI NHẬN THƯ/\nSDT", // 24 → MailRecipient
"NGUỒN\nGIỚI THIỆU", // 25ReferralSource
"NGƯỜI PHỤ TRÁCH\n(PMH)", // 26OwnerPmh
"TÌNH TRẠNG\nHIỆN TẠI", // 27Status
"GHI CHÚ / LÝ DO\nBLACKLIST", // 28Note
"NGÀY CẬP NHẬT\nCUỐI", // 29SourceUpdatedAt
"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) &&