V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
using System.Text.Json;using Dapper;using FastEndpoints;using Npgsql;
|
||||
namespace Modules.OMS.Orders.Audit;
|
||||
public sealed record AuditActor(string Type,string DisplayName);public sealed record AuditChange(string Field,string Label,object? Before,object? After);public sealed record AuditRow(Guid Id,DateTimeOffset OccurredAt,string Actor,string Action,string Data);public sealed record AuditEntry(Guid Id,DateTimeOffset OccurredAt,AuditActor Actor,string Action,IReadOnlyList<AuditChange>? Changes,string? Reason);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<IReadOnlyList<AuditEntry>>{public override void Configure(){Get("/api/oms/orders/{id}/audit");Permissions("oms.order.read");}public override async Task HandleAsync(CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);var rows=await c.QueryAsync<AuditRow>(new CommandDefinition("select id,occurred_at as OccurredAt,actor,action,data::text as Data from audit.entries where aggregate_type='Order' and aggregate_id=@Id order by occurred_at desc limit 100",new{Id=id},cancellationToken:ct));var result=rows.Select(r=>{using var d=JsonDocument.Parse(r.Data);List<AuditChange>? changes=null;if(d.RootElement.TryGetProperty("changes",out var arr)){changes=new();foreach(var x in arr.EnumerateArray())changes.Add(new(x.GetProperty("field").GetString()??"",x.GetProperty("label").GetString()??"",x.TryGetProperty("before",out var b)?b.ToString():null,x.TryGetProperty("after",out var a)?a.ToString():null));}return new AuditEntry(r.Id,r.OccurredAt,new("user",r.Actor),r.Action,changes,null);}).ToList();await Send.OkAsync(result,ct);}}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Text.Json;using Dapper;using FastEndpoints;using Npgsql;using Shared.Problems;
|
||||
namespace Modules.OMS.Orders.Confirm;
|
||||
public sealed record Request(long Version);public sealed record Response(Guid OrderId,long Version,string Status);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,Response>{public override void Configure(){Post("/api/oms/orders/{id}/confirm");Permissions("oms.order.confirm");}public override async Task HandleAsync(Request req,CancellationToken ct){var id=Route<Guid>("id");var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();if(string.IsNullOrWhiteSpace(key)){await Send.ResponseAsync(KbxValidationProblem.Create(new(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;}await using var c=await dataSource.OpenConnectionAsync(ct);await using var tx=await c.BeginTransactionAsync(ct);var replay=await c.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",new{OperationId="oms.orders.confirm",Key=key},tx,cancellationToken:ct));if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<Response>(replay)!,ct);return;}var current=await c.QuerySingleOrDefaultAsync<(long Version,string Status)>(new CommandDefinition("select version as Version,status as Status from oms.orders where id=@Id for update",new{Id=id},tx,cancellationToken:ct));if(current.Status is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("ORDER_NOT_FOUND","주문을 찾을 수 없습니다."),404,cancellation:ct);return;}if(current.Version!=req.Version){await Send.ResponseAsync(KbxConflictProblem.Version(current.Version),409,cancellation:ct);return;}if(current.Status!="DRAFT"){await Send.ResponseAsync(KbxBusinessProblem.Create("ORDER_NOT_CONFIRMABLE","주문을 확정할 수 없습니다.","작성 상태의 주문만 확정할 수 있습니다."),409,cancellation:ct);return;}var next=current.Version+1;var actor=User.Identity?.Name??"unknown";await c.ExecuteAsync(new CommandDefinition(@"update oms.orders set status='CONFIRMED',version=@Version,updated_at=now(),updated_by=@Actor where id=@Id;insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values(@AuditId,'Order',@Id,'ORDER_CONFIRMED',@Actor,now(),jsonb_build_object('version',@Version,'changes',jsonb_build_array(jsonb_build_object('field','status','label','상태','before','작성','after','확정'))));insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values(@EventId,'OrderConfirmed',@Id,jsonb_build_object('orderId',@Id,'version',@Version),now(),'PENDING');",new{Id=id,Version=next,Actor=actor,AuditId=Guid.NewGuid(),EventId=Guid.NewGuid()},tx,cancellationToken:ct));var response=new Response(id,next,"확정");await c.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@ResponseJson as jsonb))",new{OperationId="oms.orders.confirm",Key=key,ResponseJson=JsonSerializer.Serialize(response)},tx,cancellationToken:ct));await tx.CommitAsync(ct);await Send.OkAsync(response,ct);}}
|
||||
@@ -0,0 +1,5 @@
|
||||
using Dapper;using FastEndpoints;using Npgsql;using Shared.Problems;
|
||||
namespace Modules.OMS.Orders.Get;
|
||||
public sealed record OrderLine(Guid Id,string ClientId,Guid ItemId,string ItemCode,string ItemName,decimal OrderQty,decimal UnitPrice,decimal Amount,string Remark);
|
||||
public sealed record Response(Guid OrderId,string OrderNo,long Version,string Status,DateOnly OrderDate,Guid CustomerId,Guid WarehouseId,string ReceiverName,string Phone,string? PostalCode,string Address1,string? Address2,IReadOnlyList<OrderLine> Lines);
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<Response>{public override void Configure(){Get("/api/oms/orders/{id}");Permissions("oms.order.read");}public override async Task HandleAsync(CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);var h=await c.QuerySingleOrDefaultAsync(new CommandDefinition("select id as OrderId,order_no as OrderNo,version,status,order_date as OrderDate,customer_id as CustomerId,warehouse_id as WarehouseId,receiver_name as ReceiverName,phone,postal_code as PostalCode,address1,address2 from oms.orders where id=@Id",new{Id=id},cancellationToken:ct));if(h is null){await Send.ResponseAsync(KbxNotFoundProblem.Create("ORDER_NOT_FOUND","주문을 찾을 수 없습니다."),404,cancellation:ct);return;}var lines=(await c.QueryAsync<OrderLine>(new CommandDefinition(@"select l.id,l.id::text as ClientId,l.item_id as ItemId,i.code as ItemCode,i.name as ItemName,l.quantity as OrderQty,l.unit_price as UnitPrice,l.amount,coalesce(l.remark,'') as Remark from oms.order_lines l join catalog.items i on i.id=l.item_id where l.order_id=@Id order by l.line_no",new{Id=id},cancellationToken:ct))).AsList();string display=(string)h.status switch{"DRAFT"=>"작성","CONFIRMED"=>"확정","ALLOCATED"=>"할당","PICKING"=>"피킹","CHECKED"=>"검수","SHIPPED"=>"출고완료",_=>(string)h.status};await Send.OkAsync(new((Guid)h.orderid,(string)h.orderno,(long)h.version,display,(DateOnly)h.orderdate,(Guid)h.customerid,(Guid)h.warehouseid,(string)h.receivername,(string)h.phone,(string?)h.postalcode,(string)h.address1,(string?)h.address2,lines),ct);}}
|
||||
+224
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using FastEndpoints;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed class Endpoint(RegisterOrderHandler handler)
|
||||
: Endpoint<RegisterOrderRequest, RegisterOrderResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/oms/orders/register");
|
||||
Permissions("oms.order.create");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RegisterOrderRequest req, CancellationToken ct)
|
||||
{
|
||||
if (req.OrderId is not null)
|
||||
{
|
||||
await Send.ResponseAsync(KbxBusinessProblem.Create("ORDER_CREATE_ENDPOINT_ONLY", "신규 주문 등록 요청이 아닙니다.", "기존 주문 수정은 전용 수정 API를 사용하세요."), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
var result = await handler.HandleAsync(req with { OrderId = null, Version = null }, User, ct);
|
||||
|
||||
if (result.Problem is KbxValidationProblem validation)
|
||||
{
|
||||
await Send.ResponseAsync(validation, 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
if (result.Problem is KbxBusinessProblem business)
|
||||
{
|
||||
await Send.ResponseAsync(business, 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
if (result.Problem is KbxConflictProblem conflict)
|
||||
{
|
||||
await Send.ResponseAsync(conflict, 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await Send.OkAsync(result.Response!, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Data;
|
||||
using System.Security.Claims;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed record RegisterOrderResult(RegisterOrderResponse? Response, object? Problem)
|
||||
{
|
||||
public static RegisterOrderResult Ok(RegisterOrderResponse response) => new(response, null);
|
||||
public static RegisterOrderResult Fail(object problem) => new(null, problem);
|
||||
}
|
||||
|
||||
public sealed class RegisterOrderHandler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<RegisterOrderResult> HandleAsync(
|
||||
RegisterOrderRequest request,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
|
||||
|
||||
// Lookup/Domain facts are re-read on the server. UI/Zod values are never trusted as business truth.
|
||||
var customerExists = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from oms.customers where id = @Id and is_active = true)",
|
||||
new { Id = request.CustomerId }, tx, cancellationToken: ct));
|
||||
if (!customerExists)
|
||||
return RegisterOrderResult.Fail(KbxValidationProblem.Create(
|
||||
new("customerId", null, "CUSTOMER_NOT_FOUND", "사용 가능한 거래처가 아닙니다.")));
|
||||
|
||||
var warehouseExists = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
|
||||
"select exists(select 1 from inventory.warehouses where id = @Id and is_active = true)",
|
||||
new { Id = request.WarehouseId }, tx, cancellationToken: ct));
|
||||
if (!warehouseExists)
|
||||
return RegisterOrderResult.Fail(KbxValidationProblem.Create(
|
||||
new("warehouseId", null, "WAREHOUSE_NOT_FOUND", "사용 가능한 출고창고가 아닙니다.")));
|
||||
|
||||
var lineErrors = new List<KbxValidationError>();
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (line.EffectiveOrderQty <= 0)
|
||||
lineErrors.Add(new("orderQty", line.ClientId, "QUANTITY_POSITIVE", "수량은 0보다 커야 합니다."));
|
||||
if (line.UnitPrice < 0)
|
||||
lineErrors.Add(new("unitPrice", line.ClientId, "UNIT_PRICE_NONNEGATIVE", "단가는 0 이상이어야 합니다."));
|
||||
}
|
||||
|
||||
var requestedItemIds = request.Lines.Select(x => x.ItemId).Distinct().ToArray();
|
||||
var activeItemIds = (await connection.QueryAsync<Guid>(new CommandDefinition(
|
||||
"select id from catalog.items where id = any(@Ids) and is_active = true",
|
||||
new { Ids = requestedItemIds }, tx, cancellationToken: ct))).ToHashSet();
|
||||
|
||||
foreach (var line in request.Lines.Where(x => !activeItemIds.Contains(x.ItemId)))
|
||||
lineErrors.Add(new("itemCode", line.ClientId, "ITEM_NOT_FOUND", "사용 가능한 품목이 아닙니다."));
|
||||
|
||||
if (lineErrors.Count > 0)
|
||||
return RegisterOrderResult.Fail(KbxValidationProblem.Create(lineErrors.ToArray()));
|
||||
|
||||
var actor = user.Identity?.Name ?? "unknown";
|
||||
var orderId = request.OrderId ?? Guid.NewGuid();
|
||||
var nextVersion = 1L;
|
||||
string orderNo;
|
||||
|
||||
if (request.OrderId is null)
|
||||
{
|
||||
orderNo = $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
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, 'DRAFT', 1, now(), @Actor);",
|
||||
new { Id = orderId, OrderNo = orderNo, request.OrderDate, request.CustomerId, request.WarehouseId, request.ReceiverName, request.Phone, request.PostalCode, request.Address1, request.Address2, Actor = actor }, tx, cancellationToken: ct));
|
||||
}
|
||||
else
|
||||
{
|
||||
var current = await connection.QuerySingleOrDefaultAsync<(string OrderNo, long Version, string Status)>(new CommandDefinition(
|
||||
"select order_no as OrderNo, version as Version, status as Status from oms.orders where id = @Id for update",
|
||||
new { Id = orderId }, tx, cancellationToken: ct));
|
||||
if (current.OrderNo is null)
|
||||
return RegisterOrderResult.Fail(KbxBusinessProblem.Create("ORDER_NOT_FOUND", "주문을 찾을 수 없습니다."));
|
||||
if (current.Version != request.Version)
|
||||
return RegisterOrderResult.Fail(KbxConflictProblem.Version(current.Version));
|
||||
if (current.Status != "DRAFT")
|
||||
return RegisterOrderResult.Fail(KbxBusinessProblem.Create("ORDER_NOT_EDITABLE", "주문을 수정할 수 없습니다.", "작성 상태의 주문만 수정할 수 있습니다."));
|
||||
|
||||
orderNo = current.OrderNo;
|
||||
nextVersion = current.Version + 1;
|
||||
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, updated_at=now(), updated_by=@Actor where id=@Id;",
|
||||
new { Id = orderId, request.OrderDate, request.CustomerId, request.WarehouseId, request.ReceiverName, request.Phone, request.PostalCode, request.Address1, request.Address2, Version = nextVersion, Actor = actor }, tx, cancellationToken: ct));
|
||||
await connection.ExecuteAsync(new CommandDefinition("delete from oms.order_lines where order_id=@OrderId", new { OrderId = orderId }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
var lineNo = 0;
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
lineNo++;
|
||||
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 = lineNo, line.ItemId, Quantity = line.EffectiveOrderQty, line.UnitPrice, Amount = line.EffectiveOrderQty * line.UnitPrice, line.Remark }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
insert into audit.entries(id, aggregate_type, aggregate_id, action, actor, occurred_at, data)
|
||||
values (@Id, 'Order', @OrderId, 'ORDER_SAVED', @Actor, now(), jsonb_build_object('version', @Version));",
|
||||
new { Id = Guid.NewGuid(), OrderId = orderId, Actor = actor, Version = nextVersion }, tx, cancellationToken: ct));
|
||||
|
||||
// Integration event is recorded atomically; dispatch is handled outside this transaction.
|
||||
await connection.ExecuteAsync(new CommandDefinition(@"
|
||||
insert into integration.outbox(id, event_type, aggregate_id, payload, occurred_at, status)
|
||||
values (@Id, 'OrderSaved', @OrderId, jsonb_build_object('orderId', @OrderId, 'version', @Version), now(), 'PENDING');",
|
||||
new { Id = Guid.NewGuid(), OrderId = orderId, Version = nextVersion }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return RegisterOrderResult.Ok(new(orderId, orderNo, nextVersion, "작성"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Kbx.Contracts;
|
||||
using Kbx.Contracts.Generated;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed record RegisterOrderRequest(
|
||||
Guid? OrderId,
|
||||
long? Version,
|
||||
[property: KbxFieldKey(KbxFieldKeys.OrderDate)] DateOnly OrderDate,
|
||||
[property: KbxFieldKey(KbxFieldKeys.CustomerId)] Guid CustomerId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.WarehouseId)] Guid WarehouseId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.ReceiverName)] string ReceiverName,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Phone)] string Phone,
|
||||
[property: KbxFieldKey(KbxFieldKeys.PostalCode)] string? PostalCode,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Address1)] string Address1,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Address2)] string? Address2,
|
||||
IReadOnlyList<RegisterOrderLineRequest> Lines);
|
||||
|
||||
public sealed record RegisterOrderLineRequest(
|
||||
string ClientId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.ItemId)] Guid ItemId,
|
||||
[property: KbxFieldKey(KbxFieldKeys.OrderQty)] decimal? OrderQty,
|
||||
// Legacy wire compatibility for v12 clients. Remove only in a declared breaking API migration.
|
||||
decimal? Quantity,
|
||||
[property: KbxFieldKey(KbxFieldKeys.UnitPrice)] decimal UnitPrice,
|
||||
[property: KbxFieldKey(KbxFieldKeys.Remark)] string? Remark)
|
||||
{
|
||||
public decimal EffectiveOrderQty => OrderQty ?? Quantity ?? 0m;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed record RegisterOrderResponse(
|
||||
Guid OrderId,
|
||||
string OrderNo,
|
||||
long Version,
|
||||
string Status);
|
||||
@@ -0,0 +1,3 @@
|
||||
using FastEndpoints;using Shared.Problems;
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
public sealed class UpdateEndpoint(RegisterOrderHandler handler):Endpoint<RegisterOrderRequest,RegisterOrderResponse>{public override void Configure(){Put("/api/oms/orders/{id}");Permissions("oms.order.write");}public override async Task HandleAsync(RegisterOrderRequest req,CancellationToken ct){var id=Route<Guid>("id");var result=await handler.HandleAsync(req with{OrderId=id},User,ct);if(result.Problem is KbxValidationProblem v){await Send.ResponseAsync(v,400,cancellation:ct);return;}if(result.Problem is KbxBusinessProblem b){await Send.ResponseAsync(b,409,cancellation:ct);return;}if(result.Problem is KbxConflictProblem c){await Send.ResponseAsync(c,409,cancellation:ct);return;}await Send.OkAsync(result.Response!,ct);}}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Modules.OMS.Orders.Register;
|
||||
|
||||
public sealed class RegisterOrderValidator : Validator<RegisterOrderRequest>
|
||||
{
|
||||
public RegisterOrderValidator()
|
||||
{
|
||||
RuleFor(x => x.OrderDate).NotEmpty().WithErrorCode("ORDER_DATE_REQUIRED");
|
||||
RuleFor(x => x.CustomerId).NotEmpty().WithErrorCode("CUSTOMER_REQUIRED");
|
||||
RuleFor(x => x.WarehouseId).NotEmpty().WithErrorCode("WAREHOUSE_REQUIRED");
|
||||
RuleFor(x => x.ReceiverName).NotEmpty().MaximumLength(100).WithErrorCode("RECEIVER_REQUIRED");
|
||||
RuleFor(x => x.Phone).NotEmpty().MaximumLength(50).WithErrorCode("PHONE_REQUIRED");
|
||||
RuleFor(x => x.Address1).NotEmpty().MaximumLength(500).WithErrorCode("ADDRESS_REQUIRED");
|
||||
RuleFor(x => x.Lines).NotEmpty().WithErrorCode("ORDER_LINE_REQUIRED");
|
||||
|
||||
// Row-aware line validation is deliberately performed in the handler so the
|
||||
// KBX response can return the stable ClientId as rowKey instead of Lines[7].OrderQty.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FastEndpoints;
|
||||
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed class Endpoint(SearchOrdersHandler handler)
|
||||
: Endpoint<SearchOrdersRequest, SearchOrdersResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/oms/orders");
|
||||
Permissions("oms.order.read");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SearchOrdersRequest req, CancellationToken ct)
|
||||
{
|
||||
var response = await handler.HandleAsync(req, ct);
|
||||
await Send.OkAsync(response, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed class SearchOrdersHandler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<SearchOrdersResponse> HandleAsync(SearchOrdersRequest request, CancellationToken ct)
|
||||
{
|
||||
var page = Math.Max(request.Page, 1);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 500);
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// Read model query: UI-required projection in one server-side query boundary.
|
||||
const string rowsSql = """
|
||||
select
|
||||
o.id,
|
||||
o.order_no as OrderNo,
|
||||
ch.name as ChannelName,
|
||||
o.ordered_at as OrderedAt,
|
||||
o.customer_name as CustomerName,
|
||||
o.item_summary as ItemSummary,
|
||||
o.total_qty as TotalQty,
|
||||
o.amount as Amount,
|
||||
o.allocation_status as AllocationStatus,
|
||||
o.shipment_status as ShipmentStatus,
|
||||
o.exception_count as ExceptionCount
|
||||
from oms_order_search_projection o
|
||||
join sales_channel ch on ch.id = o.channel_id
|
||||
where o.ordered_at >= @From
|
||||
and o.ordered_at < @ToExclusive
|
||||
and (@ChannelId is null or o.channel_id = @ChannelId)
|
||||
and (@Status is null or o.shipment_status = @Status)
|
||||
and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%')
|
||||
and (@ExceptionOnly = false or o.exception_count > 0)
|
||||
order by o.ordered_at desc, o.id desc
|
||||
limit @PageSize offset @Offset;
|
||||
""";
|
||||
|
||||
const string summarySql = """
|
||||
select
|
||||
count(*)::int as TotalCount,
|
||||
coalesce(sum(o.total_qty), 0) as TotalQty,
|
||||
coalesce(sum(o.amount), 0) as TotalAmount,
|
||||
count(*) filter (where o.order_status = 'NEW')::int as NewCount,
|
||||
count(*) filter (where o.shipment_status = 'READY')::int as ReadyToShipCount,
|
||||
count(*) filter (where o.exception_count > 0)::int as ExceptionCount
|
||||
from oms_order_search_projection o
|
||||
where o.ordered_at >= @From
|
||||
and o.ordered_at < @ToExclusive
|
||||
and (@ChannelId is null or o.channel_id = @ChannelId)
|
||||
and (@Status is null or o.shipment_status = @Status)
|
||||
and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%')
|
||||
and (@ExceptionOnly = false or o.exception_count > 0);
|
||||
""";
|
||||
|
||||
var args = new
|
||||
{
|
||||
From = request.From.ToDateTime(TimeOnly.MinValue),
|
||||
ToExclusive = request.To.AddDays(1).ToDateTime(TimeOnly.MinValue),
|
||||
request.ChannelId,
|
||||
Status = string.IsNullOrWhiteSpace(request.Status) ? null : request.Status,
|
||||
Keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim(),
|
||||
request.ExceptionOnly,
|
||||
PageSize = pageSize,
|
||||
Offset = offset,
|
||||
};
|
||||
|
||||
var rows = (await connection.QueryAsync<OrderSearchRow>(
|
||||
new CommandDefinition(rowsSql, args, cancellationToken: ct))).AsList();
|
||||
|
||||
var summary = await connection.QuerySingleAsync<SummaryRow>(
|
||||
new CommandDefinition(summarySql, args, cancellationToken: ct));
|
||||
|
||||
return new SearchOrdersResponse(
|
||||
rows,
|
||||
summary.TotalCount,
|
||||
summary.TotalQty,
|
||||
summary.TotalAmount,
|
||||
new OrderSearchCounters(
|
||||
summary.TotalCount,
|
||||
summary.NewCount,
|
||||
summary.ReadyToShipCount,
|
||||
summary.ExceptionCount));
|
||||
}
|
||||
|
||||
private sealed record SummaryRow(
|
||||
int TotalCount,
|
||||
decimal TotalQty,
|
||||
decimal TotalAmount,
|
||||
int NewCount,
|
||||
int ReadyToShipCount,
|
||||
int ExceptionCount);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed record SearchOrdersRequest(
|
||||
DateOnly From,
|
||||
DateOnly To,
|
||||
Guid? ChannelId,
|
||||
string? Status,
|
||||
string? Keyword,
|
||||
bool ExceptionOnly = false,
|
||||
int Page = 1,
|
||||
int PageSize = 100);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Modules.OMS.Orders.Search;
|
||||
|
||||
public sealed record OrderSearchRow(
|
||||
Guid Id,
|
||||
string OrderNo,
|
||||
string ChannelName,
|
||||
DateTimeOffset OrderedAt,
|
||||
string CustomerName,
|
||||
string ItemSummary,
|
||||
decimal TotalQty,
|
||||
decimal Amount,
|
||||
string AllocationStatus,
|
||||
string ShipmentStatus,
|
||||
int ExceptionCount);
|
||||
|
||||
public sealed record OrderSearchCounters(
|
||||
int All,
|
||||
int New,
|
||||
int ReadyToShip,
|
||||
int Exceptions);
|
||||
|
||||
public sealed record SearchOrdersResponse(
|
||||
IReadOnlyList<OrderSearchRow> Items,
|
||||
int TotalCount,
|
||||
decimal TotalQty,
|
||||
decimal TotalAmount,
|
||||
OrderSearchCounters Counters);
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.OMS.Orders.Ship;
|
||||
|
||||
public sealed record ShipOrdersFilter(
|
||||
DateOnly From,
|
||||
DateOnly To,
|
||||
Guid? ChannelId,
|
||||
string? Status,
|
||||
string? Keyword,
|
||||
bool ExceptionOnly = false);
|
||||
|
||||
public sealed record ShipOrdersRequest(
|
||||
string Mode,
|
||||
IReadOnlyList<Guid>? Ids,
|
||||
ShipOrdersFilter? Filter,
|
||||
IReadOnlyList<Guid>? ExcludedIds);
|
||||
|
||||
public sealed record ShipOrdersResponse(int Requested, int Accepted, int Rejected);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<ShipOrdersRequest, ShipOrdersResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/oms/orders/ship");
|
||||
Permissions("oms.order.ship");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ShipOrdersRequest req, CancellationToken ct)
|
||||
{
|
||||
var key = HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError(null, null, "IDEMPOTENCY_KEY_REQUIRED", "안전한 재처리를 위해 Idempotency-Key가 필요합니다.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var mode = req.Mode?.Trim().ToLowerInvariant();
|
||||
if (mode is not ("ids" or "filter") || (mode == "ids" && (req.Ids is null || req.Ids.Count == 0)) || (mode == "filter" && req.Filter is null))
|
||||
{
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError("selection", null, "ORDER_SELECTION_REQUIRED", "출고지시할 주문을 선택하세요.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var replay = await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition(
|
||||
"select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",
|
||||
new { OperationId = "oms.orders.ship", Key = key }, tx, cancellationToken: ct));
|
||||
if (replay is not null)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.OkAsync(JsonSerializer.Deserialize<ShipOrdersResponse>(replay)!, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
const string idsTarget = "select distinct unnest(@Ids::uuid[]) as id";
|
||||
const string filterTarget = """
|
||||
select o.id
|
||||
from oms_order_search_projection o
|
||||
where o.ordered_at >= @From
|
||||
and o.ordered_at < @ToExclusive
|
||||
and (@ChannelId is null or o.channel_id = @ChannelId)
|
||||
and (@Status is null or o.shipment_status = @Status)
|
||||
and (@Keyword is null or o.search_text ilike '%' || @Keyword || '%')
|
||||
and (@ExceptionOnly = false or o.exception_count > 0)
|
||||
and not (o.id = any(@ExcludedIds))
|
||||
""";
|
||||
var targetSql = mode == "ids" ? idsTarget : filterTarget;
|
||||
|
||||
var filter = req.Filter;
|
||||
var args = new
|
||||
{
|
||||
Ids = (req.Ids ?? Array.Empty<Guid>()).ToArray(),
|
||||
From = filter is null ? (DateTime?)null : filter.From.ToDateTime(TimeOnly.MinValue),
|
||||
ToExclusive = filter is null ? (DateTime?)null : filter.To.AddDays(1).ToDateTime(TimeOnly.MinValue),
|
||||
ChannelId = filter?.ChannelId,
|
||||
Status = string.IsNullOrWhiteSpace(filter?.Status) ? null : filter!.Status,
|
||||
Keyword = string.IsNullOrWhiteSpace(filter?.Keyword) ? null : filter!.Keyword!.Trim(),
|
||||
ExceptionOnly = filter?.ExceptionOnly ?? false,
|
||||
ExcludedIds = (req.ExcludedIds ?? Array.Empty<Guid>()).ToArray(),
|
||||
};
|
||||
|
||||
var result = await connection.QuerySingleAsync<ShipMutationResult>(new CommandDefinition($"""
|
||||
with target as materialized (
|
||||
{targetSql}
|
||||
),
|
||||
accepted as materialized (
|
||||
update oms.orders
|
||||
set status='CONFIRMED', version=version+1, updated_at=now(), updated_by='web'
|
||||
where id in (select id from target)
|
||||
and status in ('NEW','DRAFT','READY')
|
||||
returning id
|
||||
),
|
||||
audit_insert as (
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
select gen_random_uuid(),'Order',id,'ShipRequested','web',now(),'{}'::jsonb
|
||||
from accepted
|
||||
returning 1
|
||||
),
|
||||
outbox_insert as (
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
select gen_random_uuid(),'OmsOrderShipRequested',id,jsonb_build_object('orderId',id),now(),'PENDING'
|
||||
from accepted
|
||||
returning 1
|
||||
)
|
||||
select (select count(*)::int from target) as Requested,
|
||||
(select count(*)::int from accepted) as Accepted;
|
||||
""", args, tx, cancellationToken: ct));
|
||||
|
||||
if (result.Requested == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxValidationProblem.Create(
|
||||
new KbxValidationError("selection", null, "ORDER_SELECTION_EMPTY", "현재 검색조건에서 처리할 주문이 없습니다.")), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = new ShipOrdersResponse(result.Requested, result.Accepted, result.Requested - result.Accepted);
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.command_receipts(operation_id,idempotency_key,response_json)
|
||||
values(@OperationId,@Key,cast(@Response as jsonb));
|
||||
""", new { OperationId = "oms.orders.ship", Key = key, Response = JsonSerializer.Serialize(response) }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
await Send.OkAsync(response, ct);
|
||||
}
|
||||
|
||||
private sealed record ShipMutationResult(int Requested, int Accepted);
|
||||
}
|
||||
Reference in New Issue
Block a user