V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,224 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using Npgsql;
using Shared.Excel;
using Kbx.Contracts.Generated;
namespace Modules.OMS.Orders.Import;
public sealed class OrderImportDefinition(NpgsqlDataSource dataSource) : IImportDefinition
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public ImportDefinition Definition { get; } = new(
Id: "oms.orders.v1",
ScreenId: "OMS-ORD-003",
Entity: "order",
Title: "주문 Excel 업로드",
Fields: [
I(KbxFieldKeys.OrderNo),
I(KbxFieldKeys.OrderDate),
I(KbxFieldKeys.CustomerCode),
I(KbxFieldKeys.WarehouseCode),
I(KbxFieldKeys.ReceiverName),
I(KbxFieldKeys.Phone),
I(KbxFieldKeys.PostalCode),
I(KbxFieldKeys.Address1),
I(KbxFieldKeys.Address2),
I(KbxFieldKeys.ItemCode),
I(KbxFieldKeys.OrderQty),
I(KbxFieldKeys.UnitPrice),
I(KbxFieldKeys.Remark),
],
AllowCreate: true,
AllowUpdate: true,
MaxFileSizeBytes: 20 * 1024 * 1024,
MaxRows: 100_000);
public async Task<IReadOnlyList<ImportRowValidation>> ValidateAsync(
IReadOnlyList<ImportRawRow> rows,
IReadOnlyDictionary<string, string> targetToSource,
NpgsqlConnection connection,
CancellationToken ct)
{
var customerCodes = Values(rows, targetToSource, KbxFieldKeys.CustomerCode);
var warehouseCodes = Values(rows, targetToSource, KbxFieldKeys.WarehouseCode);
var itemCodes = Values(rows, targetToSource, KbxFieldKeys.ItemCode);
var orderNos = Values(rows, targetToSource, KbxFieldKeys.OrderNo);
var customers = (await connection.QueryAsync<RefRow>(new CommandDefinition(
"select id,code from oms.customers where is_active and code = any(@Codes)",
new { Codes = customerCodes }, cancellationToken: ct))).ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
var warehouses = (await connection.QueryAsync<RefRow>(new CommandDefinition(
"select id,code from inventory.warehouses where is_active and code = any(@Codes)",
new { Codes = warehouseCodes }, cancellationToken: ct))).ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
var items = (await connection.QueryAsync<RefRow>(new CommandDefinition(
"select id,code from catalog.items where is_active and code = any(@Codes)",
new { Codes = itemCodes }, cancellationToken: ct))).ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
var existing = (await connection.QueryAsync<ExistingOrder>(new CommandDefinition(
"""
select id, order_no as "OrderNo", status
from oms.orders
where order_no = any(@OrderNos)
""",
new { OrderNos = orderNos }, cancellationToken: ct))).ToDictionary(x => x.OrderNo, StringComparer.OrdinalIgnoreCase);
var results = new List<ImportRowValidation>(rows.Count);
foreach (var row in rows)
results.Add(ValidateRow(row, targetToSource, customers, warehouses, items, existing));
// One order can span multiple Excel rows. Header values must be identical across its lines.
foreach (var group in results.Where(x => x.NormalizedData is not null && x.DomainKey is not null).GroupBy(x => x.DomainKey!, StringComparer.OrdinalIgnoreCase))
{
var signatures = group.Select(x => HeaderSignature(x.NormalizedData!.RootElement)).Distinct(StringComparer.Ordinal).ToArray();
if (signatures.Length <= 1) continue;
foreach (var row in group)
{
var issue = new ImportIssue(row.RowNumber, null, null, "ORDER_HEADER_CONFLICT", "같은 주문번호의 주문일·거래처·창고·배송정보가 서로 다릅니다.", "error");
var index = results.IndexOf(row);
results[index] = row with { Errors = row.Errors.Append(issue).ToArray() };
}
}
return results;
}
public async Task<ImportCommitResult> CommitAsync(Guid sessionId, string actor, CancellationToken ct)
{
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var staged = (await readConnection.QueryAsync<StagedRow>(new CommandDefinition("""
select row_number as "RowNumber", domain_key as "DomainKey", normalized_data::text as "NormalizedJson"
from kbx.import_rows
where session_id=@SessionId and status='VALID'
order by domain_key,row_number;
""", new { SessionId = sessionId }, cancellationToken: ct))).ToArray();
var created = 0; var updated = 0; var failed = 0;
foreach (var group in staged.GroupBy(x => x.DomainKey, StringComparer.OrdinalIgnoreCase))
{
var lines = group.Select(x => JsonSerializer.Deserialize<NormalizedOrderRow>(x.NormalizedJson, Json)!).ToArray();
try
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(ct);
var existing = await connection.QuerySingleOrDefaultAsync<ExistingOrder>(new CommandDefinition(
"""
select id, order_no as "OrderNo", status
from oms.orders
where order_no=@OrderNo
for update
""",
new { OrderNo = group.Key }, tx, cancellationToken: ct));
Guid orderId;
var header = lines[0];
if (existing is null)
{
orderId = Guid.NewGuid();
await connection.ExecuteAsync(new CommandDefinition("""
insert into oms.orders(id,order_no,order_date,customer_id,warehouse_id,receiver_name,phone,postal_code,address1,address2,status,version,created_at,created_by)
values(@Id,@OrderNo,@OrderDate,@CustomerId,@WarehouseId,@ReceiverName,@Phone,@PostalCode,@Address1,@Address2,'NEW',1,now(),@Actor);
""", new { Id=orderId, header.OrderNo, header.OrderDate, header.CustomerId, header.WarehouseId, header.ReceiverName, header.Phone, header.PostalCode, header.Address1, header.Address2, Actor=actor }, tx, cancellationToken: ct));
created++;
}
else
{
if (existing.Status is not ("NEW" or "DRAFT")) throw new InvalidOperationException($"{existing.OrderNo} 주문은 현재 상태에서 수정할 수 없습니다.");
orderId = existing.Id;
await connection.ExecuteAsync(new CommandDefinition("""
update oms.orders set order_date=@OrderDate,customer_id=@CustomerId,warehouse_id=@WarehouseId,
receiver_name=@ReceiverName,phone=@Phone,postal_code=@PostalCode,address1=@Address1,address2=@Address2,
version=version+1,updated_at=now(),updated_by=@Actor where id=@Id;
delete from oms.order_lines where order_id=@Id;
""", new { Id=orderId, header.OrderDate, header.CustomerId, header.WarehouseId, header.ReceiverName, header.Phone, header.PostalCode, header.Address1, header.Address2, Actor=actor }, tx, cancellationToken: ct));
updated++;
}
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
await connection.ExecuteAsync(new CommandDefinition("""
insert into oms.order_lines(id,order_id,line_no,item_id,quantity,unit_price,amount,remark)
values(@Id,@OrderId,@LineNo,@ItemId,@Quantity,@UnitPrice,@Amount,@Remark);
""", new { Id=Guid.NewGuid(), OrderId=orderId, LineNo=i+1, line.ItemId, Quantity=line.OrderQty, line.UnitPrice, Amount=line.OrderQty*line.UnitPrice, line.Remark }, tx, cancellationToken: ct));
}
await connection.ExecuteAsync(new CommandDefinition("""
update kbx.import_rows set status='COMMITTED'
where session_id=@SessionId and domain_key=@OrderNo and status='VALID';
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
values(@AuditId,'Order',@OrderId,'ExcelImport',@Actor,now(),jsonb_build_object('sessionId',@SessionText,'orderNo',@OrderNo));
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
values(@EventId,'OrderImported',@OrderId,jsonb_build_object('sessionId',@SessionText,'orderId',@OrderText,'orderNo',@OrderNo),now(),'PENDING');
""", new { SessionId=sessionId, SessionText=sessionId.ToString(), OrderNo=group.Key, AuditId=Guid.NewGuid(), EventId=Guid.NewGuid(), OrderId=orderId, OrderText=orderId.ToString(), Actor=actor }, tx, cancellationToken: ct));
await tx.CommitAsync(ct);
}
catch (Exception ex)
{
failed += group.Count();
await readConnection.ExecuteAsync(new CommandDefinition("""
update kbx.import_rows set status='COMMIT_FAILED', errors = errors || jsonb_build_array(jsonb_build_object(
'rowNumber',row_number,'code','COMMIT_FAILED','message',@Message,'severity','error'))
where session_id=@SessionId and domain_key=@OrderNo and status='VALID';
""", new { SessionId=sessionId, OrderNo=group.Key, Message=ex.Message }, cancellationToken: ct));
}
}
return new ImportCommitResult(created, updated, failed);
}
private static ImportRowValidation ValidateRow(
ImportRawRow row,
IReadOnlyDictionary<string,string> map,
IReadOnlyDictionary<string,RefRow> customers,
IReadOnlyDictionary<string,RefRow> warehouses,
IReadOnlyDictionary<string,RefRow> items,
IReadOnlyDictionary<string,ExistingOrder> existing)
{
var errors = new List<ImportIssue>();
string? V(string key) => map.TryGetValue(key, out var column) && row.Values.TryGetValue(column, out var value) ? value?.Trim() : null;
void Required(string key, string label, string? value) { if (string.IsNullOrWhiteSpace(value)) errors.Add(E(row.RowNumber,key,map,label + "을(를) 입력하세요.")); }
var orderNo=V(KbxFieldKeys.OrderNo); var customerCode=V(KbxFieldKeys.CustomerCode); var warehouseCode=V(KbxFieldKeys.WarehouseCode); var itemCode=V(KbxFieldKeys.ItemCode);
var receiver=V(KbxFieldKeys.ReceiverName); var phone=V(KbxFieldKeys.Phone); var address1=V(KbxFieldKeys.Address1);
Required("orderNo","주문번호",orderNo); Required("customerCode","거래처코드",customerCode); Required("warehouseCode","출고창고",warehouseCode);
Required("itemCode","품목코드",itemCode); Required("receiverName","수취인",receiver); Required("phone","연락처",phone); Required("address1","주소",address1);
if (!TryDate(V(KbxFieldKeys.OrderDate), out var orderDate)) errors.Add(E(row.RowNumber,KbxFieldKeys.OrderDate,map,"주문일 형식을 확인하세요. (yyyy-MM-dd 또는 yyyyMMdd)"));
if (!TryDecimal(V(KbxFieldKeys.OrderQty), out var orderQty) || orderQty <= 0) errors.Add(E(row.RowNumber,KbxFieldKeys.OrderQty,map,"수량은 0보다 커야 합니다."));
if (!TryDecimal(V(KbxFieldKeys.UnitPrice), out var unitPrice) || unitPrice < 0) errors.Add(E(row.RowNumber,KbxFieldKeys.UnitPrice,map,"단가는 0 이상이어야 합니다."));
customers.TryGetValue(customerCode ?? "", out var customer);
warehouses.TryGetValue(warehouseCode ?? "", out var warehouse);
items.TryGetValue(itemCode ?? "", out var item);
if (!string.IsNullOrWhiteSpace(customerCode) && customer is null) errors.Add(E(row.RowNumber,"customerCode",map,$"존재하지 않거나 사용중지된 거래처코드입니다: {customerCode}"));
if (!string.IsNullOrWhiteSpace(warehouseCode) && warehouse is null) errors.Add(E(row.RowNumber,"warehouseCode",map,$"존재하지 않거나 사용중지된 창고코드입니다: {warehouseCode}"));
if (!string.IsNullOrWhiteSpace(itemCode) && item is null) errors.Add(E(row.RowNumber,"itemCode",map,$"존재하지 않거나 사용중지된 품목코드입니다: {itemCode}"));
if (!string.IsNullOrWhiteSpace(orderNo) && existing.TryGetValue(orderNo, out var old) && old.Status is not ("NEW" or "DRAFT"))
errors.Add(E(row.RowNumber,"orderNo",map,$"{old.Status} 상태의 기존 주문은 Excel로 수정할 수 없습니다."));
JsonDocument? normalized = null;
if (errors.Count == 0)
{
normalized = JsonSerializer.SerializeToDocument(new NormalizedOrderRow(
orderNo!, orderDate, customer!.Id, customerCode!, warehouse!.Id, warehouseCode!, receiver!, phone!, V(KbxFieldKeys.PostalCode), address1!, V(KbxFieldKeys.Address2), item!.Id, itemCode!, orderQty, unitPrice, V(KbxFieldKeys.Remark)), Json);
}
return new(row.RowNumber, row.Values, orderNo, normalized, errors, []);
}
private static ImportIssue E(int row,string field,IReadOnlyDictionary<string,string> map,string message) =>
new(row,field,map.TryGetValue(field,out var source)?source:null,"INVALID_VALUE",message,"error");
private static bool TryDate(string? value, out DateOnly result) =>
DateOnly.TryParseExact(value, ["yyyy-MM-dd","yyyyMMdd","yyyy/M/d"], CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
private static bool TryDecimal(string? value, out decimal result) =>
decimal.TryParse(value?.Replace(",", ""), NumberStyles.Number, CultureInfo.InvariantCulture, out result);
private static string[] Values(IReadOnlyList<ImportRawRow> rows,IReadOnlyDictionary<string,string> map,string field) =>
map.TryGetValue(field,out var source) ? rows.Select(x=>x.Values.TryGetValue(source,out var v)?v?.Trim():null).Where(x=>!string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Cast<string>().ToArray() : [];
private static string HeaderSignature(JsonElement e) => string.Join("|", ["orderDate","customerId","warehouseId","receiverName","phone","postalCode","address1","address2"].Select(x=>e.TryGetProperty(x,out var v)?v.ToString():""));
private static ImportFieldDefinition I(string key) => ImportFieldDefinitionFactory.FromKbxField(key);
private sealed record RefRow(Guid Id,string Code);
private sealed record ExistingOrder(Guid Id,string OrderNo,string Status);
private sealed record StagedRow(int RowNumber,string DomainKey,string NormalizedJson);
private sealed record NormalizedOrderRow(string OrderNo,DateOnly OrderDate,Guid CustomerId,string CustomerCode,Guid WarehouseId,string WarehouseCode,string ReceiverName,string Phone,string? PostalCode,string Address1,string? Address2,Guid ItemId,string ItemCode,decimal OrderQty,decimal UnitPrice,string? Remark);
}