Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/backend/Modules/OMS/Orders/Register/Handler.cs
T

120 lines
7.3 KiB
C#

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, "작성"));
}
}