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