using System.Text.Json; using Dapper; using FastEndpoints; using Modules.WMS.Picking.Scan; using Modules.WMS.Picking.Shared; using Npgsql; using Shared.Problems; namespace Modules.WMS.Picking.SetQuantity; public sealed record Request( Guid LineId, decimal PickedQty, string IdempotencyKey, long ExpectedVersion); public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint { public override void Configure() { Post("/api/wms/picking/tasks/{taskId:guid}/quantity"); Permissions("wms.picking.execute"); } public override async Task HandleAsync(Request req, CancellationToken ct) { var taskId = Route("taskId"); await using var connection = await dataSource.OpenConnectionAsync(ct); await using var tx = await connection.BeginTransactionAsync(ct); var task = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( "select id, status, version from wms.picking_tasks where id=@TaskId for update", new { TaskId = taskId }, tx, cancellationToken: ct)); if (task is null) { await tx.RollbackAsync(ct); await SendNotFoundAsync(ct); return; } var prior = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(""" select response_payload::text from wms.quantity_receipts where task_id=@TaskId and idempotency_key=@Key; """, new { TaskId = taskId, Key = req.IdempotencyKey }, tx, cancellationToken: ct)); if (prior is not null) { var replay = JsonSerializer.Deserialize(prior, JsonOptions)!; await tx.RollbackAsync(ct); await Send.OkAsync(replay with { Duplicate = true }, ct); return; } if (task.Version != req.ExpectedVersion) { await tx.RollbackAsync(ct); await Send.ResponseAsync(KbxConflictProblem.Version(task.Version), 409, cancellation: ct); return; } var line = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(""" select id, line_no as LineNo, location_code as LocationCode, location_confirmed as LocationConfirmed, required_qty as RequiredQty, picked_qty as PickedQty, status from wms.picking_lines where id=@LineId and task_id=@TaskId for update; """, new { req.LineId, TaskId = taskId }, tx, cancellationToken: ct)); if (line is null || line.Status == "COMPLETED") { await tx.RollbackAsync(ct); await Send.ResponseAsync(KbxBusinessProblem.Create("PICK_LINE_NOT_EDITABLE", "현재 피킹 수량을 변경할 수 없습니다."), 409, cancellation: ct); return; } if (!line.LocationConfirmed || line.PickedQty <= 0) { await tx.RollbackAsync(ct); await Send.ResponseAsync(KbxBusinessProblem.Create( "ITEM_NOT_VERIFIED", "먼저 상품 바코드를 스캔하세요.", "수량 직접입력은 상품을 최소 1회 확인한 뒤 사용할 수 있습니다."), 409, cancellation: ct); return; } if (req.PickedQty < line.PickedQty || req.PickedQty > line.RequiredQty) { await tx.RollbackAsync(ct); await Send.ResponseAsync(KbxValidationProblem.Create( new KbxValidationError("pickedQty", null, "PICK_QTY_RANGE", $"수량은 현재 피킹수량 {line.PickedQty}부터 필요수량 {line.RequiredQty}까지 입력할 수 있습니다.")), 400, cancellation: ct); return; } var completed = req.PickedQty >= line.RequiredQty; await connection.ExecuteAsync(new CommandDefinition(""" update wms.picking_lines set picked_qty=@PickedQty, status=case when @Completed then 'COMPLETED' else 'IN_PROGRESS' end, completed_at=case when @Completed then now() else null end, updated_at=now() where id=@LineId; update wms.picking_tasks set version=version+1, updated_at=now() where id=@TaskId; """, new { req.PickedQty, Completed = completed, req.LineId, TaskId = taskId }, tx, cancellationToken: ct)); if (completed) await PickingTransitions.AutoConfirmNextSameLocationAsync(connection, tx, taskId, line.LocationCode, ct); var hasNext = await connection.ExecuteScalarAsync(new CommandDefinition( "select exists(select 1 from wms.picking_lines where task_id=@TaskId and status <> 'COMPLETED')", new { TaskId = taskId }, tx, cancellationToken: ct)); if (!hasNext) { await connection.ExecuteAsync(new CommandDefinition(""" update wms.picking_tasks set status='COMPLETED', version=version+1, completed_at=now(), updated_at=now() where id=@TaskId; insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values (@EventId,'WmsPickingCompleted',@TaskId,jsonb_build_object('taskId',@TaskId),now(),'PENDING'); """, new { TaskId = taskId, EventId = Guid.NewGuid() }, tx, cancellationToken: ct)); } var current = await PickingTaskQueries.GetAsync(connection, tx, taskId, ct) ?? throw new InvalidOperationException(); var response = new Modules.WMS.Picking.Scan.Response(true, false, current, "success", hasNext ? "피킹 수량을 반영했습니다." : "피킹을 완료했습니다."); var json = JsonSerializer.Serialize(response, JsonOptions); await connection.ExecuteAsync(new CommandDefinition(""" insert into wms.quantity_receipts(id,task_id,idempotency_key,line_id,picked_qty,actor,response_payload) values (@Id,@TaskId,@Key,@LineId,@PickedQty,@Actor,cast(@Response as jsonb)); insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values (@AuditId,'WmsPickingTask',@TaskId,'PickingQuantitySet',@Actor,now(), jsonb_build_object('lineId',@LineId,'pickedQty',@PickedQty,'idempotencyKey',@Key)); """, new { Id = Guid.NewGuid(), AuditId = Guid.NewGuid(), TaskId = taskId, Key = req.IdempotencyKey, req.LineId, req.PickedQty, Actor = User.Identity?.Name ?? "unknown", Response = json, }, tx, cancellationToken: ct)); await tx.CommitAsync(ct); await Send.OkAsync(response, ct); } private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private sealed record TaskRow(Guid Id, string Status, long Version); private sealed record LineRow(Guid Id, int LineNo, string LocationCode, bool LocationConfirmed, decimal RequiredQty, decimal PickedQty, string Status); }