Repository navigation
feat(almoxarifado): implementar distribuição interna rastreável - #417
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4ceac3e30d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var existente=await c.ExecuteScalarAsync<long?>(new CommandDefinition("select id from sigov.almoxarifado_entrega where tenant_id=@T and entidade_id=@E and idempotency_key=@K",new{T=t,E=e,K=input.IdempotencyKey.Trim()},tx,cancellationToken:ct));if(existente.HasValue){await tx.CommitAsync(ct);return existente.Value;} | ||
| var status=await c.ExecuteScalarAsync<string?>(new CommandDefinition("select status from sigov.almoxarifado_requisicao where tenant_id=@T and entidade_id=@E and id=@R for update",new{T=t,E=e,R=req},tx,cancellationToken:ct));if(status!="APROVADA")throw new InvalidOperationException("Somente solicitação aprovada pode ser separada."); | ||
| var entrega=await c.ExecuteScalarAsync<long>(new CommandDefinition("insert into sigov.almoxarifado_entrega(tenant_id,entidade_id,requisicao_id,almoxarifado_id,idempotency_key,created_by) select @T,@E,@R,@L,@K,@U where exists(select 1 from sigov.almoxarifado_local where tenant_id=@T and entidade_id=@E and id=@L and ativo) returning id",new{T=t,E=e,R=req,L=input.AlmoxarifadoId,K=input.IdempotencyKey.Trim(),U=u},tx,cancellationToken:ct)); | ||
| foreach(var item in input.Itens){var x=await c.QuerySingleOrDefaultAsync<ItemSeparar>(new CommandDefinition("select ri.material_id MaterialId,ri.quantidade_solicitada-ri.quantidade_atendida-coalesce((select sum(ei.quantidade_separada) from sigov.almoxarifado_entrega_item ei join sigov.almoxarifado_entrega en on en.id=ei.entrega_id where ei.requisicao_item_id=ri.id and en.status='SEPARADA'),0) Pendente,coalesce(s.quantidade,0)-coalesce((select sum(ei.quantidade_separada) from sigov.almoxarifado_entrega_item ei join sigov.almoxarifado_entrega en on en.id=ei.entrega_id join sigov.almoxarifado_requisicao_item ri2 on ri2.id=ei.requisicao_item_id where en.tenant_id=@T and en.entidade_id=@E and en.almoxarifado_id=@L and ri2.material_id=ri.material_id and en.status='SEPARADA'),0) Disponivel from sigov.almoxarifado_requisicao_item ri left join sigov.almoxarifado_estoque s on s.tenant_id=ri.tenant_id and s.entidade_id=ri.entidade_id and s.almoxarifado_id=@L and s.material_id=ri.material_id where ri.tenant_id=@T and ri.entidade_id=@E and ri.requisicao_id=@R and ri.id=@I for update",new{T=t,E=e,L=input.AlmoxarifadoId,R=req,I=item.RequisicaoItemId},tx,cancellationToken:ct))??throw new KeyNotFoundException("Item não pertence à solicitação.");if(item.Quantidade>x.Pendente)throw new InvalidOperationException("Quantidade excede o saldo pendente da solicitação.");if(item.Quantidade>x.Disponivel)throw new InvalidOperationException($"Estoque disponível insuficiente. Disponível: {x.Disponivel}.");await c.ExecuteAsync(new CommandDefinition("insert into sigov.almoxarifado_entrega_item(tenant_id,entidade_id,entrega_id,requisicao_item_id,quantidade_separada,lote) values(@T,@E,@D,@I,@Q,@Lote)",new{T=t,E=e,D=entrega,I=item.RequisicaoItemId,Q=item.Quantidade,Lote=N(item.Lote)},tx,cancellationToken:ct));} |
There was a problem hiding this comment.
Qualify the row lock to make separation executable
Whenever a positive separation reaches this query, PostgreSQL rejects the unqualified FOR UPDATE because the selected relation includes the nullable side of a LEFT JOIN (almoxarifado_estoque). The transaction therefore rolls back before creating any delivery item, making separation fail through both the Web and API flows; lock only an eligible non-nullable relation explicitly and acquire the stock lock separately.
Useful? React with 👍 / 👎.
| var existente=await c.ExecuteScalarAsync<long?>(new CommandDefinition("select id from sigov.almoxarifado_entrega where tenant_id=@T and entidade_id=@E and idempotency_key=@K",new{T=t,E=e,K=input.IdempotencyKey.Trim()},tx,cancellationToken:ct));if(existente.HasValue){await tx.CommitAsync(ct);return existente.Value;} | ||
| var status=await c.ExecuteScalarAsync<string?>(new CommandDefinition("select status from sigov.almoxarifado_requisicao where tenant_id=@T and entidade_id=@E and id=@R for update",new{T=t,E=e,R=req},tx,cancellationToken:ct));if(status!="APROVADA")throw new InvalidOperationException("Somente solicitação aprovada pode ser separada."); | ||
| var entrega=await c.ExecuteScalarAsync<long>(new CommandDefinition("insert into sigov.almoxarifado_entrega(tenant_id,entidade_id,requisicao_id,almoxarifado_id,idempotency_key,created_by) select @T,@E,@R,@L,@K,@U where exists(select 1 from sigov.almoxarifado_local where tenant_id=@T and entidade_id=@E and id=@L and ativo) returning id",new{T=t,E=e,R=req,L=input.AlmoxarifadoId,K=input.IdempotencyKey.Trim(),U=u},tx,cancellationToken:ct)); | ||
| foreach(var item in input.Itens){var x=await c.QuerySingleOrDefaultAsync<ItemSeparar>(new CommandDefinition("select ri.material_id MaterialId,ri.quantidade_solicitada-ri.quantidade_atendida-coalesce((select sum(ei.quantidade_separada) from sigov.almoxarifado_entrega_item ei join sigov.almoxarifado_entrega en on en.id=ei.entrega_id where ei.requisicao_item_id=ri.id and en.status='SEPARADA'),0) Pendente,coalesce(s.quantidade,0)-coalesce((select sum(ei.quantidade_separada) from sigov.almoxarifado_entrega_item ei join sigov.almoxarifado_entrega en on en.id=ei.entrega_id join sigov.almoxarifado_requisicao_item ri2 on ri2.id=ei.requisicao_item_id where en.tenant_id=@T and en.entidade_id=@E and en.almoxarifado_id=@L and ri2.material_id=ri.material_id and en.status='SEPARADA'),0) Disponivel from sigov.almoxarifado_requisicao_item ri left join sigov.almoxarifado_estoque s on s.tenant_id=ri.tenant_id and s.entidade_id=ri.entidade_id and s.almoxarifado_id=@L and s.material_id=ri.material_id where ri.tenant_id=@T and ri.entidade_id=@E and ri.requisicao_id=@R and ri.id=@I for update",new{T=t,E=e,L=input.AlmoxarifadoId,R=req,I=item.RequisicaoItemId},tx,cancellationToken:ct))??throw new KeyNotFoundException("Item não pertence à solicitação.");if(item.Quantidade>x.Pendente)throw new InvalidOperationException("Quantidade excede o saldo pendente da solicitação.");if(item.Quantidade>x.Disponivel)throw new InvalidOperationException($"Estoque disponível insuficiente. Disponível: {x.Disponivel}.");await c.ExecuteAsync(new CommandDefinition("insert into sigov.almoxarifado_entrega_item(tenant_id,entidade_id,entrega_id,requisicao_item_id,quantidade_separada,lote) values(@T,@E,@D,@I,@Q,@Lote)",new{T=t,E=e,D=entrega,I=item.RequisicaoItemId,Q=item.Quantidade,Lote=N(item.Lote)},tx,cancellationToken:ct));} |
There was a problem hiding this comment.
Serialize stock reservations before reading availability
When two approved requests reserve the same material and location concurrently, both availability calculations can observe the same committed reservation set before either transaction commits; the request and item locks are disjoint, and merely waiting on an unchanged stock row does not refresh the statement snapshot. Both separations can consequently reserve the full balance, leaving one apparently valid delivery unable to be dispatched later. Lock the common stock key in a preceding command and calculate availability only after that lock is acquired.
Useful? React with 👍 / 👎.
|
|
||
| public async Task ExpedirAsync(long t,long e,long u,string corr,long entrega,CancellationToken ct) | ||
| { | ||
| await using var c=factory.CreateConnection();await c.OpenAsync(ct);await using var tx=await c.BeginTransactionAsync(ct);var cab=await c.QuerySingleOrDefaultAsync<EntregaEstado>(new CommandDefinition("select requisicao_id RequisicaoId,almoxarifado_id LocalId,status from sigov.almoxarifado_entrega where tenant_id=@T and entidade_id=@E and id=@D for update",new{T=t,E=e,D=entrega},tx,cancellationToken:ct))??throw new KeyNotFoundException("Entrega não encontrada.");if(cab.Status=="EXPEDIDA"){await tx.CommitAsync(ct);return;}if(cab.Status!="SEPARADA")throw new InvalidOperationException("Entrega não está pronta para expedição.");var itens=(await c.QueryAsync<ItemExpedir>(new CommandDefinition("select ei.requisicao_item_id RequisicaoItemId,ri.material_id MaterialId,ei.quantidade_separada Quantidade from sigov.almoxarifado_entrega_item ei join sigov.almoxarifado_requisicao_item ri on ri.id=ei.requisicao_item_id where ei.tenant_id=@T and ei.entidade_id=@E and ei.entrega_id=@D order by ei.id",new{T=t,E=e,D=entrega},tx,cancellationToken:ct))).AsList();foreach(var x in itens){await SaidaTx(c,tx,t,u,corr,new(e,cab.LocalId,x.MaterialId,x.Quantidade,"DISTRIBUICAO",entrega.ToString(),$"Expedição da entrega {entrega}"),cab.RequisicaoId,ct);await c.ExecuteAsync(new CommandDefinition("update sigov.almoxarifado_requisicao_item set quantidade_atendida=quantidade_atendida+@Q where tenant_id=@T and entidade_id=@E and id=@I and quantidade_atendida+@Q<=quantidade_solicitada",new{T=t,E=e,I=x.RequisicaoItemId,Q=x.Quantidade},tx,cancellationToken:ct));}await c.ExecuteAsync(new CommandDefinition("update sigov.almoxarifado_entrega set status='EXPEDIDA',expedida_em=now(),expedida_por=@U where tenant_id=@T and entidade_id=@E and id=@D",new{T=t,E=e,D=entrega,U=u},tx,cancellationToken:ct));var pendente=await c.ExecuteScalarAsync<bool>(new CommandDefinition("select exists(select 1 from sigov.almoxarifado_requisicao_item where tenant_id=@T and entidade_id=@E and requisicao_id=@R and quantidade_atendida<quantidade_solicitada)",new{T=t,E=e,R=cab.RequisicaoId},tx,cancellationToken:ct));await c.ExecuteAsync(new CommandDefinition("update sigov.almoxarifado_requisicao set status=@S,updated_at=now(),updated_by=@U where tenant_id=@T and entidade_id=@E and id=@R",new{T=t,E=e,R=cab.RequisicaoId,S=pendente?"APROVADA":"ATENDIDA",U=u},tx,cancellationToken:ct));await Evento(c,tx,t,e,entrega,"EXPEDIDA",new{cab.RequisicaoId},u,corr,ct);await tx.CommitAsync(ct); |
There was a problem hiding this comment.
Reject legacy fulfillment after separation
If the existing POST requisicoes/{id}/atender action is invoked after a delivery is separated but before it is dispatched, it still fulfills and deducts the entire approved request. This expedition path then checks only the delivery status, deducts the separated quantities again, ignores whether the guarded requisition-item update affected zero rows, and marks the delivery dispatched; with enough stock this silently records a duplicate withdrawal. Expedition must lock and validate the requisition state, and the legacy fulfillment path must reject requests that already have deliveries.
Useful? React with 👍 / 👎.
| { | ||
| if(string.IsNullOrWhiteSpace(input.IdempotencyKey)||input.Itens is null||input.Itens.Count==0||input.Itens.Any(x=>x.Quantidade<=0))throw new ArgumentException("Informe a chave da operação e itens com quantidade positiva."); | ||
| await using var c=factory.CreateConnection();await c.OpenAsync(ct);await using var tx=await c.BeginTransactionAsync(ct); | ||
| var existente=await c.ExecuteScalarAsync<long?>(new CommandDefinition("select id from sigov.almoxarifado_entrega where tenant_id=@T and entidade_id=@E and idempotency_key=@K",new{T=t,E=e,K=input.IdempotencyKey.Trim()},tx,cancellationToken:ct));if(existente.HasValue){await tx.CommitAsync(ct);return existente.Value;} |
There was a problem hiding this comment.
Make the idempotency lookup atomic
When two retries with the same separation key arrive concurrently, both can complete this lookup before either insert is committed. They later serialize on the requisition lock, but the second transaction still attempts the insert and receives a unique-constraint exception rather than returning the first delivery; that exception is also outside the controller's handled exception set, so an idempotent retry becomes a 500 response. Use an atomic insert/upsert or retry the lookup after the uniqueness conflict.
Useful? React with 👍 / 👎.
| id bigint generated always as identity primary key, tenant_id bigint not null, entidade_id bigint not null, | ||
| requisicao_id bigint not null references sigov.almoxarifado_requisicao(id), almoxarifado_id bigint not null references sigov.almoxarifado_local(id), | ||
| status varchar(24) not null default 'SEPARADA', idempotency_key varchar(100) not null, observacao text, | ||
| created_at timestamptz not null default now(), created_by bigint not null, expedida_em timestamptz, expedida_por bigint, recebida_em timestamptz, recebida_por bigint, |
There was a problem hiding this comment.
Persist the required multi-sphere context
The new persisted delivery entity records only tenant and entity IDs, omitting the mandatory esfera_governo, tipo_entidade, administrative hierarchy, managing/executing units, and territorial scope. Deliveries therefore cannot retain or query the required municipal, state, or federal context independently, contrary to the repository's schema invariant for every entity; add the prescribed context columns and constraints to this new schema.
AGENTS.md reference: AGENTS.md:L34-L37
Useful? React with 👍 / 👎.
Motivation
Description
20260915160000_distribuicao_interna_materiais.sqlque criasigov.almoxarifado_entrega,sigov.almoxarifado_entrega_itemesigov.almoxarifado_entrega_eventocom constraints, índices e permissão persistidaalmoxarifado.recebimento.confirmar.AlmoxarifadoContracts.cse a interfaceIAlmoxarifadoServicecomSepararAsync,ExpedirAsync,ReceberAsynceObterEntregaAsync, além dos tipos deSeparacaoInput/RecebimentoInput/EntregaDto.AlmoxarifadoService.cs(métodosSepararAsync,ExpedirAsync,ReceberAsync, helpers para listar entregas e gravar eventos), preservando locksFOR UPDATE, validações de disponibilidade descontando reservas concorrentes e gravação de movimentos via Dapper sem ajuste automático para divergências.src/Sigov.Api/Controllers/AlmoxarifadoController.cs(/requisicoes/{id}/separacoes,/entregas/{id},/entregas/{id}/expedicao,/entregas/{id}/recebimento) e actions MVC + views emsrc/Sigov.Web/Controllers/AlmoxarifadoController.csesrc/Sigov.Web/Views/Almoxarifado/{Detalhe,Entrega}.cshtmlpara separação, revisão, expedição, conferência e impressão; decisões de autorização respeitam as permissões existentes.Testing
python3 -m json.tool database/postgres/migrations/manifest.jsone verificação SHA-256 normalizada da migration contra omanifest.json.bash -n), sincronização das cópias canônicas descript_completop.sql/script_completo*.sqlegit diff --checksem conflitos ou marcadores de merge.dotnet build/test, aplicação idempotente doscript_completop.sqlem PostgreSQL 16, validação de Swagger/rotas, testes de concorrência com dois tenants e screenshots/responsividade) estão bloqueados porquedotnet,psql, Docker/PowerShell e navegador não estão disponíveis no ambiente de execução.Codex Task