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 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(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(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(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(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,"사용")); } }