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,47 @@
using Dapper;
using FastEndpoints;
using Npgsql;
namespace Modules.ERP.Inventory.Search;
public sealed record SearchInventoryRequest(string? Keyword, int Page = 1, int PageSize = 200);
public sealed record InventoryItemRow(Guid ItemId, string ItemCode, string ItemName, string Specification, decimal TotalQty, decimal AvailableQty, decimal AllocatedQty, decimal HoldQty);
public sealed record SearchInventoryResponse(IReadOnlyList<InventoryItemRow> Items, int TotalCount);
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SearchInventoryRequest, SearchInventoryResponse>
{
public override void Configure()
{
Get("/api/erp/inventory");
Permissions("erp.inventory.read");
}
public override async Task HandleAsync(SearchInventoryRequest req, CancellationToken ct)
{
var page = Math.Max(req.Page, 1);
var pageSize = Math.Clamp(req.PageSize, 1, 500);
var keyword = string.IsNullOrWhiteSpace(req.Keyword) ? null : req.Keyword.Trim();
var offset = (page - 1) * pageSize;
const string sql = """
select item_id as ItemId, item_code as ItemCode, item_name as ItemName, specification,
sum(on_hand_qty) as TotalQty,
sum(available_qty) as AvailableQty,
sum(allocated_qty) as AllocatedQty,
sum(hold_qty) as HoldQty
from erp_inventory_snapshot_projection
where (@Keyword is null or search_text ilike '%' || @Keyword || '%')
group by item_id, item_code, item_name, specification
order by item_code
limit @PageSize offset @Offset;
select count(distinct item_id)::int
from erp_inventory_snapshot_projection
where (@Keyword is null or 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<InventoryItemRow>()).AsList();
var totalCount = await multi.ReadSingleAsync<int>();
await Send.OkAsync(new SearchInventoryResponse(items, totalCount), ct);
}
}
@@ -0,0 +1,32 @@
using Dapper;
using FastEndpoints;
using Npgsql;
namespace Modules.ERP.Inventory.Search;
public sealed class InventoryHistoryRequest { public Guid ItemId { get; init; } public int PageSize { get; init; } = 100; }
public sealed record InventoryHistoryRow(Guid EntryId, DateTimeOffset OccurredAt, string BusinessType, string ReferenceNo, string WarehouseName, string LocationCode, decimal InboundQty, decimal OutboundQty, decimal BalanceQty, string Actor);
public sealed record InventoryHistoryResponse(IReadOnlyList<InventoryHistoryRow> Items);
public sealed class HistoryEndpoint(NpgsqlDataSource dataSource) : Endpoint<InventoryHistoryRequest, InventoryHistoryResponse>
{
public override void Configure() { Get("/api/erp/inventory/{itemId}/history"); Permissions("erp.inventory.read"); }
public override async Task HandleAsync(InventoryHistoryRequest req, CancellationToken ct)
{
var pageSize=Math.Clamp(req.PageSize,1,500);
const string sql="""
select entry_id as EntryId, occurred_at as OccurredAt, business_type as BusinessType,
reference_no as ReferenceNo, warehouse_name as WarehouseName,
coalesce(location_code,'-') as LocationCode, inbound_qty as InboundQty,
outbound_qty as OutboundQty, balance_qty as BalanceQty, actor as Actor
from erp_inventory_ledger_projection
where item_id=@ItemId
order by occurred_at desc, entry_id
limit @PageSize;
""";
await using var connection=await dataSource.OpenConnectionAsync(ct);
var items=(await connection.QueryAsync<InventoryHistoryRow>(new CommandDefinition(sql,new{req.ItemId,PageSize=pageSize},cancellationToken:ct))).AsList();
await Send.OkAsync(new InventoryHistoryResponse(items),ct);
}
}
@@ -0,0 +1,37 @@
using Dapper;
using FastEndpoints;
using Npgsql;
namespace Modules.ERP.Inventory.Search;
public sealed class LocationsRequest { public Guid ItemId { get; init; } }
public sealed record InventoryLocationRow(string Key, string WarehouseName, string LocationCode, decimal OnHandQty, decimal AllocatedQty, decimal AvailableQty, decimal HoldQty);
public sealed record InventoryLocationsResponse(IReadOnlyList<InventoryLocationRow> Items);
public sealed class LocationsEndpoint(NpgsqlDataSource dataSource) : Endpoint<LocationsRequest, InventoryLocationsResponse>
{
public override void Configure()
{
Get("/api/erp/inventory/{itemId}/locations");
Permissions("erp.inventory.read");
}
public override async Task HandleAsync(LocationsRequest req, CancellationToken ct)
{
const string sql = """
select snapshot_key as Key,
warehouse_name as WarehouseName,
coalesce(location_code, '-') as LocationCode,
on_hand_qty as OnHandQty,
allocated_qty as AllocatedQty,
available_qty as AvailableQty,
hold_qty as HoldQty
from erp_inventory_snapshot_projection
where item_id = @ItemId
order by warehouse_name, location_code nulls first;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var items = (await connection.QueryAsync<InventoryLocationRow>(new CommandDefinition(sql, new { req.ItemId }, cancellationToken: ct))).AsList();
await Send.OkAsync(new InventoryLocationsResponse(items), ct);
}
}
@@ -0,0 +1,11 @@
using KBX.Shared.Workflow;
namespace KBX.Modules.ERP.InventoryMove.Workflow;
public static class InventoryMoveWorkflow
{
public static readonly WorkflowTransition[] Transitions=[
new("confirm",new HashSet<string>{"DRAFT"},"CONFIRMED","erp.inventory.move.confirm"),
new("ship",new HashSet<string>{"CONFIRMED"},"IN_TRANSIT","erp.inventory.move.ship"),
new("receive",new HashSet<string>{"IN_TRANSIT"},"RECEIVED","erp.inventory.move.receive")
];
}
@@ -0,0 +1,76 @@
using System.Security.Claims;
using System.Text.Json;
using Dapper;
using FastEndpoints;
using Npgsql;
using Shared.Problems;
namespace Modules.ERP.ItemPrices.BulkSave;
public sealed record SaveItemPriceRow(string ClientId, Guid ItemId, DateOnly EffectiveDate, decimal UnitPrice, string? Remark);
public sealed record SaveItemPricesRequest(IReadOnlyList<SaveItemPriceRow> Rows);
public sealed record SaveItemPricesResponse(int Requested, int Saved, int Created, int Updated);
public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint<SaveItemPricesRequest, SaveItemPricesResponse>
{
public override void Configure() { Post("/api/erp/item-prices/bulk"); Permissions("erp.item.price.write"); }
public override async Task HandleAsync(SaveItemPricesRequest req, CancellationToken ct)
{
var key=HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
if(string.IsNullOrWhiteSpace(key)){await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"IDEMPOTENCY_KEY_REQUIRED","안전한 재처리를 위해 Idempotency-Key가 필요합니다.")),400,cancellation:ct);return;}
if(req.Rows is null||req.Rows.Count==0){await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"PRICE_ROWS_REQUIRED","저장할 단가를 한 건 이상 입력하세요.")),400,cancellation:ct);return;}
if(req.Rows.Count>5000){await Send.ResponseAsync(KbxValidationProblem.Create(new KbxValidationError(null,null,"PRICE_ROWS_LIMIT","한 번에 최대 5,000건까지 저장할 수 있습니다.")),400,cancellation:ct);return;}
var errors=new List<KbxValidationError>();
foreach(var row in req.Rows){if(row.UnitPrice<0)errors.Add(new("unitPrice",row.ClientId,"PRICE_NONNEGATIVE","단가는 0 이상이어야 합니다."));}
foreach(var group in req.Rows.GroupBy(x=>new{x.ItemId,x.EffectiveDate}).Where(x=>x.Count()>1)) foreach(var row in group) errors.Add(new("effectiveDate",row.ClientId,"DUPLICATE_ITEM_DATE","같은 품목과 적용일이 중복되었습니다."));
if(errors.Count>0){await Send.ResponseAsync(KbxValidationProblem.Create(errors.ToArray()),400,cancellation:ct);return;}
await using var connection=await dataSource.OpenConnectionAsync(ct);
await using var tx=await connection.BeginTransactionAsync(ct);
var replay=await connection.QuerySingleOrDefaultAsync<string?>(new CommandDefinition("select response_json::text from kbx.command_receipts where operation_id=@OperationId and idempotency_key=@Key",new{OperationId="erp.itemPrices.bulkSave",Key=key},tx,cancellationToken:ct));
if(replay is not null){await tx.RollbackAsync(ct);await Send.OkAsync(JsonSerializer.Deserialize<SaveItemPricesResponse>(replay)!,ct);return;}
var ids=req.Rows.Select(x=>x.ItemId).Distinct().ToArray();
var active=(await connection.QueryAsync<Guid>(new CommandDefinition("select id from catalog.items where id=any(@Ids) and is_active=true",new{Ids=ids},tx,cancellationToken:ct))).ToHashSet();
foreach(var row in req.Rows.Where(x=>!active.Contains(x.ItemId)))errors.Add(new("itemCode",row.ClientId,"ITEM_NOT_FOUND","사용 가능한 품목이 아닙니다."));
if(errors.Count>0){await tx.RollbackAsync(ct);await Send.ResponseAsync(KbxValidationProblem.Create(errors.ToArray()),400,cancellation:ct);return;}
var actor=User.Identity?.Name??"unknown";
var rowsJson=JsonSerializer.Serialize(req.Rows.Select(x=>new{clientId=x.ClientId,itemId=x.ItemId,effectiveDate=x.EffectiveDate.ToString("yyyy-MM-dd"),unitPrice=x.UnitPrice,remark=x.Remark}));
var result=await connection.QuerySingleAsync<MutationResult>(new CommandDefinition("""
with input as materialized (
select x.client_id, x.item_id, x.effective_date, x.unit_price, nullif(trim(x.remark),'') as remark
from jsonb_to_recordset(cast(@RowsJson as jsonb)) as x(client_id text,item_id uuid,effective_date date,unit_price numeric,remark text)
), before_state as materialized (
select i.*, p.unit_price as old_unit_price, p.id as existing_id
from input i left join erp.item_prices p on p.item_id=i.item_id and p.effective_date=i.effective_date
), upserted as materialized (
insert into erp.item_prices(id,item_id,effective_date,unit_price,remark,version,created_at,created_by,updated_at,updated_by)
select coalesce(existing_id,gen_random_uuid()),item_id,effective_date,unit_price,remark,case when existing_id is null then 1 else 2 end,now(),@Actor,case when existing_id is null then null else now() end,case when existing_id is null then null else @Actor end
from before_state
on conflict(item_id,effective_date) do update set unit_price=excluded.unit_price,remark=excluded.remark,version=erp.item_prices.version+1,updated_at=now(),updated_by=@Actor
returning item_id,effective_date
), audit_insert as (
insert into audit.entries(id,aggregate_type,aggregate_id,action,actor,occurred_at,data)
select gen_random_uuid(),'Item',b.item_id,'ITEM_PRICE_SAVED',@Actor,now(),jsonb_build_object('effectiveDate',b.effective_date,'before',b.old_unit_price,'after',b.unit_price)
from before_state b returning 1
), outbox_insert as (
insert into integration.outbox(id,event_type,aggregate_id,payload,occurred_at,status)
select gen_random_uuid(),'ErpItemPriceChanged',b.item_id,jsonb_build_object('itemId',b.item_id,'effectiveDate',b.effective_date,'unitPrice',b.unit_price),now(),'PENDING'
from before_state b returning 1
)
select count(*)::int as Saved,
count(*) filter(where existing_id is null)::int as Created,
count(*) filter(where existing_id is not null)::int as Updated
from before_state;
""",new{RowsJson=rowsJson,Actor=actor},tx,cancellationToken:ct));
var response=new SaveItemPricesResponse(req.Rows.Count,result.Saved,result.Created,result.Updated);
await connection.ExecuteAsync(new CommandDefinition("insert into kbx.command_receipts(operation_id,idempotency_key,response_json) values(@OperationId,@Key,cast(@Response as jsonb))",new{OperationId="erp.itemPrices.bulkSave",Key=key,Response=JsonSerializer.Serialize(response)},tx,cancellationToken:ct));
await tx.CommitAsync(ct); await Send.OkAsync(response,ct);
}
private sealed record MutationResult(int Saved,int Created,int Updated);
}
@@ -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);
@@ -0,0 +1,10 @@
using KBX.Shared.Workflow;
namespace KBX.Modules.ERP.Purchases.Workflow;
public static class PurchaseWorkflow
{
public static readonly WorkflowTransition[] Transitions=[
new("confirm",new HashSet<string>{"DRAFT"},"CONFIRMED","erp.purchase.confirm"),
new("cancel",new HashSet<string>{"DRAFT","CONFIRMED"},"CANCELLED","erp.purchase.cancel",true)
];
}