V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user