77 lines
6.4 KiB
C#
77 lines
6.4 KiB
C#
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);
|
|
}
|