Files
KArtSell.Aegis/docs/Design/kbx-foundation-v36/backend/Modules/WMS/Picking/SetQuantity/Endpoint.cs
T

145 lines
6.9 KiB
C#

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<Request, Modules.WMS.Picking.Scan.Response>
{
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<Guid>("taskId");
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(ct);
var task = await connection.QuerySingleOrDefaultAsync<TaskRow>(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<string>(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<Modules.WMS.Picking.Scan.Response>(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<LineRow>(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<bool>(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);
}