V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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<Request, PickingTaskDto>
|
||||
{
|
||||
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<Guid>("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<long?>(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<bool>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.GetTask;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: EndpointWithoutRequest<PickingTaskDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/wms/picking/tasks/{taskId:guid}");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var task = await PickingTaskQueries.GetAsync(connection, null, taskId, ct);
|
||||
if (task is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(task, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.Scan;
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<Request, Response>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/wms/picking/tasks/{taskId:guid}/scan");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request req, CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
var handler = new Handler(dataSource);
|
||||
var result = await handler.HandleAsync(taskId, req, User.Identity?.Name ?? "unknown", ct);
|
||||
await Send.ResponseAsync(result.Body, result.StatusCode, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.WMS.Picking.Scan;
|
||||
|
||||
public sealed class Handler(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public sealed record HandlerResult(int StatusCode, object Body);
|
||||
|
||||
public async Task<HandlerResult> HandleAsync(
|
||||
Guid taskId,
|
||||
Request request,
|
||||
string actor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
return new(400, KbxValidationProblem.Create(
|
||||
new KbxValidationError("idempotencyKey", null, "IDEMPOTENCY_REQUIRED", "Idempotency key가 필요합니다.")));
|
||||
|
||||
var barcode = request.Barcode.Trim();
|
||||
if (barcode.Length < 3)
|
||||
return new(400, KbxValidationProblem.Create(
|
||||
new KbxValidationError("barcode", null, "BARCODE_INVALID", "바코드를 다시 스캔하세요.")));
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// One picking task is a sequence. Serialize scanner commands for this task.
|
||||
var taskRow = await connection.QuerySingleOrDefaultAsync<TaskRow>(new CommandDefinition("""
|
||||
select id, task_no as TaskNo, status, version
|
||||
from wms.picking_tasks
|
||||
where id=@TaskId
|
||||
for update;
|
||||
""", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (taskRow is null)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(404, new { type = "not-found", title = "피킹 작업을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// Idempotency is checked before version. A response-lost retry must replay the committed result.
|
||||
var existingJson = await connection.QuerySingleOrDefaultAsync<string>(new CommandDefinition("""
|
||||
select response_payload::text
|
||||
from wms.scan_receipts
|
||||
where task_id=@TaskId and idempotency_key=@Key;
|
||||
""", new { TaskId = taskId, Key = request.IdempotencyKey }, tx, cancellationToken: ct));
|
||||
if (existingJson is not null)
|
||||
{
|
||||
var existing = JsonSerializer.Deserialize<Response>(existingJson, JsonOptions)!;
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(200, existing with { Duplicate = true });
|
||||
}
|
||||
|
||||
if (taskRow.Version != request.ExpectedVersion)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(409, KbxConflictProblem.Version(taskRow.Version));
|
||||
}
|
||||
|
||||
if (taskRow.Status != "IN_PROGRESS")
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return new(409, KbxBusinessProblem.Create(
|
||||
"PICKING_NOT_ACTIVE",
|
||||
"현재 피킹할 수 없는 작업입니다.",
|
||||
$"현재 상태: {taskRow.Status}"));
|
||||
}
|
||||
|
||||
var line = await connection.QuerySingleOrDefaultAsync<LineRow>(new CommandDefinition("""
|
||||
select l.id as LineId,
|
||||
l.line_no as LineNo,
|
||||
l.location_code as LocationCode,
|
||||
l.location_barcode as LocationBarcode,
|
||||
l.location_confirmed as LocationConfirmed,
|
||||
l.item_id as ItemId,
|
||||
l.barcode as Barcode,
|
||||
l.required_qty as RequiredQty,
|
||||
l.picked_qty as PickedQty
|
||||
from wms.picking_lines l
|
||||
where l.task_id=@TaskId and l.status <> 'COMPLETED'
|
||||
order by l.line_no
|
||||
limit 1
|
||||
for update;
|
||||
""", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
if (line is null)
|
||||
{
|
||||
await CompleteTaskAsync(connection, tx, taskId, ct);
|
||||
var completed = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor, true, "success", "피킹을 완료했습니다.", "TaskRecoveredAsCompleted", ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(200, completed);
|
||||
}
|
||||
|
||||
var decision = PickingScanStateMachine.Decide(new PickingScanState(
|
||||
line.LocationConfirmed,
|
||||
line.LocationCode,
|
||||
line.LocationBarcode,
|
||||
line.Barcode,
|
||||
line.RequiredQty,
|
||||
line.PickedQty), barcode);
|
||||
|
||||
switch (decision.Kind)
|
||||
{
|
||||
case PickingScanDecisionKind.RejectLocation:
|
||||
case PickingScanDecisionKind.RejectItem:
|
||||
{
|
||||
var rejected = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor,
|
||||
decision.Accepted, decision.Feedback, decision.Message, decision.AuditAction, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(422, rejected);
|
||||
}
|
||||
|
||||
case PickingScanDecisionKind.AcceptLocation:
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_lines
|
||||
set location_confirmed=true, updated_at=now()
|
||||
where id=@LineId;
|
||||
update wms.picking_tasks
|
||||
set version=version+1, updated_at=now()
|
||||
where id=@TaskId;
|
||||
""", new { line.LineId, TaskId = taskId }, tx, cancellationToken: ct));
|
||||
|
||||
var accepted = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor,
|
||||
true, decision.Feedback, decision.Message, decision.AuditAction, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(200, accepted);
|
||||
}
|
||||
|
||||
case PickingScanDecisionKind.AcceptItem:
|
||||
{
|
||||
await ApplyItemScanAsync(connection, tx, taskId, line, decision, ct);
|
||||
|
||||
var nextExists = await HasIncompleteLineAsync(connection, tx, taskId, ct);
|
||||
if (!nextExists)
|
||||
await CompleteTaskAsync(connection, tx, taskId, ct);
|
||||
|
||||
var message = nextExists ? decision.Message : "피킹을 완료했습니다.";
|
||||
var accepted = await BuildAndPersistResponse(
|
||||
connection, tx, taskId, request, actor,
|
||||
true, decision.Feedback, message, decision.AuditAction, ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return new(200, accepted);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported picking scan decision: {decision.Kind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyItemScanAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
LineRow line,
|
||||
PickingScanDecision decision,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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 completed_at end,
|
||||
updated_at=now()
|
||||
where id=@LineId;
|
||||
|
||||
update wms.picking_tasks set version=version+1, updated_at=now() where id=@TaskId;
|
||||
""", new {
|
||||
PickedQty = decision.NewPickedQty!.Value,
|
||||
Completed = decision.LineCompleted,
|
||||
line.LineId,
|
||||
TaskId = taskId,
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
if (decision.LineCompleted)
|
||||
await PickingTransitions.AutoConfirmNextSameLocationAsync(connection, tx, taskId, line.LocationCode, ct);
|
||||
}
|
||||
|
||||
private static Task<bool> HasIncompleteLineAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
CancellationToken ct) => 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));
|
||||
|
||||
private static async Task CompleteTaskAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_tasks
|
||||
set status='COMPLETED', version=version+1, completed_at=coalesce(completed_at,now()), updated_at=now()
|
||||
where id=@TaskId and status <> 'COMPLETED';
|
||||
|
||||
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
|
||||
select @EventId,'WmsPickingCompleted',@TaskId,jsonb_build_object('taskId',@TaskId),now(),'PENDING'
|
||||
where not exists (
|
||||
select 1 from integration.outbox
|
||||
where aggregate_id=@TaskId and event_type='WmsPickingCompleted'
|
||||
);
|
||||
""", new { TaskId = taskId, EventId = Guid.NewGuid() }, tx, cancellationToken: ct));
|
||||
}
|
||||
|
||||
private static async Task<Response> BuildAndPersistResponse(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction tx,
|
||||
Guid taskId,
|
||||
Request request,
|
||||
string actor,
|
||||
bool accepted,
|
||||
string feedback,
|
||||
string message,
|
||||
string auditAction,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var current = await PickingTaskQueries.GetAsync(connection, tx, taskId, ct)
|
||||
?? throw new InvalidOperationException("Picking task disappeared during transaction.");
|
||||
var response = new Response(accepted, false, current, feedback, message);
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into wms.scan_receipts(
|
||||
id, task_id, idempotency_key, barcode, source, occurred_at, actor, response_payload)
|
||||
values (@Id,@TaskId,@Key,@Barcode,@Source,@OccurredAt,@Actor,cast(@Response as jsonb));
|
||||
|
||||
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
|
||||
values (@AuditId,'WmsPickingTask',@TaskId,@Action,@Actor,now(),
|
||||
jsonb_build_object('barcode',@Barcode,'source',@Source,'idempotencyKey',@Key));
|
||||
""", new {
|
||||
Id = Guid.NewGuid(),
|
||||
AuditId = Guid.NewGuid(),
|
||||
TaskId = taskId,
|
||||
Key = request.IdempotencyKey,
|
||||
request.Barcode,
|
||||
request.Source,
|
||||
request.OccurredAt,
|
||||
Actor = actor,
|
||||
Response = json,
|
||||
Action = auditAction,
|
||||
}, tx, cancellationToken: ct));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record TaskRow(Guid Id, string TaskNo, string Status, long Version);
|
||||
private sealed record LineRow(
|
||||
Guid LineId,
|
||||
int LineNo,
|
||||
string LocationCode,
|
||||
string LocationBarcode,
|
||||
bool LocationConfirmed,
|
||||
Guid ItemId,
|
||||
string Barcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Modules.WMS.Picking.Scan;
|
||||
|
||||
public sealed record Request(
|
||||
string Barcode,
|
||||
string Source,
|
||||
string IdempotencyKey,
|
||||
long ExpectedVersion,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed record Response(
|
||||
bool Accepted,
|
||||
bool Duplicate,
|
||||
Modules.WMS.Picking.Shared.PickingTaskDto Task,
|
||||
string Feedback,
|
||||
string Message);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public sealed record PickingLineDto(
|
||||
Guid LineId,
|
||||
int LineNo,
|
||||
string LocationCode,
|
||||
Guid ItemId,
|
||||
string ItemCode,
|
||||
string ItemName,
|
||||
string? ItemOption,
|
||||
string Barcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty,
|
||||
decimal RemainingQty);
|
||||
|
||||
public sealed record PickingTaskDto(
|
||||
Guid TaskId,
|
||||
string TaskNo,
|
||||
string Stage,
|
||||
string Status,
|
||||
int CompletedLines,
|
||||
int TotalLines,
|
||||
decimal CompletedQty,
|
||||
decimal TotalQty,
|
||||
long Version,
|
||||
PickingLineDto? CurrentLine,
|
||||
string? Message = null);
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public enum PickingScanDecisionKind
|
||||
{
|
||||
RejectLocation,
|
||||
AcceptLocation,
|
||||
RejectItem,
|
||||
AcceptItem
|
||||
}
|
||||
|
||||
public sealed record PickingScanState(
|
||||
bool LocationConfirmed,
|
||||
string LocationCode,
|
||||
string LocationBarcode,
|
||||
string ItemBarcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty);
|
||||
|
||||
public sealed record PickingScanDecision(
|
||||
PickingScanDecisionKind Kind,
|
||||
bool Accepted,
|
||||
string Feedback,
|
||||
string Message,
|
||||
decimal? NewPickedQty = null,
|
||||
bool LineCompleted = false)
|
||||
{
|
||||
public string AuditAction => Kind switch
|
||||
{
|
||||
PickingScanDecisionKind.RejectLocation => "LocationRejected",
|
||||
PickingScanDecisionKind.AcceptLocation => "LocationAccepted",
|
||||
PickingScanDecisionKind.RejectItem => "ItemRejected",
|
||||
PickingScanDecisionKind.AcceptItem => "ItemAccepted",
|
||||
_ => "BarcodeScanned"
|
||||
};
|
||||
}
|
||||
|
||||
public static class PickingScanStateMachine
|
||||
{
|
||||
public static PickingScanDecision Decide(PickingScanState state, string scannedBarcode)
|
||||
{
|
||||
var barcode = scannedBarcode.Trim();
|
||||
|
||||
if (!state.LocationConfirmed)
|
||||
{
|
||||
if (!string.Equals(barcode, state.LocationBarcode, StringComparison.OrdinalIgnoreCase))
|
||||
return new(
|
||||
PickingScanDecisionKind.RejectLocation,
|
||||
false,
|
||||
"error",
|
||||
$"잘못된 위치입니다. {state.LocationCode} 위치로 이동하세요.");
|
||||
|
||||
return new(
|
||||
PickingScanDecisionKind.AcceptLocation,
|
||||
true,
|
||||
"success",
|
||||
"위치를 확인했습니다. 상품을 스캔하세요.");
|
||||
}
|
||||
|
||||
if (!string.Equals(barcode, state.ItemBarcode, StringComparison.OrdinalIgnoreCase))
|
||||
return new(
|
||||
PickingScanDecisionKind.RejectItem,
|
||||
false,
|
||||
"error",
|
||||
"다른 상품입니다. 화면의 품목과 바코드를 확인하세요.");
|
||||
|
||||
var nextQty = Math.Min(state.PickedQty + 1m, state.RequiredQty);
|
||||
var completed = nextQty >= state.RequiredQty;
|
||||
return new(
|
||||
PickingScanDecisionKind.AcceptItem,
|
||||
true,
|
||||
"success",
|
||||
completed ? "현재 품목을 완료했습니다. 다음 작업을 진행하세요." : "1개 피킹했습니다.",
|
||||
nextQty,
|
||||
completed);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public static class PickingTaskQueries
|
||||
{
|
||||
public static async Task<PickingTaskDto?> GetAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction? transaction,
|
||||
Guid taskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string taskSql = """
|
||||
select id as TaskId,
|
||||
task_no as TaskNo,
|
||||
status as Status,
|
||||
version as Version
|
||||
from wms.picking_tasks
|
||||
where id = @TaskId;
|
||||
""";
|
||||
|
||||
var task = await connection.QuerySingleOrDefaultAsync<TaskRow>(
|
||||
new CommandDefinition(taskSql, new { TaskId = taskId }, transaction, cancellationToken: ct));
|
||||
if (task is null) return null;
|
||||
|
||||
const string linesSql = """
|
||||
select l.id as LineId,
|
||||
l.line_no as LineNo,
|
||||
l.location_code as LocationCode,
|
||||
l.item_id as ItemId,
|
||||
i.code as ItemCode,
|
||||
i.name as ItemName,
|
||||
l.item_option as ItemOption,
|
||||
l.barcode as Barcode,
|
||||
l.required_qty as RequiredQty,
|
||||
l.picked_qty as PickedQty,
|
||||
greatest(l.required_qty - l.picked_qty, 0) as RemainingQty,
|
||||
l.status as Status,
|
||||
l.location_confirmed as LocationConfirmed
|
||||
from wms.picking_lines l
|
||||
join catalog.items i on i.id = l.item_id
|
||||
where l.task_id = @TaskId
|
||||
order by l.line_no;
|
||||
""";
|
||||
|
||||
var lines = (await connection.QueryAsync<LineRow>(
|
||||
new CommandDefinition(linesSql, new { TaskId = taskId }, transaction, cancellationToken: ct))).AsList();
|
||||
|
||||
var current = lines.FirstOrDefault(x => x.Status != "COMPLETED");
|
||||
var completedLines = lines.Count(x => x.Status == "COMPLETED");
|
||||
var totalQty = lines.Sum(x => x.RequiredQty);
|
||||
var completedQty = lines.Sum(x => x.PickedQty);
|
||||
|
||||
var stage = task.Status switch
|
||||
{
|
||||
"READY" => "ready",
|
||||
"COMPLETED" => "completed",
|
||||
"BLOCKED" => "blocked",
|
||||
_ when current is null => "completed",
|
||||
_ when current.LocationConfirmed => "await-item",
|
||||
_ => "await-location"
|
||||
};
|
||||
|
||||
return new PickingTaskDto(
|
||||
task.TaskId,
|
||||
task.TaskNo,
|
||||
stage,
|
||||
task.Status,
|
||||
completedLines,
|
||||
lines.Count,
|
||||
completedQty,
|
||||
totalQty,
|
||||
task.Version,
|
||||
current is null ? null : new PickingLineDto(
|
||||
current.LineId,
|
||||
current.LineNo,
|
||||
current.LocationCode,
|
||||
current.ItemId,
|
||||
current.ItemCode,
|
||||
current.ItemName,
|
||||
current.ItemOption,
|
||||
current.Barcode,
|
||||
current.RequiredQty,
|
||||
current.PickedQty,
|
||||
current.RemainingQty));
|
||||
}
|
||||
|
||||
private sealed record TaskRow(Guid TaskId, string TaskNo, string Status, long Version);
|
||||
private sealed record LineRow(
|
||||
Guid LineId,
|
||||
int LineNo,
|
||||
string LocationCode,
|
||||
Guid ItemId,
|
||||
string ItemCode,
|
||||
string ItemName,
|
||||
string? ItemOption,
|
||||
string Barcode,
|
||||
decimal RequiredQty,
|
||||
decimal PickedQty,
|
||||
decimal RemainingQty,
|
||||
string Status,
|
||||
bool LocationConfirmed = false);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Modules.WMS.Picking.Shared;
|
||||
|
||||
public static class PickingTransitions
|
||||
{
|
||||
public static async Task AutoConfirmNextSameLocationAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
Guid taskId,
|
||||
string completedLocation,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update wms.picking_lines
|
||||
set location_confirmed=true, updated_at=now()
|
||||
where id = (
|
||||
select id
|
||||
from wms.picking_lines
|
||||
where task_id=@TaskId
|
||||
and status <> 'COMPLETED'
|
||||
order by line_no
|
||||
limit 1
|
||||
)
|
||||
and location_code=@LocationCode;
|
||||
""", new { TaskId = taskId, LocationCode = completedLocation }, transaction, cancellationToken: ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Dapper;
|
||||
using FastEndpoints;
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Npgsql;
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Modules.WMS.Picking.Start;
|
||||
|
||||
public sealed record StartPickingRequest(long ExpectedVersion);
|
||||
|
||||
public sealed class Endpoint(NpgsqlDataSource dataSource)
|
||||
: Endpoint<StartPickingRequest, PickingTaskDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/wms/picking/tasks/{taskId:guid}/start");
|
||||
Permissions("wms.picking.execute");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(StartPickingRequest req, CancellationToken ct)
|
||||
{
|
||||
var taskId = Route<Guid>("taskId");
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
update wms.picking_tasks
|
||||
set status = 'IN_PROGRESS',
|
||||
started_at = coalesce(started_at, now()),
|
||||
version = version + 1,
|
||||
updated_at = now()
|
||||
where id = @TaskId
|
||||
and status = 'READY'
|
||||
and version = @ExpectedVersion
|
||||
returning version;
|
||||
""";
|
||||
|
||||
var version = await connection.QuerySingleOrDefaultAsync<long?>(
|
||||
new CommandDefinition(sql, new { TaskId = taskId, req.ExpectedVersion }, tx, cancellationToken: ct));
|
||||
|
||||
if (version is null)
|
||||
{
|
||||
var current = await connection.QuerySingleOrDefaultAsync<long?>(
|
||||
new CommandDefinition("select version from wms.picking_tasks where id=@TaskId", new { TaskId = taskId }, tx, cancellationToken: ct));
|
||||
await tx.RollbackAsync(ct);
|
||||
await Send.ResponseAsync(KbxConflictProblem.Version(current), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into audit.entries(id, aggregate_type, aggregate_id, action, actor, occurred_at, data)
|
||||
values (@Id, 'WmsPickingTask', @TaskId, 'PickingStarted', @Actor, now(), '{}'::jsonb);
|
||||
""", new { Id = Guid.NewGuid(), TaskId = taskId, Actor = User.Identity?.Name ?? "unknown" }, tx, cancellationToken: ct));
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
var task = await PickingTaskQueries.GetAsync(connection, null, taskId, ct);
|
||||
await SendOkAsync(task!, ct);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using Modules.WMS.Picking.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace Modules.WMS.Picking.Tests;
|
||||
|
||||
public sealed class PickingScanStateMachineTests
|
||||
{
|
||||
[Fact]
|
||||
public void LocationMustBeConfirmedBeforeItem()
|
||||
{
|
||||
var state = new PickingScanState(false, "A-03-02", "LOC-A-03-02", "880123", 2, 0);
|
||||
var result = PickingScanStateMachine.Decide(state, "880123");
|
||||
|
||||
Assert.Equal(PickingScanDecisionKind.RejectLocation, result.Kind);
|
||||
Assert.False(result.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectLocationMovesToItemStageWithoutChangingQuantity()
|
||||
{
|
||||
var state = new PickingScanState(false, "A-03-02", "LOC-A-03-02", "880123", 2, 0);
|
||||
var result = PickingScanStateMachine.Decide(state, "LOC-A-03-02");
|
||||
|
||||
Assert.Equal(PickingScanDecisionKind.AcceptLocation, result.Kind);
|
||||
Assert.Null(result.NewPickedQty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectItemIncrementsExactlyOneUnit()
|
||||
{
|
||||
var state = new PickingScanState(true, "A-03-02", "LOC-A-03-02", "880123", 2, 0);
|
||||
var result = PickingScanStateMachine.Decide(state, "880123");
|
||||
|
||||
Assert.Equal(PickingScanDecisionKind.AcceptItem, result.Kind);
|
||||
Assert.Equal(1m, result.NewPickedQty);
|
||||
Assert.False(result.LineCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredQuantityCompletesLine()
|
||||
{
|
||||
var state = new PickingScanState(true, "A-03-02", "LOC-A-03-02", "880123", 2, 1);
|
||||
var result = PickingScanStateMachine.Decide(state, "880123");
|
||||
|
||||
Assert.Equal(2m, result.NewPickedQty);
|
||||
Assert.True(result.LineCompleted);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user