using Dapper; using FastEndpoints; using Modules.WMS.Picking.Shared; using Npgsql; using Shared.Problems; namespace Modules.WMS.Picking.Exceptions; public sealed record Request( Guid? LineId, string Type, string? Memo, string IdempotencyKey, long ExpectedVersion); public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint { public override void Configure() { Post("/api/wms/picking/tasks/{taskId:guid}/exceptions"); Permissions("wms.picking.execute"); } public override async Task HandleAsync(Request req, CancellationToken ct) { var taskId = Route("taskId"); var allowed = new[] { "no-stock", "short-quantity", "wrong-location", "damaged-item", "barcode-issue", "other" }; if (!allowed.Contains(req.Type)) { await Send.ResponseAsync(KbxValidationProblem.Create( new KbxValidationError("type", null, "EXCEPTION_TYPE_INVALID", "문제 유형을 확인하세요.")), 400, cancellation: ct); return; } await using var connection = await dataSource.OpenConnectionAsync(ct); await using var tx = await connection.BeginTransactionAsync(ct); var currentVersion = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( "select version from wms.picking_tasks where id=@TaskId for update", new { TaskId = taskId }, tx, cancellationToken: ct)); if (currentVersion is null) { await tx.RollbackAsync(ct); await SendNotFoundAsync(ct); return; } var duplicate = await connection.ExecuteScalarAsync(new CommandDefinition( "select exists(select 1 from wms.picking_exceptions where task_id=@TaskId and idempotency_key=@Key)", new { TaskId = taskId, Key = req.IdempotencyKey }, tx, cancellationToken: ct)); if (duplicate) { await tx.RollbackAsync(ct); var existing = await PickingTaskQueries.GetAsync(connection, null, taskId, ct); await Send.OkAsync(existing!, ct); return; } if (currentVersion != req.ExpectedVersion) { await tx.RollbackAsync(ct); await Send.ResponseAsync(KbxConflictProblem.Version(currentVersion), 409, cancellation: ct); return; } await connection.ExecuteAsync(new CommandDefinition(""" insert into wms.picking_exceptions( id, task_id, line_id, exception_type, memo, status, reported_at, reported_by, idempotency_key) values (@Id, @TaskId, @LineId, @Type, @Memo, 'OPEN', now(), @Actor, @Key) on conflict (task_id, idempotency_key) do nothing; update wms.picking_tasks set status='BLOCKED', version=version+1, updated_at=now() where id=@TaskId; insert into audit.entries(id, aggregate_type, aggregate_id, action, actor, occurred_at, data) values (@AuditId, 'WmsPickingTask', @TaskId, 'PickingExceptionReported', @Actor, now(), jsonb_build_object('type', @Type, 'lineId', @LineId, 'memo', @Memo)); insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values (@EventId,'WmsPickingExceptionReported',@TaskId, jsonb_build_object('taskId',@TaskId,'lineId',@LineId,'type',@Type),now(),'PENDING'); """, new { Id = Guid.NewGuid(), AuditId = Guid.NewGuid(), EventId = Guid.NewGuid(), TaskId = taskId, req.LineId, req.Type, req.Memo, Key = req.IdempotencyKey, Actor = User.Identity?.Name ?? "unknown", }, tx, cancellationToken: ct)); await tx.CommitAsync(ct); var task = await PickingTaskQueries.GetAsync(connection, null, taskId, ct); await SendOkAsync(task! with { Message = "문제를 등록했습니다. 관리자 확인이 필요합니다." }, ct); } }