V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -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);
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}