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,4 @@
using System.Text.Json;using Dapper;using FastEndpoints;using Npgsql;
namespace Modules.ERP.Items.Audit;
public sealed record AuditActor(string Type,string DisplayName);public sealed record AuditChange(string Field,string Label,object? Before,object? After);public sealed record AuditRow(Guid Id,DateTimeOffset OccurredAt,string Actor,string Action,string Data);public sealed record AuditEntry(Guid Id,DateTimeOffset OccurredAt,AuditActor Actor,string Action,IReadOnlyList<AuditChange>? Changes,string? Reason);
public sealed class Endpoint(NpgsqlDataSource dataSource):EndpointWithoutRequest<IReadOnlyList<AuditEntry>>{public override void Configure(){Get("/api/erp/items/{id}/audit");Permissions("erp.item.read");}public override async Task HandleAsync(CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);var rows=await c.QueryAsync<AuditRow>(new CommandDefinition("select id,occurred_at as OccurredAt,actor,action,data::text as Data from audit.entries where aggregate_type='Item' and aggregate_id=@Id order by occurred_at desc limit 100",new{Id=id},cancellationToken:ct));var result=rows.Select(r=>{using var d=JsonDocument.Parse(r.Data);List<AuditChange>? changes=null;if(d.RootElement.TryGetProperty("changes",out var arr)){changes=new();foreach(var x in arr.EnumerateArray())changes.Add(new(x.GetProperty("field").GetString()??"",x.GetProperty("label").GetString()??"",x.TryGetProperty("before",out var b)?b.ToString():null,x.TryGetProperty("after",out var a)?a.ToString():null));}return new AuditEntry(r.Id,r.OccurredAt,new("user",r.Actor),r.Action,changes,null);}).ToList();await Send.OkAsync(result,ct);}}
@@ -0,0 +1,5 @@
using Dapper;using FastEndpoints;using Npgsql;using Shared.Problems;
namespace Modules.ERP.Items.Deactivate;
public sealed record Request(long Version);
public sealed record Response(Guid Id,long Version,string Status);
public sealed class Endpoint(NpgsqlDataSource dataSource):Endpoint<Request,Response>{public override void Configure(){Post("/api/erp/items/{id}/deactivate");Permissions("erp.item.write");}public override async Task HandleAsync(Request req,CancellationToken ct){var id=Route<Guid>("id");await using var c=await dataSource.OpenConnectionAsync(ct);await using var tx=await c.BeginTransactionAsync(ct);var current=await c.QuerySingleOrDefaultAsync<(long Version,bool Active)>(new CommandDefinition("select d.version as Version,i.is_active as Active from catalog.items i join catalog.item_details d on d.item_id=i.id where i.id=@Id for update",new{Id=id},tx,cancellationToken:ct));if(current.Version==0){await Send.ResponseAsync(KbxNotFoundProblem.Create("ITEM_NOT_FOUND","품목을 찾을 수 없습니다."),404,cancellation:ct);return;}if(current.Version!=req.Version){await Send.ResponseAsync(KbxConflictProblem.Version(current.Version),409,cancellation:ct);return;}var next=current.Version+1;var actor=User.Identity?.Name??"unknown";await c.ExecuteAsync(new CommandDefinition(@"update catalog.items set is_active=false where id=@Id;update catalog.item_details set version=@Version,updated_at=now(),updated_by=@Actor where item_id=@Id;update erp_item_search_projection set active=false,version=@Version where id=@Id;insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values(@AuditId,'Item',@Id,'ITEM_DEACTIVATED',@Actor,now(),jsonb_build_object('version',@Version));insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values(@EventId,'ItemDeactivated',@Id,jsonb_build_object('itemId',@Id,'version',@Version),now(),'PENDING');",new{Id=id,Version=next,Actor=actor,AuditId=Guid.NewGuid(),EventId=Guid.NewGuid()},tx,cancellationToken:ct));await tx.CommitAsync(ct);await Send.OkAsync(new(id,next,"사용중지"),ct);}}
@@ -0,0 +1,32 @@
using Dapper;
using FastEndpoints;
using Npgsql;
using Modules.ERP.Items.Search;
namespace Modules.ERP.Items.Get;
public sealed class Request { public Guid Id { get; init; } }
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<Request, ItemMasterRow>
{
public override void Configure()
{
Get("/api/erp/items/{id}");
Permissions("erp.item.read");
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
const string sql = """
select id, code, name, category_name as CategoryName, specification, unit, barcode,
default_warehouse_id as DefaultWarehouseId, default_warehouse_name as DefaultWarehouseName,
lot_managed as LotManaged, expiry_managed as ExpiryManaged, active, version
from erp_item_search_projection
where id = @Id;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<ItemMasterRow>(new CommandDefinition(sql, new { req.Id }, cancellationToken: ct));
if (row is null) { await Send.NotFoundAsync(ct); return; }
await Send.OkAsync(row, ct);
}
}
@@ -0,0 +1,4 @@
using FastEndpoints;
using Shared.Problems;
namespace Modules.ERP.Items.Save;
public sealed class CreateEndpoint(Handler handler):Endpoint<ItemSaveRequest,ItemSaveResponse>{public override void Configure(){Post("/api/erp/items");Permissions("erp.item.create");}public override async Task HandleAsync(ItemSaveRequest req,CancellationToken ct){var r=await handler.HandleAsync(req with { Id=null, Version=null },User,ct);if(r.Problem is KbxValidationProblem v){await Send.ResponseAsync(v,400,cancellation:ct);return;}if(r.Problem is KbxBusinessProblem b){await Send.ResponseAsync(b,409,cancellation:ct);return;}await Send.OkAsync(r.Response!,ct);}}
@@ -0,0 +1,75 @@
using System.Data;
using System.Security.Claims;
using System.Text.Json;
using Dapper;
using Npgsql;
using Shared.Problems;
namespace Modules.ERP.Items.Save;
public sealed record ItemSaveResult(ItemSaveResponse? Response, object? Problem)
{
public static ItemSaveResult Ok(ItemSaveResponse response) => new(response, null);
public static ItemSaveResult Fail(object problem) => new(null, problem);
}
public sealed class Handler(NpgsqlDataSource dataSource)
{
public async Task<ItemSaveResult> HandleAsync(ItemSaveRequest request, ClaimsPrincipal user, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);
var actor = user.Identity?.Name ?? "unknown";
var id = request.Id ?? Guid.NewGuid();
var isNew = request.Id is null;
long version = 1;
dynamic? before = null;
if (request.DefaultWarehouseId is not null)
{
var warehouseOk = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
"select exists(select 1 from inventory.warehouses where id=@Id and is_active=true)",
new { Id=request.DefaultWarehouseId }, tx, cancellationToken:ct));
if (!warehouseOk) return ItemSaveResult.Fail(KbxValidationProblem.Create(new("defaultWarehouseId", null, "WAREHOUSE_NOT_FOUND", "사용 가능한 기본창고가 아닙니다.")));
}
var duplicateCode = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
"select exists(select 1 from catalog.items where code=@Code and id<>@Id)", new { request.Code, Id=id }, tx, cancellationToken:ct));
if (duplicateCode) return ItemSaveResult.Fail(KbxValidationProblem.Create(new("code", null, "ITEM_CODE_DUPLICATE", "이미 사용 중인 품목코드입니다.")));
if (!string.IsNullOrWhiteSpace(request.Barcode))
{
var duplicateBarcode = await connection.ExecuteScalarAsync<bool>(new CommandDefinition(
"select exists(select 1 from catalog.item_details where barcode=@Barcode and item_id<>@Id)", new { Barcode=request.Barcode, Id=id }, tx, cancellationToken:ct));
if (duplicateBarcode) return ItemSaveResult.Fail(KbxValidationProblem.Create(new("barcode", null, "BARCODE_DUPLICATE", "이미 다른 품목에서 사용 중인 바코드입니다.")));
}
if (isNew)
{
await connection.ExecuteAsync(new CommandDefinition("insert into catalog.items(id,code,name,is_active) values(@Id,@Code,@Name,true)", new { Id=id, request.Code, request.Name }, tx, cancellationToken:ct));
await connection.ExecuteAsync(new CommandDefinition(@"insert into catalog.item_details(item_id,category_name,specification,unit,barcode,default_warehouse_id,lot_managed,expiry_managed,version,created_by)
values(@Id,@CategoryName,@Specification,@Unit,@Barcode,@DefaultWarehouseId,@LotManaged,@ExpiryManaged,1,@Actor)", new { Id=id, CategoryName=request.CategoryName??"", Specification=request.Specification??"", request.Unit, Barcode=request.Barcode??"", request.DefaultWarehouseId, request.LotManaged, request.ExpiryManaged, Actor=actor }, tx, cancellationToken:ct));
}
else
{
before = await connection.QuerySingleOrDefaultAsync(new CommandDefinition(@"select i.code,i.name,i.is_active as active,d.category_name,d.specification,d.unit,d.barcode,d.default_warehouse_id,d.lot_managed,d.expiry_managed,d.version
from catalog.items i join catalog.item_details d on d.item_id=i.id where i.id=@Id for update", new { Id=id }, tx, cancellationToken:ct));
if (before is null) return ItemSaveResult.Fail(KbxBusinessProblem.Create("ITEM_NOT_FOUND", "품목을 찾을 수 없습니다."));
if ((long)before.version != request.Version) return ItemSaveResult.Fail(KbxConflictProblem.Version((long)before.version));
if (!(bool)before.active) return ItemSaveResult.Fail(KbxBusinessProblem.Create("ITEM_INACTIVE", "사용중지된 품목은 수정할 수 없습니다."));
version=(long)before.version+1;
await connection.ExecuteAsync(new CommandDefinition("update catalog.items set code=@Code,name=@Name where id=@Id", new { Id=id, request.Code, request.Name }, tx, cancellationToken:ct));
await connection.ExecuteAsync(new CommandDefinition(@"update catalog.item_details set category_name=@CategoryName,specification=@Specification,unit=@Unit,barcode=@Barcode,default_warehouse_id=@DefaultWarehouseId,lot_managed=@LotManaged,expiry_managed=@ExpiryManaged,version=@Version,updated_at=now(),updated_by=@Actor where item_id=@Id", new { Id=id, CategoryName=request.CategoryName??"", Specification=request.Specification??"", request.Unit, Barcode=request.Barcode??"", request.DefaultWarehouseId, request.LotManaged, request.ExpiryManaged, Version=version, Actor=actor }, tx, cancellationToken:ct));
}
var warehouseName = request.DefaultWarehouseId is null ? "" : await connection.ExecuteScalarAsync<string?>(new CommandDefinition("select name from inventory.warehouses where id=@Id", new { Id=request.DefaultWarehouseId }, tx, cancellationToken:ct)) ?? "";
await connection.ExecuteAsync(new CommandDefinition(@"insert into erp_item_search_projection(id,code,name,category_name,specification,unit,barcode,default_warehouse_id,default_warehouse_name,lot_managed,expiry_managed,active,version,search_text)
values(@Id,@Code,@Name,@CategoryName,@Specification,@Unit,@Barcode,@DefaultWarehouseId,@DefaultWarehouseName,@LotManaged,@ExpiryManaged,true,@Version,lower(concat_ws(' ',@Code,@Name,@CategoryName,@Barcode)))
on conflict(id) do update set code=excluded.code,name=excluded.name,category_name=excluded.category_name,specification=excluded.specification,unit=excluded.unit,barcode=excluded.barcode,default_warehouse_id=excluded.default_warehouse_id,default_warehouse_name=excluded.default_warehouse_name,lot_managed=excluded.lot_managed,expiry_managed=excluded.expiry_managed,active=true,version=excluded.version,search_text=excluded.search_text", new { Id=id, request.Code, request.Name, CategoryName=request.CategoryName??"", Specification=request.Specification??"", request.Unit, Barcode=request.Barcode??"", request.DefaultWarehouseId, DefaultWarehouseName=warehouseName, request.LotManaged, request.ExpiryManaged, Version=version }, tx, cancellationToken:ct));
var changes = new[] { new { field="code", label="품목코드", before=isNew?null:(object?)before!.code, after=request.Code }, new { field="name", label="품목명", before=isNew?null:(object?)before!.name, after=request.Name }, new { field="unit", label="단위", before=isNew?null:(object?)before!.unit, after=request.Unit } };
await connection.ExecuteAsync(new CommandDefinition(@"insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data) values(@AuditId,'Item',@Id,@Action,@Actor,now(),cast(@Data as jsonb));
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status) values(@EventId,'ItemChanged',@Id,jsonb_build_object('itemId',@Id,'version',@Version),now(),'PENDING');", new { AuditId=Guid.NewGuid(), EventId=Guid.NewGuid(), Id=id, Action=isNew?"ITEM_CREATED":"ITEM_SAVED", Actor=actor, Data=JsonSerializer.Serialize(new { version, changes }), Version=version }, tx, cancellationToken:ct));
await tx.CommitAsync(ct);
return ItemSaveResult.Ok(new(id,version,"사용"));
}
}
@@ -0,0 +1,16 @@
namespace Modules.ERP.Items.Save;
public sealed record ItemSaveRequest(
Guid? Id,
long? Version,
string Code,
string Name,
string? CategoryName,
string? Specification,
string Unit,
string? Barcode,
Guid? DefaultWarehouseId,
bool LotManaged,
bool ExpiryManaged);
public sealed record ItemSaveResponse(Guid Id, long Version, string Status);
@@ -0,0 +1,4 @@
using FastEndpoints;
using Shared.Problems;
namespace Modules.ERP.Items.Save;
public sealed class UpdateEndpoint(Handler handler):Endpoint<ItemSaveRequest,ItemSaveResponse>{public override void Configure(){Put("/api/erp/items/{id}");Permissions("erp.item.write");}public override async Task HandleAsync(ItemSaveRequest req,CancellationToken ct){var routeId=Route<Guid>("id");var r=await handler.HandleAsync(req with { Id=routeId },User,ct);if(r.Problem is KbxValidationProblem v){await Send.ResponseAsync(v,400,cancellation:ct);return;}if(r.Problem is KbxBusinessProblem b){await Send.ResponseAsync(b,409,cancellation:ct);return;}if(r.Problem is KbxConflictProblem c){await Send.ResponseAsync(c,409,cancellation:ct);return;}await Send.OkAsync(r.Response!,ct);}}
@@ -0,0 +1,15 @@
using FastEndpoints;
namespace Modules.ERP.Items.Search;
public sealed class Endpoint(SearchItemsHandler handler) : Endpoint<SearchItemsRequest, SearchItemsResponse>
{
public override void Configure()
{
Get("/api/erp/items");
Permissions("erp.item.read");
}
public override async Task HandleAsync(SearchItemsRequest req, CancellationToken ct)
=> await Send.OkAsync(await handler.HandleAsync(req, ct), ct);
}
@@ -0,0 +1,46 @@
using Dapper;
using Npgsql;
namespace Modules.ERP.Items.Search;
public sealed class SearchItemsHandler(NpgsqlDataSource dataSource)
{
public async Task<SearchItemsResponse> HandleAsync(SearchItemsRequest request, CancellationToken ct)
{
var page = Math.Max(request.Page, 1);
var pageSize = Math.Clamp(request.PageSize, 1, 500);
var offset = (page - 1) * pageSize;
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
const string sql = """
select
p.id,
p.code,
p.name,
p.category_name as CategoryName,
p.specification,
p.unit,
p.barcode,
p.default_warehouse_id as DefaultWarehouseId,
p.default_warehouse_name as DefaultWarehouseName,
p.lot_managed as LotManaged,
p.expiry_managed as ExpiryManaged,
p.active,
p.version
from erp_item_search_projection p
where (@Keyword is null or p.search_text ilike '%' || @Keyword || '%')
order by p.code
limit @PageSize offset @Offset;
select count(*)::int
from erp_item_search_projection p
where (@Keyword is null or p.search_text ilike '%' || @Keyword || '%');
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
using var multi = await connection.QueryMultipleAsync(new CommandDefinition(sql, new { Keyword = keyword, PageSize = pageSize, Offset = offset }, cancellationToken: ct));
var items = (await multi.ReadAsync<ItemMasterRow>()).AsList();
var count = await multi.ReadSingleAsync<int>();
return new(items, count);
}
}
@@ -0,0 +1,6 @@
namespace Modules.ERP.Items.Search;
public sealed record SearchItemsRequest(
string? Keyword,
int Page = 1,
int PageSize = 200);
@@ -0,0 +1,20 @@
namespace Modules.ERP.Items.Search;
public sealed record ItemMasterRow(
Guid Id,
string Code,
string Name,
string CategoryName,
string Specification,
string Unit,
string Barcode,
Guid? DefaultWarehouseId,
string DefaultWarehouseName,
bool LotManaged,
bool ExpiryManaged,
bool Active,
long Version);
public sealed record SearchItemsResponse(
IReadOnlyList<ItemMasterRow> Items,
int TotalCount);