145 lines
6.9 KiB
C#
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);
|
|
}
|