[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:
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 có 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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user