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 Rows); public sealed record SaveItemPricesResponse(int Requested, int Saved, int Created, int Updated); public sealed class Endpoint(NpgsqlDataSource dataSource) : Endpoint { 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(); 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(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(replay)!,ct);return;} var ids=req.Rows.Select(x=>x.ItemId).Distinct().ToArray(); var active=(await connection.QueryAsync(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(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); }