V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user