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,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));
}
}