120 lines
7.3 KiB
C#
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, "작성"));
|
|
}
|
|
}
|