Skip to content

feat(almoxarifado): implementar distribuição interna rastreável - #417

Merged
devmnsoft merged 1 commit into
mainfrom
codex/evoluir-design-de-telas-e-corrigir-erros
Sep 15, 2026
Merged

devmnsoft merged 1 commit into
mainfrom
codex/evoluir-design-de-telas-e-corrigir-erros

Conversation

@devmnsoft

Copy link
Copy Markdown
Owner

Motivation

  • Entregar a jornada completa de distribuição interna (separação → expedição → recebimento → tratamento de divergências) com persistência e garantias transacionais.
  • Garantir idempotência e rastreabilidade das operações operacionais (separação/entrega/recebimento) sem introduzir serviços de estoque concorrentes nem criar saldo em trânsito.
  • Evoluir as telas envolvidas para revisão, expedição, conferência e impressão, preservando autorizações e auditoria dos formulários.

Description

  • Adiciona migration forward-only 20260915160000_distribuicao_interna_materiais.sql que cria sigov.almoxarifado_entrega, sigov.almoxarifado_entrega_item e sigov.almoxarifado_entrega_evento com constraints, índices e permissão persistida almoxarifado.recebimento.confirmar.
  • Expande os contratos e DTOs em AlmoxarifadoContracts.cs e a interface IAlmoxarifadoService com SepararAsync, ExpedirAsync, ReceberAsync e ObterEntregaAsync, além dos tipos de SeparacaoInput/RecebimentoInput/EntregaDto.
  • Implementa a lógica transacional e idempotente em AlmoxarifadoService.cs (métodos SepararAsync, ExpedirAsync, ReceberAsync, helpers para listar entregas e gravar eventos), preservando locks FOR UPDATE, validações de disponibilidade descontando reservas concorrentes e gravação de movimentos via Dapper sem ajuste automático para divergências.
  • Expõe endpoints API e UI: novas rotas API em src/Sigov.Api/Controllers/AlmoxarifadoController.cs (/requisicoes/{id}/separacoes, /entregas/{id}, /entregas/{id}/expedicao, /entregas/{id}/recebimento) e actions MVC + views em src/Sigov.Web/Controllers/AlmoxarifadoController.cs e src/Sigov.Web/Views/Almoxarifado/{Detalhe,Entrega}.cshtml para separação, revisão, expedição, conferência e impressão; decisões de autorização respeitam as permissões existentes.

Testing

  • PASS: validação sintática do manifesto JSON com python3 -m json.tool database/postgres/migrations/manifest.json e verificação SHA-256 normalizada da migration contra o manifest.json.
  • PASS: shell scripts (verificação bash -n), sincronização das cópias canônicas de script_completop.sql/script_completo*.sql e git diff --check sem conflitos ou marcadores de merge.
  • PASS: verificações estáticas de integridade (ausência de marcadores de conflito, checksums atualizados e conteúdo anexado aos scripts consolidados) concluídas com sucesso.
  • BLOCKED: gates runtime (dotnet build/test, aplicação idempotente do script_completop.sql em PostgreSQL 16, validação de Swagger/rotas, testes de concorrência com dois tenants e screenshots/responsividade) estão bloqueados porque dotnet, psql, Docker/PowerShell e navegador não estão disponíveis no ambiente de execução.

Codex Task

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-15T22:37:30.227204Z 4ceac3e PR opened
🔒 Security Review ✅ Completed 2026-09-15T22:36:04.266559Z 4ceac3e PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devmnsoft
devmnsoft merged commit f7a7e19 into main Sep 15, 2026
6 of 13 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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));}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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));}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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;}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +3 to +6
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant