commit de compras

This commit is contained in:
2026-11-04 09:37:20 +02:00
parent fcf31e8d48
commit 0b05a1192c
741 changed files with 114127 additions and 116 deletions
+66
View File
@@ -1,5 +1,71 @@
# SGF Tempo — instruções locais # SGF Tempo — instruções locais
- Decisão expressa de04/11/2026: concluir Compras numa única instalação e **não criar mais demonstrações isoladas**. Instalação de trabalho escolhida e actualizada no lugar:8085, entrada `http://127.0.0.1:8085/compras`, estado privado `receipt-composition-cc50a276c10c`. Continuar aí; não abrir outra porta ou base de demonstração por incremento. Ver `docs/PURCHASING_COMPLETION.md` e `docs/COMPRAS_USO_LOCAL.md`. C01–C10 concluídos no percurso aquisição → obrigação: fornecedor novo/revisão, requisição/submissão/aprovação, três propostas/seis avaliações, adjudicação/aprovação, ordem, recepção, factura/validação, preparação/reconhecimento distinto. ExemploREQ-2026-000003/OC-2026-000001, facturaFACT-COMPRA-COMPLETA-20261104, principal/saldo100MZN e movimento1UN. Entrada com funções existentes/CSRF/guardas físicas, painel de documentos, prévias legíveis, retenção da origem ao trocar função e protecção de campos/confirmações em curso.63 verificações dirigidas:13 percurso UI→HTTP real,22 leituras/entrada,10 cliente,12 dados canónicos READ ONLY, duas comparações com dump inicial, três passagens de função e uma observação do mesmo movimento na API de Stock; sem repetir comandos concluídos ou suites anteriores. DOM simulado não é revisão visual. Writers/controllers/políticas/pacotes fixados preservados; orçamento do host e concessões idênticos ao backup. Código/dumps privados fora do repositório; sem novas bases/roles/portas/demos.8085 agora contém o exemplo concluído; isso substitui a sua condição inicial vazia, sem alterar8084/anteriores. Preservar seis registos/cache/trabalho alheio; sem implantação/concessões reais, pagamentos/contabilidade desta origem, emissão fiscal, candidata de Compras, publicação, aceitação/checkpoint presumidos. `scripts/abrir-compras.ps1` reutiliza a instalação; foi conferido com8085 já activa, não com reinício completo do sistema. Visual/carga sustentada/implantação permanecem separados.
- Continuação de 03/11/2026: `SGF-STOCK-PERIOD-REPORT-QA-v1` entregue; ver `docs/SGF_STOCK_PERIOD_REPORT.md`. Stock → Relatórios → Movimentos: resumo físico por artigo/armazém, abertura/entradas/saídas/fecho, período explícito por registo UTC, metadados actuais, sem total entre unidades. SQL/páginas25/100, referências/contexto/revisão, direitos e fonte SELECT-only existentes; flag desligada por omissão, sem direito novo.52 verificações (28 API/SQL,12 UI, duas de consultas, três guardas directas, sete demo).105 pares/114 movimentos iniciais são fixtures SQL de relatório, não operações do workflow; um append adicional só na regressão para revisão. Cinco SELECTs na projecção para25 e100 linhas, excluindo host/middleware/guardas; não é carga/memória/implantação. Demo8085 nova vazia;8084/anteriores, seis registos/cache/provas/trabalho alheio preservados. Sem writer/efeito financeiro novo, índices no destino, concessões reais, instalação, activação, publicação, aceitação/checkpoint. UTC da origem, unidades coerentes/histórico, exportação e restantes relatórios exigem seus recortes; visual/carga sustentada/implantação separados.
- Continuação de 03/11/2026: `SGF-STOCK-RECEIPT-TRACE-QA-v1` entregue; ver `docs/SGF_STOCK_RECEIPT_TRACE.md`. Movimento `receipt_in` pode abrir a recepção real após escolha explícita de Compras pelo mecanismo de contexto existente. Destino revalida `stock.view`/`purchasing_receipts.view`, perfil/tenant/armazém, linha/quantidade/artigo/fotografia e políticas originais; sem pais do browser, fallback ou comandos de negócio. Writer/runtime/leitores comuns/navegação aceite preservados, conta Stock continua SELECT em três tabelas, flag nova desligada por omissão.46 verificações:16 consulta/preservação (14 aprovadas reutilizadas após corrigir comparação de objectos no harness),20 UI e dez demo.31 tabelas host/111 nativas e sequências intactas na observação final, sem repetir origem ou suites anteriores. Demo8084 nova vazia, POST de contexto auditado pelo mecanismo existente; correlação positiva no livro QA concluído reutilizado só para leitura. Preservar8083/anteriores, seis registos/cache/provas/trabalho alheio. Sem novos direitos reais/nomes de direitos, movimento/efeito financeiro, instalação, activação, publicação, aceitação/checkpoint. Visual/carga sustentada/implantação separados; resolver conserva bloqueios do runtime de Compras.
- Continuação de 03/11/2026: `SGF-STOCK-COMPOSED-SOURCE-QA-v1` entregue; ver `docs/SGF_STOCK_COMPOSED_SOURCE.md`. Stock observa o mesmo livro físico da composição integrada Compras, sem copiar movimentos/saldos ou adquirir mutex nativo de escrita. Leitor/navegação/React/writer existentes preservados; adaptador novo, flag desligada por omissão, role SELECT-only em três tabelas, perfil por pessoa/empresa/unidade/armazéns, contexto/revisão revalidados. Direito existente `stock.view` anunciado e concedido só ao perfil de armazém das fixtures novas; trigger append-only apenas nesses livros QA.41 verificações (31 integração/dez demo, duas guardas directas), uma recepção/movimento1UN na regressão; fonte preparatória recuperada após recusa409 sem emitir segunda ordem. Demo8083 nova sem operações principais. Preservar8082/anteriores, seis registos/cache/provas/trabalho alheio; sem direitos reais, efeitos financeiros, instalação no destino, activação, publicação, aceitação/checkpoint. Visual/carga sustentada/implantação separados.
- Continuação de 03/11/2026: `SGF-OBLIGATION-EVIDENCE-WORKSPACE-QA-v1` entregue; ver `docs/SGF_OBLIGATION_EVIDENCE_WORKSPACE.md`. Paginar propostas/eventos conserva fundamento/prévia da decisão seleccionada. Proposta escolhida explicitamente, sem revisão própria/duplicação oferecida; operação em curso impede trocar selecção. Nova revisão/actor/permissões observados invalidam conferências antigas; resposta/prévia atrasada não as reactiva.26 verificações novas (20 DOM/seis demo);72 provas nativas reutilizadas, não repetidas nem somadas. Controller/runtime/kernel financeiros e bloco legado preservados. Nova demo8082 sem operações principais. Sem direitos/writers novos, confirmação financeira nova presumida ou protocolo de paginação novo. Preservar8081/anteriores, seis registos/cache/provas/trabalho alheio; sem activação, publicação, concessões reais, aceitação/checkpoint. Visual/carga/instalação/implantação continuam separados.
- Continuação de 03/11/2026: `SGF-INVOICE-MATCHING-PROGRESS-QA-v1` entregue; ver `docs/SGF_INVOICE_MATCHING_PROGRESS.md`. Quantidades por linha: encomendado/aceite/correcções/validado/pendente/restante, duas agregações de factos e estado observado uma vez antes da revisão/capacidades. Pendentes não reservam; discrepância avisa sem alterar documento ou decisão nativa. Histórico de factura protege contra resposta atrasada.47 verificações:14+16 em livros concluídos reutilizados só para leitura,11 DOM e seis demo. Todas as tabelas/sequências dos dois livros intactas; sem repetir comandos financeiros. Nova demo8081 sem operações principais. Estado original, preview/confirm e writers preservados; sem direitos novos. A revisão completa ainda materializa dependências: não declarar optimização integral de memória/paginação, carga ou implantação. Preservar8080/anteriores, seis registos/cache/provas/trabalho alheio; sem publicação, activação, concessões reais, aceitação/checkpoint. Visual/carga/instalação continuam separados.
- Continuação de 03/11/2026: `SGF-REQUEST-HISTORY-CURSOR-QA-v1` entregue; ver `docs/SGF_REQUEST_HISTORY_DELIVERY.md`. Histórico detalhado além25, páginas25/100 por cursor Laravel cifrado/vinculado a requisição/pessoa/contexto/tamanho, validade600s e acesso revalidado. Sem contagem global ou carregamento do universo; preserva filtro próprio da etapa e formulário não gravado.36 verificações:20 API/guardas/preservação,10 DOM, seis demo.127 eventos originais percorridos uma vez após nova entrada no topo;128 ao actualizar.126 notas são fixtures de auditoria, não revisões de negócio. Harness herdou ordem100MZN anterior ao histórico, preservada; essa preparação foi removida para futuras execuções.17 provas válidas reutilizadas. Nova demo8080 sem requisições principais. Escritor/preview/confirm e `ProcessHistory` preservados; sem direitos novos. Preservar8079/anteriores, seis registos/cache/provas/trabalho alheio; sem publicação, activação, concessões reais, aceitação/checkpoint. Cursor de leitura não é aprovação/snapshot MVCC persistente; visual/carga/implantação continuam separados.
- Continuação de 03/11/2026: `SGF-PURCHASING-USABLE-CONTINUITY-QA-v1` entregue; ver `docs/SGF_PURCHASING_USABLE_CONTINUITY.md`. Corrige ligação do formulário de recepção ao selector pesquisável e pesquisa inicial vazia nos catálogos de artigos/fornecedores; completa atalhos recepção/factura/obrigação conforme menu, sem nova autoridade. Históricos de requisição/propostas agora filtram eventos próprios na SQL antes de limite/paginação, sem devolver payloads de etapas posteriores ou apagar auditoria.55 verificações:17 DOM→HTTP real com recepção1UN/recuperação201→200 sem duplicação; oito fronteiras;12 de históricos com142 tabelas/sequências intactas; nove de interface e nove demo. Fixture adicional aberta1MZN só na regressão para selector. Nova demo8079 sem operações principais. Escritores/blocos preview/confirm preservados; sem direitos novos. Preservar8078/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação/checkpoint. Não é prova de navegador, concorrência nova, visual, carga sustentada ou implantação.
- Continuação de 03/11/2026: `SGF-PURCHASING-CONTEXTUAL-NAVIGATION-QA-v1` entregue; ver `docs/SGF_PURCHASING_CONTEXTUAL_NAVIGATION.md`. Dossiê abre origens exactas e continuações requisição/propostas/ordem preservam IDs. Sete interfaces usam parser comum estrito, apenas GET inicial, sem escolher primeiro registo ou trocar contexto; recepção/factura conferidas contra a ordem antes de apresentar o documento indicado.77 verificações; regressão reutiliza livro concluído sem repetir comandos,142 tabelas/sequências intactas. Demo8078 nova sem operações principais, mantendo anexos/percurso integrado. Sem direitos ou writers novos. Preservar8077/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação ou checkpoint. DOM simulado não é revisão visual; carga sustentada/instalação/implantação continuam separadas. Atalho não aprova nem determina prontidão da próxima etapa.
- Continuação de 03/11/2026: `SGF-RECEIPT-DOCUMENTS-QA-v1` entregue; ver `docs/SGF_RECEIPT_DOCUMENTS_DELIVERY.md`. Guias/qualidade/outros anexos no histórico de recepção e eventos no dossiê, pelo serviço/descarga nativos fixados. Prévia revertida, confirmação individual com fundamento/SHA/contexto/versão, páginas15, recuperação por chave e reutilização por recepção/tipo/SHA.58 verificações;16 documentos na regressão (um pela API e15 fixtures nativas), concorrência201/200, uma recepção/movimento1UN preservados. Demo8077 nova sem recepções/anexos/operações principais. Reutiliza `purchasing_receipts.view/confirm`, sem direitos novos. Não altera aceitação/qualidade/stock, nem cria efeitos financeiros. Preservar8076/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação ou checkpoint. Resultado de commit incerto conserva ficheiro; órfãos/retencão/quotas/carga/visual/implantação pendentes. Testes do componente usam DOM simulado; não são revisão visual.
- Continuação de03/11/2026: `SGF-SUPPLIER-DOCUMENTS-QA-v1` entregue; ver `docs/SGF_SUPPLIER_DOCUMENTS_DELIVERY.md`. Anexos binários privados pelo controlador nativo: prévia revertida/ficheiro temporário removido, confirmação com SHA/contexto/versão, documento e auditoria únicos por chave, descarga íntegra auditada e paginação15. Anexar põe fornecedor pendente; revisão distinta da versão actual restabelece elegibilidade nas propostas, comprovada sem proposta automática.52 verificações;16anexos na regressão (duas confirmações API/14fixtures nativas), demo8076 nova sem anexos/operações. Reutiliza direitos `purchasing_suppliers.view/prepare`, sem direitos novos. Resultado de commit incerto conserva ficheiro; reconciliação de órfãos/retencão não declarada entregue. Preservar8075/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões/dados reais, activação, publicação, aceitação ou checkpoint. Visual/carga/armazenamento/implantação pendentes; formatos positivos ensaiados com PNG, regras PDF/JPEG nativas preservadas.
- Continuação de03/11/2026: `SGF-RECEIPT-WORKSPACE-COMPLETION-QA-v1` entregue; ver `docs/SGF_RECEIPT_WORKSPACE_COMPLETION.md`. Catálogo de recepção por linha com pesquisa literal/páginas25/100, unidade/ligações conhecidas, escolha explícita e impressão de artigos conferida antes de nova confirmação; histórico navegável além25 com consulta própria.41 verificações;127artigos compatíveis,27recepções parciais/1UN na regressão (25como fixture pelo mesmo writer, duas pela API). Demo8075 integrada nova, sem operações principais; catálogo QA preparado, pedidos apenas nas linhas marcadas. Reutiliza direitos existentes e writer, sem nova valorização/conversões ou efeitos financeiros. Preservar8074/anteriores, seis registos/cache/provas/trabalho alheio; sem concessões reais, activação, publicação, aceitação ou checkpoint. Ramos completos de abastecimento/peças não reexecutados; visual/carga/implantação mantêm limites próprios.
- Continuação de03/11/2026: `SGF-PURCHASING-DOSSIER-QA-v1` entregue; ver `docs/SGF_PURCHASING_DOSSIER_DELIVERY.md`. Dossiê GET da requisição até à obrigação, páginas25 independentes e histórico projectado apenas das etapas autorizadas; etapas restritas não expõem linhas/contagens. Direito de entrada `purchasing_requests.view` e direitos nativos/de cada etapa, sem direitos novos.49 verificações: leitura reutiliza percurso QA já provado, em aplicação/sessões novas, sem repetir comandos;142tabelas e sequências intactas. Demo8074 nova e vazia, com fornecedores/orçamento QA preparados;8073 e anteriores preservadas. Não confundir dossiê com fila universal, decisão de prontidão ou autorização da operação seguinte; impressão só das páginas visíveis e obrigação paginada por facturas de origem. Sem alteração de seis registos/cache/dados/acessos reais, activação, publicação, aceitação ou checkpoint. Visual/carga/implantação permanecem pendentes.
- Continuação de03/11/2026: `SGF-SOURCING-SUPPLIER-SEARCH-QA-v1` entregue; ver `docs/SGF_SUPPLIER_SEARCH_DELIVERY.md`. Resolve a selecção limitada aos100primeiros fornecedores: pesquisa literal nome/NUIT, páginas SQL25/100, escolha individual e impressão do fornecedor conferida na prévia/confirmação de nova proposta.51 verificações; demo8073 conserva composição integrada, com127linhas artificiais adicionais de directório e125fornecedores admissíveis, zero operações principais iniciais. Reutiliza `purchasing_sourcing.prepare`, origem/isolamento e controlador original; não são novos cadastros reais ou revisões comerciais executadas. Recibos existentes recuperam decisão histórica, sem nova execução. Preservar8072/anteriores, seis registos/cache/provas/trabalho alheio; sem novos direitos públicos/reais, índices no destino, pagamentos/lançamentos, activação, publicação, aceitação ou checkpoint. Visual, carga sustentada e capacidade de implantação continuam separados.
- Continuação de03/11/2026: `SGF-PURCHASING-INTEGRATED-QA-v1` entregue; ver `docs/SGF_PURCHASING_INTEGRATED_DELIVERY.md`. Demo8072 reúne requisição nova → propostas/avaliações → adjudicação distinta → ordem → recepção → factura conferida → obrigação explícita no mesmo livro nativo, com cadastro de fornecedores e referência geográfica no mesmo host.68 verificações, sem repetir suites anteriores; navegação comum filtrada pelos direitos existentes, sem nomes de direitos novos. Começa sem operações principais/reservas/compromissos: três fornecedores e orçamento QA1000MZN preparados,20.407territórios e zero localizações. Um consumo100MZN, um movimento e uma obrigação100MZN; orçamento host intacto. Retira a limitação de fixtures separadas apenas para este percurso de Compras; não declara os seis módulos reunidos nem pagamentos/contabilidade desta origem. Preservar8071/anteriores, seis registos/cache/trabalho alheio; sem concessões reais, activação no destino, publicação, aceitação ou checkpoint. Visual, carga sustentada e capacidade de implantação mantêm pendências próprias.
- Continuação de03/11/2026: `SGF-INVOICE-TO-PAYABLE-QA-v1` entregue; ver `docs/SGF_INVOICE_TO_PAYABLE_DELIVERY.md`. Preparação individual e reconhecimento distinto das facturas conferidas pelo mesmo adaptador original/núcleo/livro nativo, sem espelho de dívida no host.72 verificações; demo8071 começa sem ordens principais/recepções/facturas/propostas/obrigações, com adjudicação QA preparada. Um principal100MZN, vencimento desconhecido, prévia reversível, versão/contexto/pessoa, repetição com revalidação da origem, concorrência e recuperação atómica. Direitos `purchasing_obligations.view/prepare/validate` só nas novas fixtures55439; configurações desligadas por omissão, histórico desactivado. Preservar8070/anteriores, seis registos/cache/trabalho alheio. Sem pagamentos, lançamentos, migração histórica, concessões reais, activação no destino, publicação, aceitação ou checkpoint. Instalação única de todas as fixtures anteriores, correcções/anexos, visual, carga sustentada e capacidade de implantação permanecem separadas.
- Continuação de03/11/2026: `SGF-SUPPLIER-INVOICE-MATCHING-QA-v1` entregue; ver `docs/SGF_SUPPLIER_INVOICE_MATCHING_DELIVERY.md`. Ordem efectivamente emitida → recepção aceite → registo pendente → conferência/validação distinta, pelos controladores/workflow originais e consumo no mesmo núcleo orçamental.77 verificações, demo8070 sem ordem/recepção/factura/consumo iniciais no contexto principal; adjudicação QA preparada e ordem estrangeira negativa separada. Direitos `purchasing_invoices.view/prepare/validate` só nas novas fixtures. Duas parciais40+60 consomem100MZN uma vez; documento sobreposto70 permanece pendente e preservado. Sem obrigação automática, pagamentos, lançamentos, emissão fiscal, correcção/rejeição/notas de crédito ou anexos binários. Não tratar esta conferência SGF como ausente; a ligação destas facturas ao reconhecimento da obrigação mantém passo próprio. Preservar8069/anteriores, seis registos/cache/pacotes/trabalho alheio; sem novas concessões reais, activação no destino, publicação, aceitação ou checkpoint. Visual, carga sustentada e implantação continuam separados.
- Continuação de03/11/2026: `SGF-GEOGRAPHY-SEGMENTATION-QA-v1` entregue; ver `docs/GEOGRAPHY_SEGMENTATION_DELIVERY.md`. Selector comum ligado a empresa/unidade/cliente/fornecedor, catálogo/cascata/pesquisa paginados, prévia/decisão explícita, histórico e segmentação com filtros/cursor.83 verificações e demo8069 sem localizações iniciais;16 provas da preparação reutilizadas. Só localização para segmentação: não altera endereços fiscais/de entrega, revisão financeira de fornecedores ou dependentes. Reutiliza direitos existentes, sem novos direitos públicos; schema e concessões só nas novas fixtures55439. Cliente conserva associação modular explícita e fornecedor exige origem/unidade/autoridade nativas actuais, também nos relatórios. Carga20.407 privada e versão preservada; pendências da fonte impedidas de selecção. Circuito de correcção/publicação de versões, países adicionais, migração de localizações, visual e implantação mantêm limites próprios. Preservar8068/anteriores, seis registos/cache/trabalho alheio; sem publicação, activação no destino, aceitação ou checkpoint.
- Continuação de03/11/2026: `SGF-REQUEST-TO-RECEIPT-QA-v1` entregue,103 verificações e demo8068; ver `docs/SGF_REQUEST_TO_RECEIPT_DELIVERY.md`. Novas requisições na interface SGF usam o mesmo `RequisitionWorkflow`/ciclo/orçamento nativo que propostas, ordens e recepções: rascunho, submissão/reserva única, aprovação distinta e rejeição/libertação única. Identidades QA próprias de solicitante, departamento, Compras, Finanças e Armazém; direitos `purchasing_requests.view/prepare/submit/approve` só nas fixtures novas. Origem auditada empresa/unidade, sem redigitar IDs ou duplicar orçamento no host. Demo fixada ao pacote efectivamente testado; alterações simultâneas de Compras preservadas. Não tratar novas requisições como ausentes; conversão de antigos `ir_requests` com reserva no host permanece pendente de protocolo próprio, sem transferência implícita. Preservar demos anteriores/seis registos/cache/trabalho alheio. Sem activação no destino, concessões reais, publicação, aceitação ou checkpoint.
- Referência fornecida em03/11/2026: `cartografia_moz.xlsx`, declarada pelo utilizador como validada pelo INE, preparada para segmentação comum de empresas/clientes/fornecedores; ver `docs/CARTOGRAPHY_REFERENCE_PREPARATION.md`.20.407 linhas, códigos de texto e versão SHA preservados,16 verificações; ambiguidades/designações ausentes/ascendência incompleta sinalizadas sem deduplicação ou associação automática. Original intacto, carga preparada privada, nenhuma ligação a bases ou localização atribuída. Não confundir preparação com catálogo/selector/formulários montados ou vigência independentemente verificada. Próxima integração usa catálogo comum, selecção explícita por versão/ID, precisão conhecida e histórico por entidade/contexto; preservar seis registos/cache/acessos.
- Continuação de03/11/2026: `SGF-SOURCING-TO-RECEIPT-QA-v1` entregue; ver `docs/SGF_SOURCING_TO_RECEIPT_DELIVERY.md`. Interface SGF para propostas/descontos, avaliações individuais, comparação, escolha fundamentada e decisão distinta, ligada à ordem e recepção efectivas pelos controladores/serviços originais. Nova demo8066 parte de requisição nativa aprovada e fornecedores QA preparados, sem propostas/avaliações/adjudicação/ordem/recepção iniciais; preservar8064/8065. Direitos `purchasing_sourcing.view/prepare/evaluate/select/approve` somente nas novas fixtures. Prévia usa savepoint com rollback integral (sequências podem avançar); confirmação tem recibo append-only na mesma transacção. O manifesto fica fixado e a integridade de cada dependência é verificada antes do carregamento. Não tratar estas interfaces como ausentes; passagem de requisições internas SGF sem orçamento duplicado, cadastro/revisão de fornecedores SGF e regimes/revisões posteriores mantêm fronteiras próprias. Seis registos/cache/trabalho alheio preservados; sem concessões reais, publicação, activação no destino, aceitação ou checkpoint.
- Continuação de03/11/2026: `SGF-ORDER-TO-RECEIPT-QA-v1` entregue, ver `docs/SGF_ORDER_TO_RECEIPT_DELIVERY.md`. Adjudicação aprovada pelo serviço original → conferência SGF → emissão/compromisso100MZN únicos → recepção por Armazém → movimento1UN → histórico. Reutiliza `PurchaseWorkflow`, bibliotecas económicas originais fixadas e o mesmo workflow/writer de recepção. Nova demo8065 com emissão/recepção por decidir; preservar8064/anteriores. Direitos `purchasing_orders.view/issue` só nas fixtures novas, sem catálogo/concessões públicos. Não voltar a tratar emissão de ordens SGF como ausente; propostas/adjudicação na interface SGF e passagem de requisições SGF para essa origem mantêm lacunas próprias. Sem orçamento duplicado no host, pagamentos/contabilidade, instalação/activação no destino, publicação, aceitação ou checkpoint; preservar seis registos, cache e trabalho alheio.
- Continuação expressa de03/11/2026, «então faça isso e saia do loop»: recepção funcional SGF implementada em `SGF-RECEIPT-WORKFLOW-QA-v1`, ver `docs/SGF_RECEIPT_WORKFLOW_DELIVERY.md`. Ordem → conferência/prévia → confirmação pelo workflow original → writer físico único → histórico; proveniência SGF atómica, repetição/concorrência/recuperação. Nova demonstração8064 sem recepções/movimentos iniciais, com perfis sintéticos próprios; duas bases novas55439 e direitos `purchasing_receipts.view/confirm` somente nas fixtures, sem catálogo/grants públicos. Não tratar a interface de recepção SGF como ainda ausente; instalação geral de procurement, pesquisa completa de catálogo, criação/emissão de ordens SGF, revisão visual e implantação conservam pendências próprias. Preservar demos, seis registos, cache e trabalho alheio. Sem activação no destino, publicação, aceitação ou checkpoint presumidos.
- Orientação prática posterior de03/11/2026: concluir funcionalidades dos módulos em entregas funcionais antes de ampliar ligações, definir os contratos durante a construção e reutilizar provas válidas. Após integração, testar as fronteiras alteradas/percurso completo; não repetir suites ou abrir novos ensaios de optimização sem necessidade que substituam a conclusão funcional.
- Autorização expressa de03/11/2026: «Autorizar o ciclo sintético proposto», `COMMON-LEDGER-STOCK-VALUATION-SIM-v1`, conforme `docs/GENERAL_LEDGER_STOCK_VALUATION_SCOPE.md`: entrada2UN/100MZN, saída1UN/50, devolução0,5UN/25, saldo1,5UN/75, pelos workflows físicos efectivos e mesmo diário/relatórios/fecho QA. Taxa50/UN validada distintamente, origens exclusivas, revisão individual, idempotência, concorrência/recuperação; direitos existentes só em bases QA novas55439. Retira apenas a exclusão desta valorização sintética seleccionada; sem duplicar custos directos/industriais, alterar demonstrações/seis registos/cache, correcção de valor lançado, reabertura, operações/acessos reais, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
- Continua??o de03/11/2026: `SGF-SUPPLIER-REGISTRY-QA-v1` entregue; ver `docs/SGF_SUPPLIER_REGISTRY_DELIVERY.md`. Cadastro/altera??o e revis?o distinta, activa??o/suspens?o, filtros/pagina??o15, hist?rico e liga??o ?s propostas pelos controladores/valida??o/pol?tica originais. Nova demo8067 e64 verifica??es;8064/8065/8066 preservadas. Direitos `purchasing_suppliers.view/prepare/review` apenas nas novas fixtures; cadastro novo vis?vel por origem auditada empresa/unidade e admissibilidade tamb?m na API de propostas. N?o tratar cadastro/revis?o como ausente neste recorte QA. Anexos, selector integral al?m100, passagem de requisi??es internas com autoridade or?amental ?nica, regimes/correc??es posteriores e implanta??o mant?m pend?ncias. Seis registos/cache/trabalho alheio preservados; sem concess?es reais, activa??o no destino, publica??o, aceita??o ou checkpoint.
- Autorização expressa de03/11/2026: «Autorizar a correcção industrial sintética proposta», `COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1`, `docs/GENERAL_LEDGER_INDUSTRIAL_CORRECTION_SCOPE.md`. Workflows efectivos: taxa material50→51 para20KG, resultado1800→1820/10UN; trabalho500/CIF300 e residual300 separado. Mesmo writer, par atómico estorno1800/substituição1820, revisão distinta, relatórios/fecho QA, direitos existentes apenas em bases novas55439. Retira apenas a exclusão desta correcção industrial em período aberto; preservar8058/demos/seis registos/cache e trabalho alheio. Sem cadeias adicionais, stock/salários reais, correcção em período fechado, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
- Autorização expressa de03/11/2026: «Autorizar o ciclo industrial sintético proposto», `COMMON-LEDGER-INDUSTRIAL-SIM-v1`, `docs/GENERAL_LEDGER_INDUSTRIAL_SCOPE.md`. Resultado efectivo1000+500+CIF300=1800,10UN/180, residual300 separado, mesmo writer/relatórios/fecho QA e revisão distinta. Bases/roles novas55439 e direitos existentes só QA. Retira apenas a exclusão de desenvolver/provar este lote/CIF seleccionado; preservar demos, seis registos, cache e trabalho alheio. Sem stock valorizado, folha salarial/operações reais, correcção industrial, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
- Autorização expressa posterior de03/11/2026: «Autorizar a correcção sintética proposta» para `COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1`, `docs/GENERAL_LEDGER_DIRECT_COST_CORRECTION_SCOPE.md`: custo original275, taxa material sucessora60/UN e cálculo295 validados pelos workflows, par atómico estorno275/substituição295, revisão distinta, relatórios e fecho QA. Direitos correct_* existentes só em fixtures novas55439; preservar8054, seis registos/cache/demos e trabalho alheio. Retira a exclusão de desenvolver/provar esta correcção em período aberto, sem reabertura, stock/salários, operações/acessos reais, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
- Autorização expressa posterior de03/11/2026: «Autorizar o ciclo sintético proposto» para `COMMON-LEDGER-DIRECT-COST-SIM-v1`, conforme `docs/GENERAL_LEDGER_DIRECT_COST_SCOPE.md`: workflow efectivo de oficina/custo directo100+175=275MZN, D QA-CUSTO-DIRECTO / C QA-COMPENSACAO-ENSAIO, revisão distinta, mesmo writer, relatórios e conferência do período; bases/roles/direitos só QA novos55439. Retira a exclusão de desenvolver/provar apenas este custo seleccionado no diário sintético. Preservar origens/outbox, seis registos, cache, demos e trabalho alheio. Sem valorização/lançamentos de stock, salários/CIF, correcção de custo já lançado, operações/acessos reais, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint presumidos.
- Autorização expressa posterior: «Autorizar o ciclo sintético proposto» para `COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1`, `docs/GENERAL_LEDGER_SALES_RECEIVABLE_SCOPE.md`: factura de teste862,07+137,93=1000MZN pelo workflow existente, contabilização em três linhas no mesmo writer, recebimentos/afectações400/600, reversão400 e conferência do período. Bases/roles novas no cluster55439, guarda QA específica sem retirar bloqueios reais; direitos só QA. Preservar ciclos de fornecedores, seis registos, cache, demos e alterações alheias. Sem fiscalidade/contas/direitos/operações reais, reabertura, stock/custos/salários contabilizados, migração no destino, activação de Compras, publicação, aceitação ou checkpoint presumidos.
- Autorização expressa posterior, 02/11/2026: «Autorizar o ciclo sintético proposto» para `COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1`, conforme `docs/GENERAL_LEDGER_COMPLETE_SYNTHETIC_SCOPE.md`: liquidação QA20 sobre principal60, contabilização da afectação e reversão confirmadas, correcção técnica por par imutável, fecho QA e adaptador autónomo SGC sobre núcleo único. Inclui os direitos propostos apenas em fixtures novas no cluster55439. Substitui as exclusões anteriores apenas para este desenvolvimento/provas sintéticos; reabertura, operações/contas/direitos reais, migração no destino, activação de Compras, publicação, aceitação e checkpoint continuam excluídos. Preservar seis registos, cache, demos e trabalho alheio.
- Autorização expressa posterior, 02/11/2026: implementar `COMMON-LEDGER-PAYABLE-SIM-v1`, conforme `docs/GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md`. Um núcleo único, obrigação reconhecida 60 MZN, duas contas e período artificiais, preparação/revisão distintas, aplicação única/atómica e quatro direitos exclusivamente em fixture QA nova no cluster 55439. Substitui apenas a exclusão de desenvolver este primeiro lançamento sintético; não autoriza lançamentos/contas/direitos reais, migração no destino, fecho/reabertura, novos efeitos de pagamentos, activação de Compras, publicação, aceitação ou checkpoint. Preservar seis registos, cache bloqueada, demos e alterações alheias. Ver `docs/GENERAL_LEDGER_DELIVERY.md` para implementação e provas; revisão visual e implantação separadas.
- Continuação «prossiga», 29/10/2026, projecto Compras: desenvolvimento sintético de `PAYABLE-IMPORTED-DECISION-v1`, confirmação/rejeição de pagamentos originalmente pendentes após importação. Reutilizar conferência original e núcleo comum, transacção única, fotografia importada imutável, decisão append-only e abatimento único. Ver `C:/laragon/www/compras/docs/conferencia-pendentes-importados.md`. Substitui a exclusão de desenvolver esta conferência apenas neste incremento; preservar configurações/dados/demos existentes. Migração 000023 e activação não aplicadas por consequência do desenvolvimento. Sem novos pagamentos, transferências, contabilidade ou publicação; SGF continua sem adaptador histórico. - Continuação «prossiga», 29/10/2026, projecto Compras: desenvolvimento sintético de `PAYABLE-IMPORTED-DECISION-v1`, confirmação/rejeição de pagamentos originalmente pendentes após importação. Reutilizar conferência original e núcleo comum, transacção única, fotografia importada imutável, decisão append-only e abatimento único. Ver `C:/laragon/www/compras/docs/conferencia-pendentes-importados.md`. Substitui a exclusão de desenvolver esta conferência apenas neste incremento; preservar configurações/dados/demos existentes. Migração 000023 e activação não aplicadas por consequência do desenvolvimento. Sem novos pagamentos, transferências, contabilidade ou publicação; SGF continua sem adaptador histórico.
- Aplicação local posterior autorizada por «continue», 29/10/2026: 000022 aplicada em `compras_dev`, lote 23, e importação histórica activada, após backup/restauro de 72 tabelas e preservação verificada. Duas tabelas novas vazias, dois triggers, activação auditada sem contas/concessões novas ou incorporação automática. Ver `C:/laragon/www/compras/docs/passagem-historico-pagamentos.md`. Demos (incluindo 8005) e público preservados; SGF continua sem fornecedor histórico configurado. Este registo substitui apenas a pendência de aplicação local abaixo; não autoriza novos pagamentos/confirmação de pendentes ou publicação. - Aplicação local posterior autorizada por «continue», 29/10/2026: 000022 aplicada em `compras_dev`, lote 23, e importação histórica activada, após backup/restauro de 72 tabelas e preservação verificada. Duas tabelas novas vazias, dois triggers, activação auditada sem contas/concessões novas ou incorporação automática. Ver `C:/laragon/www/compras/docs/passagem-historico-pagamentos.md`. Demos (incluindo 8005) e público preservados; SGF continua sem fornecedor histórico configurado. Este registo substitui apenas a pendência de aplicação local abaixo; não autoriza novos pagamentos/confirmação de pendentes ou publicação.
+44
View File
@@ -1,5 +1,33 @@
# SGF TEMPO # SGF TEMPO
**Compras — entrada única actual:** **http://127.0.0.1:8085/compras**. [Guia de utilização](docs/COMPRAS_USO_LOCAL.md) · [percurso concluído e prova](docs/PURCHASING_COMPLETION.md). A instalação existente reúne fornecedores, requisições, propostas/adjudicação, ordens, recepções, facturas e reconhecimento de obrigações. Há um exemplo completo conservado, com principal100MZN e movimento1UN. Funções sintéticas explícitas; nenhuma instalação empresarial ou operação real foi activada. Para reutilizar a aplicação local: `scripts/abrir-compras.ps1`. As referências de demonstrações abaixo são históricas.
[Comprovativos de fornecedor integrados na consulta contabilística](docs/FINANCIAL_RECEIPT_RECOVERY_DELIVERY.md): domínio explícito de reconhecimento ou liquidação, direitos próprios, sem reexecutar operações.43 verificações dirigidas em base QA nova; nenhum pagamento criado. Revisão visual, carga e implantação pendentes.
[Recuperação de comprovativos contabilísticos](docs/GENERAL_LEDGER_RECEIPT_RECOVERY_DELIVERY.md): consulta por chave, apenas do autor no contexto actual, sem executar operações. Integrada nas páginas de origens e diário;29 verificações dirigidas em base QA nova. Revisão visual e implantação permanecem pendentes.
[Correcção industrial1800→1820 implementada em QA](docs/GENERAL_LEDGER_INDUSTRIAL_CORRECTION_DELIVERY.md): nova demo **http://127.0.0.1:8059/qa/cost-demo**, par atómico revisto distintamente, residual300 separado, diário/razão/fecho. Conjunto11 lançamentos/23 linhas, bruto8725 por lado. Demos anteriores intactas; sem reabertura, stock/salários ou implantação presumida.
[Relatório financeiro: captura histórica validada reutilizada](docs/SGF_PAYABLE_REPORT_CAPTURE_DELIVERY.md). Ensaio em cópia QA:22,0→12,9s para10.000 posições, consultas2313 e heap34MiB iguais; totais/hash preservados. Validação do recibo e histórico original mantida, comandos usam fontes actuais. O gargalo persiste; sem capacidade de implantação validada.
[Lote industrial1800 → Contabilidade implementado em QA](docs/GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md): demo nova **http://127.0.0.1:8058/qa/cost-demo**, workflows efectivos, residual300 separado e mesmo diário/razão/fecho. Conjunto com vendas, fornecedor e custo directo:9 lançamentos/19 linhas, bruto5105 por lado. Demos anteriores intactas; sem stock valorizado, salários ou uso real. Revisão visual, carga e implantação pendentes.
[Vendas, fornecedores e custos no mesmo diário QA](docs/GENERAL_LEDGER_COMPOSITION_DELIVERY.md): demonstração nova **http://127.0.0.1:8057/qa/cost-demo**, painel de origens com paginação independente e fecho conjunto. Regressão:8 lançamentos/17 linhas, bruto3305 por lado, cliente400/custo295/fornecedor60. Reutiliza os efeitos autorizados; demos anteriores e decisões iniciais preservadas. Revisão visual, carga sustentada e implantação continuam pendentes.
[Correcção de custo275→295 implementada em QA](docs/GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md): demonstração própria **http://127.0.0.1:8055/qa/cost-demo**, estorno275/substituição295 numa transacção, revisão distinta, diário/balancete/razão e fecho. Original preservado; tipos de custo/fornecedor separados e SQL contra partes órfãs. A8054 mantém o recorte anterior; revisão visual e implantação separadas.
[Oficina → custo directo → Contabilidade implementado em QA](docs/GENERAL_LEDGER_DIRECT_COST_DELIVERY.md): demonstração nova **http://127.0.0.1:8054/qa/cost-demo**, custo seleccionado100+175=275, revisão distinta, um lançamento, relatórios e fecho. Mesmos workflows/writer; sem duplicar origens, processar outbox ou valorizar stock. Correcção de custo já lançado, revisão visual e implantação continuam separadas.
[Balancete e razão por conta integrados](docs/GENERAL_LEDGER_REPORTS_DELIVERY.md): núcleo único em SGF/SGC, abertura/movimentos/fecho por datas, páginas25/100, saldo acumulado entre páginas e evidência da origem. Só consulta com direitos existentes; três consultas de relatório por página do balancete. Provas dirigidas e pacote QA novo, preservando o anterior; revisão visual e implantação separadas.
[Comercial → Recebíveis → Contabilidade implementado em QA](docs/GENERAL_LEDGER_SALES_RECEIVABLE_DELIVERY.md): nova demonstração **http://127.0.0.1:8053/qa/sales-demo**, factura de teste1.000 → recebimentos/afectações400+600 → reversão400 → extracto e fecho QA. Mesmo writer, três linhas no reconhecimento, revisão distinta e concorrência comprovada. Mantém demos anteriores, revisão visual e capacidade de implantação separadas; não activa operações reais.
[Distribuição financeira autónoma verificada](docs/COMMON_FINANCE_BUNDLE_DELIVERY.md): artefacto com hashes fixados, recusa de alterações antes do arranque e nova demonstração SGC **http://127.0.0.1:8052/qa/ledger-demo**. Os núcleos financeiros são carregados do pacote local, sem fonte SGF de runtime ou activação do destino.
[Ciclo contabilístico sintético completo autorizado](docs/GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md): demonstrações novas **SGF8050 / SGC8051**, reconhecimento60 → afectação20/saldo40 → contabilização → reversão/saldo60 → par correctivo → fecho QA. Um núcleo, revisão distinta, recuperação e concorrência comprovadas; sem uso real, reabertura ou activação de Compras.
[Percurso integrado de conferência financeira até ao lançamento](docs/SGF_FINANCIAL_LEDGER_FLOW_DELIVERY.md): demonstração própria em **http://127.0.0.1:8049/qa/ledger-demo**, começando sem obrigação reconhecida. Preparação, reconhecimento independente, proposta/revisão contabilística, aplicação única, extracto e posição ligados pela mesma origem. Código funcional sintético; compra viva, correcções/fecho contabilísticos, revisão visual e implantação continuam separados.
[Ciclo de material Oficina–Stock](docs/WORKSHOP_MATERIAL_CYCLE.md): repartição de entregas entre consumos, devolução independente do excedente não consumido e histórico paginado. Ensaio sintético; sem instalação operacional ou compensação de material consumido. [Ciclo de material Oficina–Stock](docs/WORKSHOP_MATERIAL_CYCLE.md): repartição de entregas entre consumos, devolução independente do excedente não consumido e histórico paginado. Ensaio sintético; sem instalação operacional ou compensação de material consumido.
[Oficina → entrega já abatida](docs/WORKSHOP_DELIVERY_LINK.md): ligação individual de consumo verificado a uma saída existente, com impacto físico zero e confirmação independente. Apenas correspondência integral sintética; repartições e compensações pendentes. [Oficina → entrega já abatida](docs/WORKSHOP_DELIVERY_LINK.md): ligação individual de consumo verificado a uma saída existente, com impacto físico zero e confirmação independente. Apenas correspondência integral sintética; repartições e compensações pendentes.
@@ -160,6 +188,8 @@ Continuação Oficina → Stock: [retirada e recusa de propostas sem efeito fís
Continuação Compras → Financeiro: [recibo histórico no adaptador SGF](docs/SGF_PAYABLE_HISTORY_RECEIPT_DELIVERY.md), com 10 cenários/56 asserções e 17 verificações HTTP. Apenas ensaio por fotografia reconciliada; sem fonte viva, cutover operacional, decisão de pendentes ou activação real. Continuação Compras → Financeiro: [recibo histórico no adaptador SGF](docs/SGF_PAYABLE_HISTORY_RECEIPT_DELIVERY.md), com 10 cenários/56 asserções e 17 verificações HTTP. Apenas ensaio por fotografia reconciliada; sem fonte viva, cutover operacional, decisão de pendentes ou activação real.
Consulta financeira SGF: [paginação, leitura conjunta, testes e medições antes/depois](docs/SGF_PAYABLE_PAGED_READ_DELIVERY.md). Em 10.000 documentos sintéticos, 250.028 consultas integrais passaram a 67 por página de 25/100, com posições equivalentes. Sem índices no destino ou validação de capacidade de implantação.
O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md). O menu **Classificação contabilística**, disponível após actualizar a sessão com os acessos especificamente concedidos, permite preparar e submeter versões PGC-NIRF, validar/recusar sem editar e consultar classificação por data. Categoria e excepções são empresariais; operações e respectivos históricos continuam limitados às unidades autorizadas. As tabelas reais começam vazias, sem contas de movimento ou regras aprovadas por omissão. Consultar [utilização, provas e limites da classificação](docs/CATALOGUE_ACCOUNTING_DELIVERY.md).
## Ensaio isolado de custo directo ## Ensaio isolado de custo directo
@@ -172,3 +202,17 @@ Página inicial e menus agrupados: [alterações, verificações e limites visua
O ensaio de orçamento versus realizado está descrito em [INDUSTRIAL_BUDGET_DELIVERY.md](docs/INDUSTRIAL_BUDGET_DELIVERY.md), com percurso, estado das provas e propostas separadas. Continua limitado a `sgf_tempo_test`; a instalação normal e os acessos novos não são aplicados por arrasto. O ensaio de orçamento versus realizado está descrito em [INDUSTRIAL_BUDGET_DELIVERY.md](docs/INDUSTRIAL_BUDGET_DELIVERY.md), com percurso, estado das provas e propostas separadas. Continua limitado a `sgf_tempo_test`; a instalação normal e os acessos novos não são aplicados por arrasto.
O percurso `WORKSHOP-DIRECT-COST-SIM-v1` e as instruções de prova estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md). Reutiliza apontamentos verificados sem duplicar consumos; não activa custeio real. Migração `090000` e quatro concessões continuam propostas, não aplicadas. O percurso `WORKSHOP-DIRECT-COST-SIM-v1` e as instruções de prova estão em [WORKSHOP_DIRECT_COST_DELIVERY.md](docs/WORKSHOP_DIRECT_COST_DELIVERY.md). Reutiliza apontamentos verificados sem duplicar consumos; não activa custeio real. Migração `090000` e quatro concessões continuam propostas, não aplicadas.
Continuação financeira nativa: [consulta paginada SGC com origem viva em QA](docs/NATIVE_PAYABLE_PAGED_READ_DELIVERY.md). Leitura conjunta reutiliza o núcleo comum, preserva factura/contexto e mantém comandos actuais. Pacote instalado e activação permanecem separados; revisão visual e capacidade de implantação pendentes.
Continuação financeira: [origem, obrigação e selecção para pagamentos ligadas](docs/SGF_PAYABLE_EVIDENCE_FOCUS_DELIVERY.md). Conferência directa da origem, evidência exacta do reconhecimento e navegação das obrigações além da primeira página. Testes dirigidos e fingerprint anterior preservado; sem pagamentos, concessões ou activação operacional.
Histórico financeiro SGF: [pagamentos, afectações e eventos paginados](docs/SGF_SETTLEMENT_PAGED_READ_DELIVERY.md). Quatro janelas independentes, leitura conjunta e montantes por afectar calculados sobre o histórico integral. Em 1.000 pagamentos sintéticos: 3.038 consultas integrais passaram a 35 na leitura do livro com janelas de 25/100; sem índices novos ou capacidade de implantação presumida.
Consulta financeira SGF: [leitura coerente sem bloqueios de escrita da empresa](docs/SGF_SETTLEMENT_READ_ISOLATION_DELIVERY.md). O leitor paginado terminou durante o bloqueio do escritor; a confirmação continuou a aguardar e a revalidar, sem duplicação. Provas e dados anteriores preservados; sem activação operacional ou validação de implantação.
[Relatório reconciliado de obrigações SGF](docs/SGF_PAYABLE_REPORT_DELIVERY.md): posição completa do filtro, saldos indeterminados separados e evidência por conteúdo. Em 10.000 posições, a leitura específica reduziu 6.607 para 2.313 consultas; o tempo de 12,6 s continua uma limitação, sem capacidade de implantação declarada. [Primeiro lançamento contabilístico sintético proposto](docs/GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md), apenas especificação para autorização.
[Primeiro lançamento contabilístico sintético](docs/GENERAL_LEDGER_DELIVERY.md): obrigação reconhecida de 60 MZN → proposta → revisão distinta → lançamento único em duas contas artificiais. Código, interface, extracto, 74 asserções e 23 verificações HTTP; duas aplicações concorrentes recuperam o mesmo lançamento. Configuração real continua desligada, sem publicação ou checkpoint. Revisão visual e implantação separadas.
Demonstração contabilística isolada para a equipa: http://127.0.0.1:8048/qa/ledger-demo — outra fixture, ainda sem proposta/lançamento, com três funções e cookie próprios. A regressão conserva separadamente o único lançamento comprovado. Ver docs/GENERAL_LEDGER_DELIVERY.md; não equivale a aceitação ou revisão visual.
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Accounting;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class CostLedgerController extends Controller
{
public function index(Request $r,GeneralLedger $ledger)
{
$d=$r->validate(['order_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
try{$data=$ledger->costs($r->user(),isset($d['order_id'])?(int)$d['order_id']:null,(int)($d['page']??1));}catch(\DomainException $e){abort(403,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.cost-ledger',['data'=>$data])->header('Cache-Control','no-store');
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Workshop\DirectCosts;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Brick\Math\BigRational;
use Tempo\Finance\Payables\Payables;
/** Explicit composition of an actual cost conference; no technical, financial or outbox writes. */
final class CostLedgerSource
{
private function scope(User $u):array
{
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);DirectCosts::guard();$ctx=app(CompanyContext::class);
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$s=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
foreach(['workshop.view','direct_costs.view'] as $p)app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],$p,$s['unit_id']);
foreach(['gl_cost_sources','wo_orders','wo_entries','wo_decisions','dc_rates','dc_calculations','dc_decisions'] as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Origem de custo não composta neste ensaio.');return $s;
}
private function hash(array $v):string{return hash('sha256',Payables::canonical($v));}
private function facts(User $u,array $s,object $map):array
{
$c=app(DirectCosts::class)->conference($map->order_id,$u->id);$lines=$c['snapshot']['lines']??[];$entries=array_column($lines,'entry_id');$rates=array_values(array_filter(array_column($lines,'rate_id'),fn($id)=>$id!==null));
return ['mapping'=>(array)$map,'conference'=>$c,'entries'=>DB::table('wo_entries')->where('order_id',$map->order_id)->whereIn('id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'technical_decisions'=>DB::table('wo_decisions')->whereIn('entry_id',$entries)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rates'=>DB::table('dc_rates')->where($s)->where('order_id',$map->order_id)->whereIn('id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'rate_decisions'=>DB::table('dc_decisions')->whereIn('rate_id',$rates)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
}
private function eligible(array $facts):array
{
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$decision=$c['decision'];
if(!$c['calculation']||!$c['current']||!$decision||$decision['decision']!=='approved'||$decision['actor_id']===$c['calculation']['actor_id']||$snap['currency']!=='MZN'||$snap['incomplete']||$snap['selected_direct_total_exact']===null)throw new \DomainException('Cálculo actual completo e validado distintamente obrigatório; custo conhecido parcial não é total.');
$origins=[];foreach($snap['lines'] as $line)$origins[$line['kind']]=$line['origin'];
if($origins!==['time'=>$m['time_origin'],'material'=>$m['material_origin']]&&$origins!==['material'=>$m['material_origin'],'time'=>$m['time_origin']])throw new \DomainException('Perímetro seleccionado mudou; não atribuir outra origem automaticamente.');
if(config('industrial_ledger.synthetic_enabled')===true){abort_unless(DB::getSchemaBuilder()->hasTable('gl_industrial_sources'),503);if(DB::table('ic_inputs as i')->join('gl_industrial_sources as m','m.lot_id','=','i.lot_id')->where('i.tenant_id',$m['tenant_id'])->where('i.company_id',$m['company_id'])->whereIn('i.source_ref',array_values($origins))->exists())throw new \DomainException('Origem já composta em lote industrial; não reconhecer novamente.');}
try{$amount=BigRational::of($snap['selected_direct_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Custo exacto não representável em cêntimos; não arredondar.',0,$e);}
if($amount<=0)throw new \DomainException('Este ensaio exige custo seleccionado positivo.');
return ['effect_type'=>'workshop.direct_cost.validated','source_id'=>$m['order_id'],'cost_order_id'=>$m['order_id'],'obligation_id'=>$m['order_id'],'origin_key'=>$this->hash(['scope'=>array_intersect_key($m,array_flip(['tenant_id','company_id','unit_id'])),'order'=>$m['order_id'],'material_origin'=>$m['material_origin'],'time_origin'=>$m['time_origin']]),'recognition_revision'=>$this->hash($facts),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'calculation_id'=>$c['calculation']['id'],'calculation_version'=>$c['calculation']['version'],'document_revision'=>$c['calculation']['fingerprint'],'cost_evidence'=>$facts,'label'=>'Custo directo conhecido do perímetro seleccionado; não custo completo, margem ou valorização de inventário'];
}
public function source(User $u,int $order):array
{
$s=$this->scope($u);$m=DB::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??throw new \DomainException('Ordem/perímetro de custo não composto neste âmbito.');return $this->eligible($this->facts($u,$s,$m));
}
public function period(User $u,array $period):array
{
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
foreach(DB::table('gl_cost_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$s,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($v);$j=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$m->order_id)->first();if(!$j||!$this->resolved($s,$j,$source))$pending++;}catch(\DomainException){$pending++;}}
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function correctionSource(User $u,string $kind,int $journal):array
{
abort_unless(config('cost_ledger.correction_enabled')===true,403,'Correcção de custo não composta neste ensaio.');$s=$this->scope($u);abort_unless(DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'),503,'Schema de correcção de custo não instalado.');
if(!in_array($kind,['workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true))throw new \DomainException('Tipo de correcção fora do recorte.');$j=DB::table('gl_journals')->where($s)->where('id',$journal)->first()??throw new \DomainException('Original fora do âmbito.');$original=json_decode($j->snapshot,true,128,JSON_THROW_ON_ERROR);if($original['source']['effect_type']!=='workshop.direct_cost.validated')throw new \DomainException('Só o custo original admite esta correcção.');
$base=$this->source($u,(int)$original['source']['cost_order_id']);if($base['origin_key']!==$original['source']['origin_key']||$base['permitted_period_key']!==$original['period']['period_key'])throw new \DomainException('Perímetro/período original incompatível.');if($base['recognition_revision']===$original['source']['recognition_revision'])throw new \DomainException('Não há nova versão validada para substituir o original.');
return array_replace($base,['effect_type'=>$kind,'source_id'=>$journal,'amount_minor'=>$kind==='workshop.direct_cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>$this->hash(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind]),'original_journal_id'=>$journal,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>$this->hash((array)$j),'original_cost_source'=>$original['source']]);
}
private function resolved(array $s,object $original,array $source):bool
{
if(json_decode($original->snapshot,true)['source']['recognition_revision']===$source['recognition_revision'])return true;
if(config('cost_ledger.correction_enabled')!==true||!DB::getSchemaBuilder()->hasTable('gl_cost_correction_capability'))return false;
$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$original->id)->first();if(!$r)return false;$j=DB::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$j)return false;$current=json_decode($j->snapshot,true)['source'];return $current['effect_type']==='workshop.direct_cost.replacement'&&($current['cost_order_id']??null)===$source['cost_order_id']&&($current['replacement_source_revision']??null)===$source['recognition_revision'];
}
public function workspace(User $u,?int $order,int $page):array
{
$s=$this->scope($u);$q=DB::table('gl_cost_sources')->where($s);$p=$q->orderBy('id')->paginate(25,['*'],'page',$page);$chosen=null;$source=null;$pending=null;
if($order!==null){$m=DB::table('gl_cost_sources')->where($s)->where('order_id',$order)->first()??abort(403,'Ordem fora do âmbito composto.');$chosen=$this->facts($u,$s,$m);try{$source=$this->eligible($chosen);}catch(\DomainException $e){$pending=$e->getMessage();}}
$original=$order!==null?DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','workshop.direct_cost.validated')->where('snapshot->source->cost_order_id',$order)->first():null;$state=$original?($source&&$this->resolved($s,$original,$source)?'current':'requires_correction'):'unposted';
return ['scope'=>$s,'orders'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'order_id'=>$order,'facts'=>$chosen,'source'=>$source,'pending'=>$pending,'original_journal'=>$original,'accounting_state'=>$state];
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Purchasing\SupplierPayables;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\GeneralLedger\{LedgerSchema,PayableJournal};
use Tempo\Finance\Payables\Payables;
final class GeneralLedger
{
public static function guard():void
{
abort_unless((app()->environment('payable-testing')||(app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true||config('industrial_ledger.synthetic_enabled')===true)))&&config('general_ledger.synthetic_enabled')===true,403,'Lançamentos apenas na composição sintética explicitamente preparada.');
LedgerSchema::assertSynthetic(DB::connection());
foreach(LedgerSchema::TABLES as $table)abort_unless(DB::getSchemaBuilder()->hasTable($table),503,'Núcleo contabilístico não instalado neste ensaio.');
}
private function scope(User $user):array
{
self::guard();$context=app(CompanyContext::class);
abort_unless(auth()->id()===$user->id&&$context->userId()===$user->id&&$context->unitId(),403);
if(request()->hasSession())abort_unless(request()->session()->get('company_context.module_id')==='accounting',409,'Seleccione Contabilidade.');
else abort_unless(app()->runningInConsole(),403);
$scope=$context->identifiers()+['unit_id'=>$context->unitId()];
app(Permissions::class)->require($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']);return $scope;
}
public function can(User $user,string $action):bool
{
$scope=$this->scope($user);return app(Permissions::class)->has($user->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.'.$action,$scope['unit_id']);
}
public function engine(User $user,?\Closure $checkpoint=null):PayableJournal
{
$scope=$this->scope($user);$person=DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person');abort_unless(is_string($person)&&$person!=='',403);
$authorize=function(string $action,bool $lock=true)use($user,$scope,$person):void{
self::guard();
if($lock){DB::table('companies')->where('tenant_id',$scope['tenant_id'])->where('id',$scope['company_id'])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$user->id)->lockForUpdate()->firstOrFail();}
abort_unless($this->scope($user)===$scope,409,'Contexto alterado.');
app(CompanyContext::class)->selectFor($user->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id'=>$user->id,'action'=>'view'])->value('person')===$person,409,'Pessoa alterada; reabra a conferência.');
abort_unless($this->can($user,$action),403);
};
$sales=config('sales_ledger.synthetic_enabled')===true?app(SalesLedgerSource::class):null;
$cost=config('cost_ledger.synthetic_enabled')===true?app(CostLedgerSource::class):null;
$industrial=config('industrial_ledger.synthetic_enabled')===true?app(IndustrialLedgerSource::class):null;
$providers=array_values(array_filter([$sales,$cost,$industrial]));
$extra=$providers?function($kind,$id)use($sales,$cost,$industrial,$user){if(in_array($kind,['industry.cost.validated','industry.cost.reversal','industry.cost.replacement'],true)){if(!$industrial)throw new \DomainException('Origem industrial não composta.');return $kind==='industry.cost.validated'?$industrial->source($user,$id):$industrial->correctionSource($user,$kind,$id);}if(in_array($kind,['workshop.direct_cost.validated','workshop.direct_cost.reversal','workshop.direct_cost.replacement'],true)){if(!$cost)throw new \DomainException('Origem de custo não composta neste host.');return $kind==='workshop.direct_cost.validated'?$cost->source($user,$id):$cost->correctionSource($user,$kind,$id);}if(!$sales)throw new \DomainException('Origem comercial não composta neste host.');return $sales->source($user,$kind,$id);}:null;
$period=$providers?function($period)use($providers,$user){if(count($providers)===1)return $providers[0]->period($user,$period);$facts=0;$pending=0;$hash=hash_init('sha256');foreach($providers as $provider){$v=$provider->period($user,$period);$facts+=$v['facts'];$pending+=$v['pending'];hash_update($hash,$v['hash']);}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($hash)];}:null;
return new PayableJournal(DB::connection(),$scope,$user->id,$person,fn(string $a)=>$authorize($a),fn()=>$authorize('view',false),fn(int $id)=>$this->source($user,$scope,$id),$checkpoint,$extra,$period);
}
public function industrial(User $user,?int $lot=null,int $page=1):array{
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$lot,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$origin=app(IndustrialLedgerSource::class)->workspace($user,$lot,$page);$data=$this->listing($user,['page'=>$page]+($lot!==null?['industrial_lot_id'=>$lot]:[]),false);if($lot===null){foreach(['journals','lines','proposals','reviews','events'] as $key)$data[$key]=[];$data['totals']=['debit_minor'=>0,'credit_minor'=>0,'net_minor'=>0];$data['pagination']=['page'=>$page,'last_page'=>1,'total'=>0];foreach($data['evidence_pagination'] as &$p)$p=['page'=>$page,'last_page'=>1,'total'=>0];unset($p);}$closed=DB::table('gl_periods')->where($origin['scope'])->where('period_key','QA-2026-10')->orderByDesc('version')->value('status')==='closed';$cycle=$lot!==null&&config('industrial_ledger.correction_enabled')===true?$this->cycle($user,$page,['industrial_lot_id'=>$lot]):['corrections'=>[],'correction_results'=>[],'pagination'=>[],'permissions'=>[]];foreach($cycle['corrections'] as $group){$v=json_decode($group->replacement_snapshot,true)['source'];$group->current=$group->current&&$origin['source']!==null&&($v['replacement_source_revision']??null)===$origin['source']['recognition_revision'];}return ['industrial'=>$origin,'book'=>$data,'closed'=>$closed,'corrections'=>$cycle['corrections'],'correction_results'=>$cycle['correction_results'],'pagination'=>$cycle['pagination'],'permissions'=>$cycle['permissions']];});
}
public function costs(User $user,?int $order=null,int $page=1):array
{
abort_unless(config('cost_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$order,$page,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$commercial=app(CostLedgerSource::class)->workspace($user,$order,$page);$data=$this->cycle($user,$page,$order!==null?['cost_order_id'=>$order]:[]);foreach($data['corrections'] as $group){$source=json_decode($group->replacement_snapshot,true)['source'];if(isset($source['cost_order_id']))$group->current=$group->current&&$commercial['source']!==null&&($source['replacement_source_revision']??null)===$commercial['source']['recognition_revision'];}return ['cost'=>$commercial]+$data;});
}
private function source(User $user,array $scope,int $id):array
{
$obligation=DB::table('pa_obligations')->where($scope)->where('id',$id)->lockForUpdate()->first()??throw new \DomainException('Obrigação indisponível neste âmbito.');
$origin=DB::table('pa_sources')->where($scope)->where('id',$obligation->source_id)->lockForUpdate()->first()??throw new \DomainException('Referência factual ausente.');
$finance=app(SupplierPayables::class);$document=$finance->document($user,(int)$origin->local_source_id);
$position=$finance->engine($user)->positionForSource((int)$origin->local_source_id);
if(!$position||(int)$position['id']!==$id||$position['source_conflict'])throw new \DomainException('Fonte financeira mudou ou não está conferida.');
$proposal=DB::table('pa_proposals')->where($scope)->where('id',$obligation->proposal_id)->first()??throw new \DomainException('Conferência original ausente.');
return ['effect_type'=>'supplier_payable.recognised','origin_key'=>$origin->source_key,'obligation_id'=>$id,'local_source_id'=>(int)$origin->local_source_id,
'document_revision'=>$obligation->source_revision,'recognition_revision'=>hash('sha256',Payables::canonical(['obligation'=>(array)$obligation,'recognition_snapshot'=>$proposal->snapshot])),
'principal_minor'=>(int)$obligation->principal_minor,'currency'=>$obligation->currency,'due_on'=>$obligation->due_on,'tax_minor'=>$document['tax_minor'],
'compatible'=>true,'history_imported'=>$position['history_imported']];
}
public function report(User $user,string $kind,array $options):array
{
if(!in_array($kind,['trial-balance','account-ledger'],true))throw new \InvalidArgumentException('Relatório desconhecido.');$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$kind,$options,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$engine=$this->engine($user);
return $kind==='trial-balance'?$engine->trialBalance($options):$engine->accountLedger($options);
});
}
public function financialReceipt(User $user,string $domain,string $key):?array
{
if(!in_array($domain,['recognition','settlement'],true))throw new \InvalidArgumentException('Domínio de comprovativo inválido.');$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$domain,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$this->scope($user);$engine=app(SupplierPayables::class)->engine($user);return $domain==='recognition'?$engine->receipt($key):$engine->settlements()->receipt($key);});
}
public function receipt(User $user,string $key):?array
{
$own=DB::transactionLevel()===0;return DB::transaction(function()use($user,$key,$own){if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');return $this->engine($user)->receipt($key);});
}
public function listing(User $user,array $options=[],bool $withAccounts=true):array
{
$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$options,$own,$withAccounts){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$data=$this->engine($user)->listing($options,$withAccounts);
$data['permissions']=array_combine(['prepare','validate','post'],array_map(fn($a)=>$this->can($user,$a),['prepare','validate','post']));return $data;
});
}
public function cycle(User $user,int $page=1,array $bookOptions=[]):array
{
abort_unless(config('general_ledger.cycle_enabled')===true,403);
$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$page,$own,$bookOptions){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$data=$this->engine($user)->cycleListing($page,$bookOptions['cost_order_id']??null,$bookOptions['industrial_lot_id']??null);
$data['book']=$this->listing($user,['page'=>$page]+$bookOptions);
$actions=['prepare','validate','post','correct_prepare','correct_validate','correct_post','period_prepare','period_validate','period_close'];
$data['permissions']=array_combine($actions,array_map(fn($a)=>$this->can($user,$a),$actions));return $data;
});
}
public function sales(User $user,?int $invoice=null,int $page=1):array
{
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$invoice,$page,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$commercial=app(SalesLedgerSource::class)->workspace($user,$invoice,$page);$data=$this->cycle($user,$page,$invoice!==null?['sales_invoice_id'=>$invoice]:[]);$data['commercial']=$commercial;
if($invoice!==null){$account=DB::table('gl_accounts')->where($data['commercial']['scope'])->where('code','QA-CLIENTES')->value('id');$data['client_account']=$account?$this->listing($user,['page'=>$page,'sales_invoice_id'=>$invoice,'account_id'=>$account])['totals']:null;}return $data;
});
}
/** Read one factual chain. No command, inferred association or module switch. */
public function flow(User $user,int $sourceId):array
{
$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$sourceId,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$scope=$this->scope($user);
$finance=app(SupplierPayables::class);
abort_unless($finance->can($user,'view'),403);
$page=$finance->listing($user,['source'=>$sourceId,'per_page'=>25]);
$document=$page['documents'][0]??abort(403);
$position=$page['obligations'][0]??null;
$proposal=null;$review=null;$journal=null;
if($position){
$proposal=DB::table('gl_proposals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->orderByDesc('capture_version')->first();
if($proposal)$review=DB::table('gl_reviews')->where($scope)->where('proposal_id',$proposal->id)->first();
$journal=DB::table('gl_journals')->where($scope)->where('obligation_id',$position['id'])->where('snapshot->source->effect_type','supplier_payable.recognised')->first();
}
return compact('scope','document','position','proposal','review','journal')+['source_id'=>$sourceId,
'financial_proposals'=>$page['proposals'],'financial_events'=>$page['events'],
'source_conflict'=>$position['source_conflict']??false];
});
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Modules\Accounting;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class GeneralLedgerController extends Controller
{
public function financialReceipt(Request $r,string $domain,string $key,GeneralLedger $ledger)
{
abort_if($r->query()!==[],422);try{$data=$ledger->financialReceipt($r->user(),$domain,$key);}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}abort_if($data===null,404,'Comprovativo não encontrado neste domínio, contexto e captura.');return response()->json($data)->header('Cache-Control','no-store');
}
public function receipt(Request $r,string $key,GeneralLedger $ledger)
{
abort_if($r->query()!==[],422);try{$data=$ledger->receipt($r->user(),$key);}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}catch(\DomainException $e){abort(403,$e->getMessage());}abort_if($data===null,404,'Comprovativo não encontrado neste contexto e nesta captura.');return response()->json($data)->header('Cache-Control','no-store');
}
public function report(Request $r,string $kind,GeneralLedger $ledger)
{
$d=$r->validate(['from_on'=>'required|date_format:Y-m-d','to_on'=>'required|date_format:Y-m-d|after_or_equal:from_on','account_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
try{$data=$ledger->report($r->user(),$kind,$d);}
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
catch(\DomainException $e){abort(403,$e->getMessage());}
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
return response(\Tempo\Finance\GeneralLedger\ReportsWorkspace::render($data,['host'=>'ERP SGF','return_url'=>'/general-ledger','industrial_links'=>config('industrial_ledger.synthetic_enabled')===true,'cost_links'=>config('cost_ledger.synthetic_enabled')===true,'sales_links'=>config('sales_ledger.synthetic_enabled')===true]))->header('Cache-Control','no-store');
}
private function options(Request $r):array
{
$d=$r->validate(['industrial_lot_id'=>'sometimes|required|integer|min:1','cost_order_id'=>'sometimes|required|integer|min:1','sales_invoice_id'=>'sometimes|required|integer|min:1','obligation_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000','period_key'=>'sometimes|required|string|max:80','account_id'=>'sometimes|nullable|integer|min:1']);
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);if(array_key_exists('account_id',$d)&&$d['account_id']===null)unset($d['account_id']);return $d;
}
public function index(Request $r,GeneralLedger $ledger)
{
try{return response()->json($ledger->listing($r->user(),$this->options($r)))->header('Cache-Control','no-store');}
catch(\DomainException $e){abort(403,$e->getMessage());}
}
public function page(Request $r,GeneralLedger $ledger)
{
try{return response()->view('accounting.general-ledger',['data'=>$ledger->listing($r->user(),$this->options($r))])->header('Cache-Control','no-store');}
catch(\DomainException $e){abort(403,$e->getMessage());}
}
public function command(Request $r,string $action,GeneralLedger $ledger)
{
if(!in_array($action,['prepare','validate','post'],true))abort_unless(config('general_ledger.cycle_enabled')===true,403);
try{return response()->json($ledger->engine($r->user())->command($action,$r->all(),(string)$r->header('Idempotency-Key')))->header('Cache-Control','no-store');}
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
catch(\DomainException $e){abort(409,$e->getMessage());}
}
public function cycle(Request $r,GeneralLedger $ledger)
{
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page'])!==[],422);
$page=(int)($d['page']??1);$data=$ledger->cycle($r->user(),$page);
return response(\Tempo\Finance\GeneralLedger\CycleWorkspace::render($data,['host'=>'ERP SGF','csrf'=>csrf_token(),'context'=>$r->session()->get('company_context.token'),'scope'=>$r->session()->get('company_context'),'financial_url'=>'/supplier-payments','return_url'=>'/general-ledger','page_url'=>'/general-ledger/cycle']))->header('Cache-Control','no-store');
}
public function cycleData(Request $r,GeneralLedger $ledger)
{
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),['page'])!==[],422);
return response()->json($ledger->cycle($r->user(),(int)($d['page']??1)))->header('Cache-Control','no-store');
}
public function flow(Request $r,GeneralLedger $ledger)
{
$d=$r->validate(['source'=>'required|integer|min:1']);
abort_if(array_diff(array_keys($r->query()),['source'])!==[],422);
return response()->view('accounting.payable-flow',['flow'=>$ledger->flow($r->user(),(int)$d['source'])])->header('Cache-Control','no-store');
}
}
@@ -0,0 +1,7 @@
<?php
namespace App\Modules\Accounting;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class IndustrialLedgerController extends Controller{
public function index(Request $r,GeneralLedger $ledger){$d=$r->validate(['lot_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);try{$data=$ledger->industrial($r->user(),isset($d['lot_id'])?(int)$d['lot_id']:null,(int)($d['page']??1));}catch(\DomainException $e){abort(403,$e->getMessage());}return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.industrial-ledger',['data'=>$data])->header('Cache-Control','no-store');}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Industry\IndustrialCosts;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Brick\Math\BigRational;
use Tempo\Finance\Payables\Payables;
final class IndustrialLedgerSource
{
private function scope(User $u):array{
abort_unless(config('industrial_ledger.synthetic_enabled')===true,403);IndustrialCosts::guard();$c=app(CompanyContext::class);abort_unless(auth()->id()===$u->id&&$c->userId()===$u->id&&$c->unitId(),403);$s=$c->identifiers()+['unit_id'=>$c->unitId()];app(Permissions::class)->require($u->id,$s['tenant_id'],$s['company_id'],'industrial_costs.view',$s['unit_id']);foreach(['gl_industrial_sources','ic_lots','ic_results','ic_inputs','ic_decisions'] as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Origem industrial indisponível.');return $s;
}
private function facts(User $u,object $map):array{return ['mapping'=>(array)$map,'conference'=>app(IndustrialCosts::class)->conference($map->lot_id,$u->id)];}
private function eligible(array $s,array $facts):array{
$m=$facts['mapping'];$c=$facts['conference'];$snap=$c['snapshot'];$r=$c['result'];$d=$c['decision'];
if(!$r||!$c['product']||($c['product']['id']??null)!==$c['lot']['product_id']||!$c['current']||!$d||$d['decision']!=='approved'||$d['actor_id']===$r['actor_id']||!$snap||$snap['incomplete']||$snap['selected_total_exact']===null||$snap['currency']!=='MZN'||!$snap['reconciliation']['balanced'])throw new \DomainException('Resultado industrial actual completo e validado distintamente obrigatório.');
$origins=[];foreach(['material','labour','pool','machine','production'] as $kind)$origins[$kind]=$snap['origins'][$kind]['source_ref'];if(Payables::canonical($origins)!==Payables::canonical(json_decode($m['origins'],true)))throw new \DomainException('Perímetro industrial alterado; nova conferência necessária.');
if(count(array_unique($origins))!==5)throw new \DomainException('Origem industrial ambígua.');
if(DB::table('ic_inputs')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('lot_id','<>',$m['lot_id'])->whereIn('source_ref',array_values($origins))->exists())throw new \DomainException('Origem reutilizada entre lotes.');
if(DB::getSchemaBuilder()->hasTable('gl_cost_sources')&&DB::table('gl_cost_sources')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where(fn($q)=>$q->whereIn('material_origin',array_values($origins))->orWhereIn('time_origin',array_values($origins)))->exists())throw new \DomainException('Origem já composta como custo directo; não reconhecer novamente.');
try{$amount=BigRational::of($snap['selected_total_exact'])->multipliedBy(100)->toBigInteger()->toInt();}catch(\Brick\Math\Exception\MathException $e){throw new \DomainException('Montante industrial não representável em cêntimos; sem arredondamento.',0,$e);}if($amount<=0)throw new \DomainException('Montante seleccionado deve ser positivo.');
return ['effect_type'=>'industry.cost.validated','source_id'=>$m['lot_id'],'industrial_lot_id'=>$m['lot_id'],'obligation_id'=>$m['lot_id'],'origin_key'=>hash('sha256',Payables::canonical(['scope'=>$s,'lot'=>$m['lot_id'],'origins'=>$origins])),'recognition_revision'=>hash('sha256',Payables::canonical($facts)),'principal_minor'=>$amount,'currency'=>'MZN','tax_minor'=>0,'compatible'=>true,'history_imported'=>false,'permitted_period_key'=>$m['period_key'],'period_start'=>$c['lot']['period_start'],'period_end'=>$c['lot']['period_end'],'result_id'=>$r['id'],'result_version'=>$r['version'],'document_revision'=>$r['fingerprint'],'good_units'=>$snap['good_units'],'unit_cost_exact'=>$snap['unit_cost_exact'],'residual_exact'=>$snap['residual_exact'],'industrial_evidence'=>$facts,'label'=>'Resultado industrial seleccionado de ensaio; residual separado, sem stock ou salários contabilizados'];
}
public function source(User $u,int $lot):array{$s=$this->scope($u);$m=DB::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??throw new \DomainException('Lote fora do âmbito composto.');return $this->eligible($s,$this->facts($u,$m));}
public function correctionSource(User $u,string $kind,int $id):array{
abort_unless(config('industrial_ledger.correction_enabled')===true,403);$s=$this->scope($u);abort_unless(DB::getSchemaBuilder()->hasTable('gl_industrial_correction_capability'),503);if(!in_array($kind,['industry.cost.reversal','industry.cost.replacement'],true))throw new \DomainException('Tipo de correcção industrial inválido.');$j=DB::table('gl_journals')->where($s)->where('id',$id)->first()??throw new \DomainException('Original fora do âmbito.');$old=json_decode($j->snapshot,true);if($old['source']['effect_type']!=='industry.cost.validated')throw new \DomainException('Apenas o original industrial admite esta correcção.');$base=$this->source($u,(int)$old['source']['industrial_lot_id']);if($base['origin_key']!==$old['source']['origin_key']||$base['permitted_period_key']!==$old['period']['period_key']||$base['recognition_revision']===$old['source']['recognition_revision'])throw new \DomainException('Origem/perímetro/período sem sucessora compatível.');return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$kind==='industry.cost.reversal'?(int)$j->debit_minor:$base['principal_minor'],'replacement_source_revision'=>$base['recognition_revision'],'recognition_revision'=>hash('sha256',Payables::canonical(['current_source'=>$base,'original'=>(array)$j,'kind'=>$kind])),'original_journal_id'=>$id,'original_journal_on'=>$j->journal_on,'original_journal_sha256'=>hash('sha256',Payables::canonical((array)$j)),'original_industrial_source'=>$old['source']]);
}
private function resolved(array $s,object $j,array $source):bool{
if($j->source_revision===$source['recognition_revision'])return true;if(config('industrial_ledger.correction_enabled')!==true||!DB::getSchemaBuilder()->hasTable('gl_industrial_correction_capability'))return false;$r=DB::table('gl_correction_results')->where($s)->where('original_journal_id',$j->id)->first();if(!$r)return false;$replacement=DB::table('gl_journals')->where($s)->where('id',$r->replacement_journal_id)->first();if(!$replacement)return false;$v=json_decode($replacement->snapshot,true)['source'];return $v['effect_type']==='industry.cost.replacement'&&($v['industrial_lot_id']??null)===$source['industrial_lot_id']&&($v['replacement_source_revision']??null)===$source['recognition_revision'];
}
public function period(User $u,array $period):array{
$s=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;foreach(DB::table('gl_industrial_sources')->where($s)->where('period_key',$period['period_key'])->orderBy('id')->cursor() as $m){$v=$this->facts($u,$m);hash_update($h,Payables::canonical($v));$facts++;try{$source=$this->eligible($s,$v);$j=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$m->lot_id)->first();if(!$j||!$this->resolved($s,$j,$source)||$source['period_start']<$period['starts_on']||$source['period_end']>$period['ends_on'])$pending++;}catch(\DomainException){$pending++;}}return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function workspace(User $u,?int $lot,int $page):array{
$s=$this->scope($u);$p=DB::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'page',$page);$facts=null;$source=null;$pending=null;$journal=null;if($lot!==null){$m=DB::table('gl_industrial_sources')->where($s)->where('lot_id',$lot)->first()??abort(403,'Lote fora do âmbito.');$facts=$this->facts($u,$m);try{$source=$this->eligible($s,$facts);}catch(\DomainException $e){$pending=$e->getMessage();}$journal=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->where('snapshot->source->industrial_lot_id',$lot)->first();}return ['scope'=>$s,'lots'=>$p->items(),'pagination'=>['page'=>$page,'total'=>$p->total(),'last_page'=>$p->lastPage()],'lot_id'=>$lot,'facts'=>$facts,'source'=>$source,'pending'=>$pending,'journal'=>$journal,'accounting_state'=>$journal?($source&&$this->resolved($s,$journal,$source)?'current':'requires_future_correction'):'unposted'];
}
public function cards(User $u,int $page):array{
$s=$this->scope($u);$p=DB::table('gl_industrial_sources')->where($s)->orderBy('lot_id')->paginate(25,['lot_id','period_key'],'industrial_page',$page);$ids=array_column($p->items(),'lot_id');$results=DB::table('ic_results as r')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->whereIn('r.lot_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('ic_results as n')->whereColumn('n.previous_id','r.id'))->get(['r.id','r.lot_id','r.version'])->keyBy('lot_id');$decisions=DB::table('ic_decisions')->whereIn('result_id',$results->pluck('id')->all())->get(['result_id','decision'])->keyBy('result_id');$posted=DB::table('gl_journals')->where($s)->where('snapshot->source->effect_type','industry.cost.validated')->whereIn('snapshot->source->industrial_lot_id',$ids)->select('id')->selectRaw("snapshot->'source'->>'industrial_lot_id' AS lot_id")->get()->keyBy('lot_id');$rows=[];foreach($p->items() as $m){$r=$results->get($m->lot_id);$rows[]=['lot_id'=>$m->lot_id,'period_key'=>$m->period_key,'result_id'=>$r?->id,'result_version'=>$r?->version,'recorded_decision'=>$r?$decisions->get($r->id)?->decision:null,'original_journal_id'=>$posted->get($m->lot_id)?->id,'current_dependency_conference_required'=>true];}return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total(),'per_page'=>25]];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use Illuminate\Support\Facades\DB;
/** Bounded cross-source navigation. Recorded evidence never substitutes a current owner conference. */
final class LedgerComposition
{
private function requireTables(array $tables):void{foreach($tables as $t)abort_unless(DB::getSchemaBuilder()->hasTable($t),503,'Fonte composta indisponível; não interpretar como zero.');}
private function postings(array $scope,string $kind,string $key,array $ids):array
{
if(!$ids)return [];$rows=DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',$kind)->whereIn('snapshot->source->'.$key,$ids)->select('id')->selectRaw("snapshot->'source'->>'$key' AS source_id")->get();$out=[];foreach($rows as $row)$out[(int)$row->source_id]=$row->id;return $out;
}
private function page($pager,array $rows):array{return ['allowed'=>true,'rows'=>$rows,'pagination'=>['page'=>$pager->currentPage(),'total'=>$pager->total(),'last_page'=>$pager->lastPage(),'per_page'=>25]];}
public function workspace(User $user,array $options=[]):array
{
abort_unless(config('general_ledger.composition_enabled')===true&&config('sales_ledger.synthetic_enabled')===true&&config('cost_ledger.synthetic_enabled')===true,403,'Composição disponível apenas no ensaio explicitamente preparado.');$own=DB::transactionLevel()===0;
return DB::transaction(function()use($user,$options,$own){
if($own)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$ledger=app(GeneralLedger::class);$book=$ledger->listing($user,['page'=>(int)($options['journals_page']??1),'period_key'=>$options['period_key']??'QA-2026-10'],false);$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$permissions=app(Permissions::class);$can=fn($right)=>$permissions->has($user->id,$scope['tenant_id'],$scope['company_id'],$right,$scope['unit_id']);$panels=['sales'=>['allowed'=>false],'costs'=>['allowed'=>false],'suppliers'=>['allowed'=>false]];
if($can('service_invoices.view')&&$can('service_receivables.view')){
$this->requireTables(['si_invoices','sr_records','sr_effects','gl_sales_capability']);$p=ModuleRecords::query('si_invoices')->where($scope)->orderBy('id')->paginate(25,['id','series_code','number','issued_at'],'invoices_page',(int)($options['invoices_page']??1));$ids=array_column($p->items(),'id');$posted=$this->postings($scope,'sales.invoice','invoice_id',$ids);$records=DB::table('sr_records')->where($scope)->whereIn('invoice_id',$ids)->where('kind','obligation')->get(['id','invoice_id'])->keyBy('invoice_id');$opened=DB::table('sr_effects')->whereIn('record_id',$records->pluck('id')->all())->where('kind','open')->pluck('record_id')->all();$rows=[];foreach($p->items() as $i){$ob=$records->get($i->id);$rows[]=['invoice_id'=>$i->id,'number'=>$i->series_code.'/'.$i->number,'issued_at'=>$i->issued_at,'confirmed_opening'=>$ob&&in_array($ob->id,$opened,true),'original_journal_id'=>$posted[$i->id]??null];}$panels['sales']=$this->page($p,$rows);
}
if($can('workshop.view')&&$can('direct_costs.view')){
$this->requireTables(['gl_cost_sources','wo_orders','dc_calculations','dc_decisions']);$p=DB::table('gl_cost_sources')->where($scope)->orderBy('order_id')->paginate(25,['order_id','period_key'],'costs_page',(int)($options['costs_page']??1));$ids=array_column($p->items(),'order_id');$calculations=DB::table('dc_calculations as c')->where(array_combine(array_map(fn($k)=>'c.'.$k,array_keys($scope)),array_values($scope)))->whereIn('c.order_id',$ids)->whereNotExists(fn($q)=>$q->selectRaw('1')->from('dc_calculations as n')->whereColumn('n.order_id','c.order_id')->whereColumn('n.version','>','c.version'))->get(['c.id','c.order_id','c.version'])->keyBy('order_id');$decisions=DB::table('dc_decisions')->whereIn('calculation_id',$calculations->pluck('id')->all())->get(['calculation_id','decision'])->keyBy('calculation_id');$posted=$this->postings($scope,'workshop.direct_cost.validated','cost_order_id',$ids);$results=DB::table('gl_correction_results')->where($scope)->whereIn('original_journal_id',array_values($posted))->get(['id','original_journal_id'])->keyBy('original_journal_id');$rows=[];foreach($p->items() as $m){$calc=$calculations->get($m->order_id);$j=$posted[$m->order_id]??null;$rows[]=['order_id'=>$m->order_id,'period_key'=>$m->period_key,'calculation_id'=>$calc?->id,'calculation_version'=>$calc?->version,'recorded_decision'=>$calc?$decisions->get($calc->id)?->decision:null,'original_journal_id'=>$j,'recorded_correction_result_id'=>$j?$results->get($j)?->id:null,'current_dependency_conference_required'=>true];}$panels['costs']=$this->page($p,$rows);
}
if($can('supplier_payables.view')){
$this->requireTables(['pa_obligations','pa_sources','pa_imported_sources']);$q=DB::table('pa_obligations as o')->join('pa_sources as s','s.id','=','o.source_id')->join('pa_imported_sources as r','r.id','=','s.local_source_id');foreach($scope as $k=>$v)$q->where('o.'.$k,$v)->where('s.'.$k,$v)->where('r.'.$k,$v);$p=$q->orderBy('o.id')->paginate(25,['o.id','o.principal_minor','o.currency','s.local_source_id','r.payload'],'suppliers_page',(int)($options['suppliers_page']??1));$posted=$this->postings($scope,'supplier_payable.recognised','obligation_id',array_column($p->items(),'id'));$rows=[];foreach($p->items() as $o)$rows[]=['obligation_id'=>$o->id,'source_id'=>$o->local_source_id,'document_number'=>json_decode($o->payload,true)['number']??'—','principal_minor'=>(int)$o->principal_minor,'currency'=>$o->currency,'original_journal_id'=>$posted[$o->id]??null];$panels['suppliers']=$this->page($p,$rows);
}
if(config('industrial_ledger.synthetic_enabled')===true)$panels['industrial']=$can('industrial_costs.view')?app(IndustrialLedgerSource::class)->cards($user,(int)($options['industrial_page']??1)):['allowed'=>false];
$key=$options['period_key']??'QA-2026-10';$period=DB::table('gl_periods')->where($scope)->where('period_key',$key)->orderByDesc('version')->first();$p=DB::table('gl_period_checks')->where($scope)->where('period_key',$key)->orderByDesc('id')->paginate(25,['*'],'checks_page',(int)($options['checks_page']??1));$ids=array_column($p->items(),'id');$reviews=DB::table('gl_period_reviews')->where($scope)->whereIn('check_id',$ids)->get()->keyBy('check_id');$closures=DB::table('gl_period_closures')->where($scope)->whereIn('check_id',$ids)->get()->keyBy('check_id');$checks=[];foreach($p->items() as $c)$checks[]=['capture'=>(array)$c,'review'=>$reviews->get($c->id),'closure'=>$closures->get($c->id)];$periodActions=[];foreach(['period_prepare','period_validate','period_close'] as $a)$periodActions[$a]=$ledger->can($user,$a);return ['synthetic_only'=>true,'scope'=>$scope,'panels'=>$panels,'book'=>$book,'period'=>$period,'checks'=>$this->page($p,$checks),'period_permissions'=>$periodActions,'all_current_source_views'=>!in_array(false,array_column($panels,'allowed'),true),'query'=>$options,'recorded_state_is_not_current_conference'=>true];
});
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Modules\Accounting;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class LedgerCompositionController extends Controller
{
public function index(Request $r,LedgerComposition $workspace)
{
$rules=['period_key'=>'sometimes|required|string|max:80'];foreach(['industrial_page','invoices_page','costs_page','suppliers_page','journals_page','checks_page'] as $k)$rules[$k]='sometimes|integer|min:1|max:100000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
try{$data=$workspace->workspace($r->user(),$d);}catch(\DomainException $e){abort(403,$e->getMessage());}catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
return $r->is('api/*')?response()->json($data)->header('Cache-Control','no-store'):response()->view('accounting.ledger-composition',['data'=>$data])->header('Cache-Control','no-store');
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Modules\Accounting;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class SalesLedgerController extends Controller
{
private function data(Request $r,GeneralLedger $ledger):array
{
$d=$r->validate(['invoice_id'=>'sometimes|required|integer|min:1','page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
try{return $ledger->sales($r->user(),isset($d['invoice_id'])?(int)$d['invoice_id']:null,(int)($d['page']??1));}
catch(\DomainException $e){abort(403,$e->getMessage());}
catch(\InvalidArgumentException $e){abort(422,$e->getMessage());}
}
public function page(Request $r,GeneralLedger $ledger){return response()->view('accounting.sales-ledger',['data'=>$this->data($r,$ledger)])->header('Cache-Control','no-store');}
public function index(Request $r,GeneralLedger $ledger){return response()->json($this->data($r,$ledger))->header('Cache-Control','no-store');}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Modules\Accounting;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Modules\Services\{ServiceInvoicing,ServiceReceivables};
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Brick\Math\BigDecimal;
use Tempo\Finance\Payables\Payables;
/** Reads immutable commercial/receivable facts. Never issues, opens, receives or allocates. */
final class SalesLedgerSource
{
private function scope(User $u):array
{
abort_unless(config('sales_ledger.synthetic_enabled')===true,403);ServiceReceivables::guard();foreach(['si_invoices','sr_records','sr_effects','gl_sales_capability'] as $table)abort_unless(DB::getSchemaBuilder()->hasTable($table),503,'Composição comercial contabilística não instalada.');$ctx=app(CompanyContext::class);
abort_unless(auth()->id()===$u->id&&$ctx->userId()===$u->id&&$ctx->unitId(),403);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];
foreach(['service_invoices.view','service_receivables.view'] as $p)app(Permissions::class)->require($u->id,$scope['tenant_id'],$scope['company_id'],$p,$scope['unit_id']);return $scope;
}
private function hash(array $d):string{return hash('sha256',Payables::canonical($d));}
private function minor(string $value):int{return BigDecimal::of(ServiceReceivables::money($value,false))->multipliedBy(100)->toInt();}
private function effectQuery(array $s){return DB::table('sr_effects as e')->join('sr_records as r','r.id','=','e.record_id')->where(array_combine(array_map(fn($k)=>'r.'.$k,array_keys($s)),array_values($s)))->select('e.*');}
private function invoice(User $u,array $scope,int $id):array
{
$row=DB::table('si_invoices')->where($scope)->where('id',$id)->first()??abort(403,'Factura fora do âmbito.');
$shown=app(ServiceInvoicing::class)->show($id,$u->id);$snapshot=$shown['snapshot'];
if(($snapshot['simulation_only']??false)!==true||($snapshot['notice']??'')!==ServiceInvoicing::NOTICE||($snapshot['document']['data']['currency']??'')!=='MZN')throw new \DomainException('Só factura efectivamente emitida pelo circuito de teste.');
$ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$id,'kind'=>'obligation'])->first();
$open=$ob?$this->effectQuery($scope)->where('e.record_id',$ob->id)->where('e.kind','open')->first():null;
if(!$ob||!$open)throw new \DomainException('Conferência financeira de abertura pendente; não presumir dívida aprovada.');
$origin=json_decode($ob->data,true)['origin']??[];
if(($origin['invoice_id']??null)!==$id||!hash_equals($origin['snapshot_sha256']??'',hash('sha256',$row->snapshot))||(int)$open->actor_id===(int)$ob->actor_id)throw new \DomainException('Abertura ou fotografia documental divergente.');
$calc=$snapshot['calculation'];$amount=$this->minor($calc['total_document_proposed']);
if($this->minor($ob->amount)!==$amount||$this->minor($open->amount)!==$amount)throw new \DomainException('Principal não coincide com o documento.');
return ['effect_type'=>'sales.invoice','origin_key'=>$this->hash(['scope'=>$scope,'invoice'=>$id,'series'=>$row->series_code,'number'=>$row->number]),'source_id'=>$id,'invoice_id'=>$id,'obligation_id'=>(int)$ob->id,'recognition_revision'=>$this->hash(['invoice_snapshot'=>$snapshot,'pdf_sha256'=>$row->pdf_sha256,'opening'=>(array)$open,'obligation'=>(array)$ob]),'principal_minor'=>$amount,'amount_minor'=>$amount,'base_minor'=>$this->minor($calc['base_exact']),'tax_minor'=>$this->minor($calc['vat_document_proposed']),'currency'=>'MZN','compatible'=>true,'history_imported'=>false,'due_on'=>json_decode($ob->data,true)['due_date']??null,'document_revision'=>hash('sha256',$row->snapshot),'document_snapshot'=>$snapshot,'opening_effect_id'=>$open->id];
}
public function source(User $u,string $kind,int $id):array
{
$scope=$this->scope($u);if($kind==='sales.invoice')return $this->invoice($u,$scope,$id);
$expected=match($kind){'receivable.allocated'=>'allocate','receivable.reversed'=>'reverse_allocation',default=>throw new \DomainException('Efeito comercial não suportado.')};
$e=$this->effectQuery($scope)->where('e.id',$id)->where('e.kind',$expected)->first()??throw new \DomainException('Afectação/reversão confirmada fora do âmbito.');
$ob=DB::table('sr_records')->where($scope)->where('id',$e->obligation_id)->where('kind','obligation')->first()??throw new \DomainException('Obrigação a receber indisponível.');
$base=$this->invoice($u,$scope,(int)$ob->invoice_id);
$receipt=DB::table('sr_records')->where($scope)->where('id',$e->receipt_id)->where(['kind'=>'receipt','obligation_id'=>$ob->id,'invoice_id'=>$ob->invoice_id])->first();
$confirmation=$receipt?$this->effectQuery($scope)->where('e.record_id',$receipt->id)->where('e.kind','receipt_confirm')->first():null;
if(!$receipt||!$confirmation||$receipt->actor_id===$confirmation->actor_id)throw new \DomainException('Recebimento não confirmado independentemente.');
if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$ob->invoice_id)->exists())throw new \DomainException('Contabilize primeiro a factura original.');
if($kind==='receivable.reversed'){
$target=$this->effectQuery($scope)->where('e.id',$e->target_effect_id)->where('e.kind','allocate')->where('e.obligation_id',$ob->id)->first();
if(!$target||$target->amount!==$e->amount||!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','receivable.allocated')->where('snapshot->source->source_id',(int)$target->id)->exists())throw new \DomainException('Reversão exige a afectação original contabilizada integralmente.');
}
return array_replace($base,['effect_type'=>$kind,'source_id'=>$id,'amount_minor'=>$this->minor($e->amount),'tax_minor'=>0,'invoice_tax_minor'=>$base['tax_minor'],'recognition_revision'=>$this->hash(['invoice'=>$base['recognition_revision'],'effect'=>(array)$e,'receipt'=>(array)$receipt,'confirmation'=>(array)$confirmation]),'financial_effect'=>(array)$e,'receipt_confirmation_id'=>$confirmation->id]);
}
public function period(User $u,array $period):array
{
$scope=$this->scope($u);$h=hash_init('sha256');$facts=0;$pending=0;
foreach(DB::table('si_invoices')->where($scope)->orderBy('id')->cursor() as $i){$facts++;hash_update($h,$i->snapshot);hash_update($h,$i->pdf_sha256);if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type','sales.invoice')->where('snapshot->source->source_id',(int)$i->id)->exists())$pending++;}
foreach(DB::table('sr_records')->where($scope)->orderBy('id')->cursor() as $r){hash_update($h,Payables::canonical((array)$r));if(!$this->effectQuery($scope)->where('e.record_id',$r->id)->where('e.kind','<>','allocate')->exists())$pending++;}
foreach($this->effectQuery($scope)->orderBy('e.id')->cursor() as $e){$facts++;hash_update($h,Payables::canonical((array)$e));
if(in_array($e->kind,['allocate','reverse_allocation'],true)){if(!DB::table('gl_journals')->where($scope)->where('snapshot->source->effect_type',$e->kind==='allocate'?'receivable.allocated':'receivable.reversed')->where('snapshot->source->source_id',(int)$e->id)->exists())$pending++;}
elseif(!in_array($e->kind,['open','receipt_confirm','refused'],true))$pending++; // Unsupported financial changes are visible blockers, never ignored.
}
return ['facts'=>$facts,'pending'=>$pending,'hash'=>hash_final($h)];
}
public function workspace(User $u,?int $invoice,int $page=1):array
{
$scope=$this->scope($u);$p=\App\Shared\Context\ModuleRecords::query('si_invoices')->where($scope)->orderByDesc('id')->paginate(25,['id','series_code','number','issued_at'],'page',$page);
$data=['scope'=>$scope,'invoices'=>$p->items(),'pagination'=>['page'=>$page,'last_page'=>$p->lastPage(),'total'=>$p->total()],'invoice'=>null];if($invoice===null)return $data;
$data['invoice']=app(ServiceInvoicing::class)->show($invoice,$u->id);
$row=DB::table('si_invoices')->where($scope)->where('id',$invoice)->first()??abort(403);
$records=DB::table('sr_records')->where($scope)->where('invoice_id',$invoice)->orderByDesc('id')->paginate(25,['*'],'records_page',$page);$data['records']=$records->items();$data['records_pagination']=['page'=>$page,'last_page'=>$records->lastPage(),'total'=>$records->total()];
$decisions=$this->effectQuery($scope)->whereIn('e.record_id',array_column($records->items(),'id'))->where('e.kind','<>','allocate')->get()->keyBy('record_id');foreach($data['records'] as $r){$r->decision_kind=$decisions->get($r->id)?->kind;$r->decision_id=$decisions->get($r->id)?->id;}
$ob=DB::table('sr_records')->where($scope)->where(['invoice_id'=>$invoice,'kind'=>'obligation'])->first();$data['obligation_id']=$ob?->id;
$data['position']=$ob?app(ServiceReceivables::class)->position($ob->id,$u->id):null;
if($data['position']){unset($data['position']['effects']);$data['position']['receipts']=array_intersect_key($data['position']['receipts'],array_fill_keys(array_column($records->items(),'id'),true));}
$effects=$this->effectQuery($scope)->where('r.invoice_id',$invoice)->orderByDesc('e.id')->paginate(25,['e.*'],'effects_page',$page);$data['effects']=$effects->items();$data['effects_pagination']=['page'=>$page,'last_page'=>$effects->lastPage(),'total'=>$effects->total()];
$data['finance_permissions']=[];foreach(['prepare','confirm','allocate','correction_request','correction_approve'] as $a)$data['finance_permissions'][$a]=app(Permissions::class)->has($u->id,$scope['tenant_id'],$scope['company_id'],'service_receivables.'.$a,$scope['unit_id']);
return $data;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Modules\Accounting;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** SGF observes the canonical physical/valuation book with a SELECT-only QA role. */
final class StockValuationRead
{
public function index(Request $r,CompanyContext $context,Permissions $permissions)
{
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
$s=$context->identifiers();$unit=$context->unitId();abort_unless($unit,403);
foreach(['stock.view','general_ledger.view']as$p)$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$p,$unit);
$binding=config('stock_ledger.read_sources.'.$s['tenant_id'].':'.$s['company_id'].':'.$unit);
if(!$binding)return response()->json(['status'=>'unavailable','message'=>'Livro de valorização autorizado ainda não ligado a este contexto.'],503)->header('Cache-Control','no-store');
abort_unless(app()->environment('budget-testing'),403);
$host=DB::connection();$h=$host->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($h['driver']??null)==='pgsql'&&($h['host']??null)==='127.0.0.1'&&(string)($h['port']??'')==='55439'&&preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$h['database']??'',$hm)&&($h['username']??null)==='stock_ledger_sgf_'.$hm[1]&&empty($h['url'])&&empty($h['read'])&&empty($h['write']),403);
$hi=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster");abort_unless($hi->db===$h['database']&&$hi->role===$h['username']&&$hi->cluster==='sgf_modular_regression_'.$run,403);
abort_unless(($binding['connection']??null)==='stock_valuation_source'&&is_int($binding['book_id']??null)&&($binding['book_id']??0)>0,503);
$c=DB::connection('stock_valuation_source');$cfg=$c->getConfig();
abort_unless(($cfg['driver']??null)==='pgsql'&&($cfg['host']??null)==='127.0.0.1'&&(string)($cfg['port']??'')==='55439'&&preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D',$cfg['database']??'',$m)&&($cfg['username']??null)==='stock_valuation_read_'.$m[1]&&empty($cfg['url'])&&empty($cfg['read'])&&empty($cfg['write'])&&($cfg['search_path']??'public')==='public',403);
$native=$binding['native_scope']??[];abort_unless(array_keys($native)===['tenant_id','company_id','unit_id']&&is_int($native['tenant_id'])&&$native['tenant_id']>0&&$native['company_id']===0&&$native['unit_id']===0,503);
try{$data=$c->transaction(function()use($c,$cfg,$run,$native,$binding,$d){
abort_unless($c->transactionLevel()===1,503);$c->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
$i=$c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') ro,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless($i->db===$cfg['database']&&$i->role===$cfg['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->ro==='on'&&!$i->elevated,403);
foreach(['stock_movements','gl_journals','gl_lines','gl_stock_rates','gl_stock_books']as$table)abort_if($c->selectOne("SELECT has_table_privilege(current_user,?,'INSERT,UPDATE,DELETE') writes",[$table])->writes,403);
$book=$c->table('gl_stock_books')->where($native)->where('id',$binding['book_id'])->first();abort_unless($book,503,'Livro composto incompatível.');
abort_unless($c->table('stock_items')->where(['tenant_id'=>$native['tenant_id'],'id'=>$book->stock_item_id,'unit'=>$book->unit_code])->exists()
&&$c->table('warehouses')->where(['tenant_id'=>$native['tenant_id'],'id'=>$book->warehouse_id])->exists(),503,'Artigo/armazém/unidade da origem incompatíveis.');
$page=(int)($d['page']??1);$size=(int)($d['per_page']??25);
$physical=$c->table('stock_movements')->where(['tenant_id'=>$native['tenant_id'],'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id]);
$journals=$c->table('gl_journals')->where($native)->where('snapshot->source->stock_book_id',$book->id);$count=(clone$journals)->count();
$rows=(clone$journals)->orderBy('id')->offset(($page-1)*$size)->limit($size)->get();$ids=$rows->pluck('id')->all();$lines=$c->table('gl_lines')->where($native)->whereIn('journal_id',$ids)->orderBy('journal_id')->orderBy('line_no')->get();
$postedIds=(clone$journals)->selectRaw("(snapshot->'source'->>'stock_movement_id')::bigint");
$unposted=(clone$physical)->whereNotIn('id',$postedIds)->count();
$rate=$c->table('gl_stock_rates')->where($native)->where('book_id',$book->id)->orderByDesc('version')->first();$review=$rate?$c->table('gl_stock_rate_reviews')->where($native)->where('rate_id',$rate->id)->first():null;
$stale=$rate?(clone$journals)->where('snapshot->source->stock_rate_id','<>',$rate->id)->count():$count;
$net=$c->table('gl_lines as l')->join('gl_accounts as a','a.id','=','l.account_id')->where(['l.tenant_id'=>$native['tenant_id'],'l.company_id'=>0,'l.unit_id'=>0,'a.code'=>'QA-STOCK'])->whereIn('l.journal_id',(clone$journals)->select('id'))->selectRaw('coalesce(sum(l.debit_minor-l.credit_minor),0) net')->first();
return ['synthetic_only'=>true,'single_canonical_book'=>true,'status'=>$rate&&$review&&$review->decision==='approved'&&$unposted===0&&$stale===0?'reconciled':'pending','reconciliation_scope'=>'physical_movement_coverage_and_rate_ids_only','current_source_dependencies_validated'=>false,'book'=>['id'=>$book->id,'stock_item_id'=>$book->stock_item_id,'warehouse_id'=>$book->warehouse_id,'unit'=>$book->unit_code,'period_key'=>$book->period_key],'physical_quantity_milli'=>(int)(clone$physical)->sum('quantity_milli'),'physical_revision'=>(int)(clone$physical)->max('id'),'posted_stock_value_minor'=>(int)$net->net,'currency'=>'MZN','unposted_movements'=>$unposted,'stale_rate_journals'=>$stale,'journals'=>$rows->all(),'lines'=>$lines->all(),'pagination'=>['page'=>$page,'per_page'=>$size,'total'=>$count],'source_identity'=>['database'=>$i->db,'role'=>$i->role],'operation_executed'=>false];
});}catch(\PDOException $e){$state=$e->errorInfo[0]??$e->getCode();if(str_starts_with((string)$state,'08')||in_array($state,['28P01','3D000','42P01','42703','57014'],true))return response()->json(['status'=>'unavailable','message'=>'Livro de valorização indisponível ou incompatível.'],503)->header('Cache-Control','no-store');throw$e;}
if($r->is('api/*'))return response()->json($data)->header('Cache-Control','no-store');
return response()->view('accounting.stock-valuation-read',['data'=>$data])->header('Cache-Control','no-store');
}
}
@@ -27,7 +27,7 @@ final class RequireCompanyContext
app(\App\Shared\Context\RequireModuleContext::class)->check($request, $this->context); app(\App\Shared\Context\RequireModuleContext::class)->check($request, $this->context);
$response = $next($request); $response = $next($request);
if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments'], true) && $response->isSuccessful()) { if ($request->isMethod('GET') && in_array($request->path(), ['supplier-payables','supplier-payments','purchasing/internal-requisitions'], true) && $response->isSuccessful()) {
$bar = '<nav aria-label="Contexto SGF" style="padding:12px;background:#edf4f3;color:#163e38"><strong>Módulo activo: Compras</strong> · <a href="/" data-sgf-module-back>Voltar à navegação SGF</a></nav><script src="/sgf-module-context.js" defer></script>'; $bar = '<nav aria-label="Contexto SGF" style="padding:12px;background:#edf4f3;color:#163e38"><strong>Módulo activo: Compras</strong> · <a href="/" data-sgf-module-back>Voltar à navegação SGF</a></nav><script src="/sgf-module-context.js" defer></script>';
$response->setContent(preg_replace('/(<body\b[^>]*>)/i', '$1'.$bar, $response->getContent(), 1)); $response->setContent(preg_replace('/(<body\b[^>]*>)/i', '$1'.$bar, $response->getContent(), 1));
} }
@@ -45,7 +45,20 @@ final class IdentityWorkflow
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id), $result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
'token' => $selected['token'], 'grants' => $grants, 'token' => $selected['token'], 'grants' => $grants,
'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context), 'modules' => app(\App\Shared\Context\ModuleNavigation::class)->available($this->context),
'module_id' => $selected['module_id'] ?? null]; 'module_id' => $selected['module_id'] ?? null,
'geography_enabled'=>app()->environment('budget-testing')&&config('geography.enabled')===true,
'stock'=>['period_report'=>app()->environment('budget-testing')&&config('receipt_qa.stock_read_enabled')===true&&config('receipt_qa.stock_period_enabled')===true],
'purchasing' => ['internal_requisitions' => app()->environment('budget-testing')
&& config('internal_requisitions.qa_enabled') === true,
'receipts'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true,
'orders'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.orders_enabled')===true,
'supplier_obligations'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.obligations_enabled')===true,
'supplier_invoices'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.invoices_enabled')===true,
'source_requests'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.requests_enabled')===true,
'suppliers'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.suppliers_enabled')===true,
'sourcing'=>app()->environment('budget-testing')&&config('receipt_qa.enabled')===true&&config('receipt_qa.sourcing_enabled')===true],
'accounting' => ['stock_valuation_read' => app()->environment('budget-testing')
&& config('stock_ledger.read_sources.'.$selected['tenant_id'].':'.$selected['company_id'].':'.($selected['unit_id']??'')) !== null]];
if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) { if ($result['context']['module_id'] && !in_array($result['context']['module_id'], array_column($result['context']['modules'], 'id'), true)) {
$result['context']['module_id'] = null; $result['context']['module_id'] = null;
$result['context']['token'] = bin2hex(random_bytes(24)); $result['context']['token'] = bin2hex(random_bytes(24));
+6
View File
@@ -119,6 +119,12 @@ final class IndustrialCosts {
$id=DB::table('ic_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$r->version,'kind'=>'decision']; $id=DB::table('ic_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'lot_id'=>$lot,'version'=>$r->version,'kind'=>'decision'];
}); });
} }
/** Current owner conference for one lot; no listing universe or writer lock. */
public function conference(int $lot,int $actor):array {
self::guard();$s=$this->scope($actor,'view');$l=$this->lot($lot,$s);$r=DB::table('ic_results')->where($s)->where('lot_id',$lot)->orderByDesc('version')->first();$snapshot=$r?json_decode($r->snapshot,true,64,JSON_THROW_ON_ERROR):null;
$ids=array_column(array_filter($snapshot['refs']??[]),'id');$inputs=DB::table('ic_inputs')->where($s)->where('lot_id',$lot)->whereIn('id',$ids)->orderBy('id')->get();$decisions=DB::table('ic_decisions')->whereIn('input_id',$ids)->orderBy('id')->get();$decision=$r?DB::table('ic_decisions')->where('result_id',$r->id)->first():null;
return ['lot'=>(array)$l,'product'=>(array)DB::table('ic_products')->where($s)->where('id',$l->product_id)->first(),'result'=>$r?(array)$r:null,'snapshot'=>$snapshot,'decision'=>$decision?(array)$decision:null,'current'=>$r?$this->resultCurrent($r):false,'inputs'=>$inputs->map(fn($i)=>(array)$i)->all(),'input_decisions'=>$decisions->map(fn($i)=>(array)$i)->all()];
}
public function listing(int $actor): array { public function listing(int $actor): array {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[],'notice'=>self::NOTICE];self::guard(); $s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'lots'=>[],'notice'=>self::NOTICE];self::guard();
return DB::transaction(function()use($s){ return DB::transaction(function()use($s){
@@ -0,0 +1,88 @@
<?php
namespace App\Modules\Inventory;
use App\Modules\Purchasing\BudgetAdapter;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
require_once __DIR__.'/../../../packages/inventory-read/bootstrap.php';
/** Observe the composed receipt book; never borrow its writer or tenant mutex. */
final class CompositionStockRead
{
public function index(Request $r, CompanyContext $context, Permissions $permissions)
{
abort_unless(config('receipt_qa.stock_read_enabled') === true && config('receipt_qa.enabled') === true,404);
abort_unless(app()->environment('budget-testing'),403);
BudgetAdapter::guard();
$d=$r->validate(['view'=>'sometimes|in:items,warehouses,balances,movements,period','search'=>'nullable|string|max:100',
'warehouse_id'=>'sometimes|integer|min:1','page'=>'sometimes|integer|min:1|max:100000',
'per_page'=>'sometimes|integer|in:25,100','snapshot'=>'sometimes|string|max:8192',
'from'=>'required_if:view,period|prohibited_unless:view,period|date_format:Y-m-d|after_or_equal:1900-01-01|before_or_equal:9998-12-31',
'to'=>'required_if:view,period|prohibited_unless:view,period|date_format:Y-m-d|after_or_equal:from|before_or_equal:9998-12-31']);
abort_if(array_diff(array_keys($r->query()),array_keys($d))!==[],422);
$filters=['view'=>$d['view']??'balances','search'=>$d['search']??'',
'warehouse_id'=>isset($d['warehouse_id'])?(int)$d['warehouse_id']:null,'page'=>(int)($d['page']??1),'per_page'=>(int)($d['per_page']??25)];
if($filters['view']==='period'){
abort_unless(config('receipt_qa.stock_period_enabled')===true,404);
$filters+=['from'=>$d['from'],'to'=>$d['to']];
}else abort_if(array_key_exists('from',$d)||array_key_exists('to',$d),422);
$host=DB::connection();$c=$host->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$host->getDatabaseName(),$m)
&&($c['username']??null)==='ir_composition_'.$m[1]&&preg_match('/^[a-f0-9]{12}$/D',$run)
&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??'public')==='public',403);
$identity=$host->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$identity->elevated&&$identity->db===$host->getDatabaseName()&&$identity->role===$c['username']&&$identity->cluster==='sgf_modular_regression_'.$run,403);
$scope=$context->identifiers();$unit=$context->unitId();$user=$context->userId();
abort_unless($unit&&$r->user()&&(int)$r->user()->id===$user,403);
// Hold only shared host authority locks. Unrelated readers remain concurrent.
try {
return $host->transaction(function()use($host,$scope,$unit,$user,$permissions,$m,$run,$r,$filters,$d){
$host->statement("SET LOCAL lock_timeout='2s'");
$host->statement("SET LOCAL statement_timeout='3s'");
abort_unless($host->table('users')->where('id',$user)->sharedLock()->first(),403);
abort_unless($host->table('company_memberships')->where($scope+['user_id'=>$user])->sharedLock()->first(),403);
foreach(['company.view'=>null,'unit.view'=>$unit,'stock.view'=>$unit]as$right=>$u){
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'unit_id'=>$u,'permission'=>$right])->sharedLock()->first(),403);
$permissions->require($user,$scope['tenant_id'],$scope['company_id'],$right,$u);
}
$root='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
$profile=realpath($root.'/stock-binding.json');
abort_unless($profile&&!is_link($root.'/stock-binding.json')&&strtolower(str_replace('\\','/',$profile))===strtolower($root.'/stock-binding.json'),503,'Fonte de stock indisponível.');
$p=json_decode(file_get_contents($profile),true,32,JSON_THROW_ON_ERROR);
$key=implode(':',[$scope['tenant_id'],$scope['company_id'],$unit,$user]);
$binding=$p['bindings'][$key]??null;abort_unless(is_array($binding),403);
abort_unless(is_int($binding['source_tenant_id']??null)&&$binding['source_tenant_id']>0
&&is_array($binding['warehouse_ids']??null)&&count($binding['warehouse_ids'])>0&&count($binding['warehouse_ids'])<=1000
&&!array_filter($binding['warehouse_ids'],fn($id)=>!is_int($id)||$id<1),503,'Fonte de stock incompatível.');
$c=$p['connection']??[];
abort_unless(($c['driver']??null)==='pgsql'&&($c['host']??null)==='127.0.0.1'&&(int)($c['port']??0)===55439
&&($c['database']??null)==='compras_receipts_test_'.$m[1]&&($c['username']??null)==='stock_receipt_read_'.$m[1]
&&empty($c['url'])&&empty($c['read'])&&empty($c['write'])&&($c['search_path']??null)==='public',403);
// Purge avoids retaining an earlier profile/credential after a configuration change.
DB::purge('composition_stock');config(['database.connections.composition_stock'=>$c]);$source=DB::connection('composition_stock');
$i=$source->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,current_setting('default_transaction_read_only') read_only,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$i->elevated&&$i->db===$c['database']&&$i->role===$c['username']&&$i->cluster==='sgf_modular_regression_'.$run&&$i->read_only==='on',403);
$rights=$source->selectOne("SELECT EXISTS(SELECT FROM pg_tables WHERE schemaname='public' AND (has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') OR (tablename NOT IN ('stock_items','warehouses','stock_movements') AND has_table_privilege(current_user,quote_ident(schemaname)||'.'||quote_ident(tablename),'SELECT')))) forbidden,
EXISTS(SELECT FROM pg_trigger WHERE tgrelid='public.stock_movements'::regclass AND tgname='composition_stock_append_only' AND tgenabled='O' AND NOT tgisinternal AND (tgtype::int & 26)=26) append_only");
abort_if($rights->forbidden,403);abort_unless($rights->append_only,503,'Fonte de stock incompatível.');
abort_unless(($binding['schema']??null)==='public'&&($binding['append_only']??false)===true,503);
if($filters['warehouse_id']!==null)abort_unless(in_array($filters['warehouse_id'],$binding['warehouse_ids'],true),403);
$binding['physical_source']=[$i->db,$i->role,$i->cluster];
$fingerprint=hash('sha256',json_encode([$scope,$unit,$user,$r->session()->get('company_context.token'),$binding,array_diff_key($filters,['page'=>true])],JSON_THROW_ON_ERROR));
$result=$filters['view']==='period'
?(new StockPeriodReport($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null)
:(new \Tempo\Inventory\InventoryReadSession($source))->read($binding,$fingerprint,$filters,$d['snapshot']??null);
$result['capabilities']['receipt_trace']=config('receipt_qa.stock_trace_enabled')===true
&&$permissions->has($user,$scope['tenant_id'],$scope['company_id'],'purchasing_receipts.view',$unit);
return response()->json($result)->header('Cache-Control','no-store');
});
}catch(\PDOException $e){
$state=$e->errorInfo[0]??(string)$e->getCode();
if(!str_starts_with($state,'08')&&!in_array($state,['28P01','3D000','42P01','42703','42883','55P03','57014'],true))throw $e;
return response()->json(['status'=>'unavailable','message'=>'A fonte de stock está indisponível ou incompatível. Actualize a consulta mais tarde.'],503)->header('Cache-Control','no-store');
}
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Inventory;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Crypt;
/** Physical registration-period quantities, projected from the canonical book only. */
final class StockPeriodReport
{
public function __construct(private ConnectionInterface $db){}
public function read(array $binding,string $fingerprint,array $filters,?string $token):array
{
return $this->db->transaction(function()use($binding,$fingerprint,$filters,$token){
abort_unless($this->db->transactionLevel()===1,503);
$this->db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$this->db->statement("SET LOCAL statement_timeout='3s'");
$reader=new \Tempo\Inventory\InventoryReader($this->db,$binding['schema'],$binding['source_tenant_id'],$binding['warehouse_ids']);
try{$revision=$reader->revision();}catch(\UnexpectedValueException){abort(503,'Fonte de stock incompatível.');}
$observed=time();
if($token!==null){
try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Referência de relatório inválida. Actualize a consulta.');}
abort_unless(($cut['fingerprint']??null)===$fingerprint&&($cut['revision']??null)===$revision
&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A fonte ou o contexto mudou. Actualize o relatório.');
$observed=$cut['observed'];
}
$from=$filters['from'].' 00:00:00';
$until=(new \DateTimeImmutable($filters['to'],new \DateTimeZone('UTC')))->modify('+1 day')->format('Y-m-d').' 00:00:00';
$q=$this->db->table('public.stock_movements AS m')
->join('public.stock_items AS i',fn($j)=>$j->on('i.id','=','m.stock_item_id')->on('i.tenant_id','=','m.tenant_id'))
->join('public.warehouses AS w',fn($j)=>$j->on('w.id','=','m.warehouse_id')->on('w.tenant_id','=','m.tenant_id'))
->where('m.tenant_id',$binding['source_tenant_id'])->whereIn('m.warehouse_id',$binding['warehouse_ids'])->where('m.moved_at','<',$until);
if($filters['warehouse_id']!==null)$q->where('m.warehouse_id',$filters['warehouse_id']);
if($filters['search']!==''){
$literal='%'.str_replace(['\\','%','_'],['\\\\','\\%','\\_'],$filters['search']).'%';
$q->where(fn($s)=>$s->where('i.code','ilike',$literal)->orWhere('i.name','ilike',$literal));
}
$q->select(['m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code AS warehouse_code','w.name AS warehouse_name'])
->selectRaw('SUM(CASE WHEN m.moved_at < ? THEN m.quantity_milli::numeric ELSE 0 END)::text AS opening_milli',[$from])
->selectRaw('SUM(CASE WHEN m.moved_at >= ? AND m.quantity_milli > 0 THEN m.quantity_milli::numeric ELSE 0 END)::text AS incoming_milli',[$from])
->selectRaw('SUM(CASE WHEN m.moved_at >= ? AND m.quantity_milli < 0 THEN -m.quantity_milli::numeric ELSE 0 END)::text AS outgoing_milli',[$from])
->selectRaw('SUM(m.quantity_milli)::text AS closing_milli')
->selectRaw('COUNT(*) FILTER (WHERE m.moved_at >= ?)::text AS period_movements',[$from])
->groupBy('m.stock_item_id','m.warehouse_id','i.code','i.name','i.unit','w.code','w.name')
->orderBy('m.stock_item_id')->orderBy('m.warehouse_id');
$total=(int)$this->db->query()->fromSub((clone $q)->reorder(),'stock_period_pairs')->count();
$rows=$q->limit($filters['per_page'])->offset(($filters['page']-1)*$filters['per_page'])->get()->map(fn($r)=>(array)$r)->all();
return ['status'=>'available','synthetic_only'=>true,'report'=>'physical_period','period'=>['from'=>$filters['from'],'to'=>$filters['to'],'timezone'=>'UTC','basis'=>'registration_time'],
'rows'=>$rows,'pagination'=>['total'=>$total,'page'=>$filters['page'],'per_page'=>$filters['per_page'],'last_page'=>max(1,(int)ceil($total/$filters['per_page']))],
'observed_at'=>gmdate('c',$observed),'snapshot'=>Crypt::encryptString(json_encode(['fingerprint'=>$fingerprint,'revision'=>$revision,'observed'=>$observed],JSON_THROW_ON_ERROR)),
'ledger_revision'=>$revision,'capabilities'=>['write'=>false,'valuation'=>false,'reserved'=>false,'blocked'=>false],
'notice'=>'Quantidades por data de registo UTC; designações actuais. Não há total somado entre unidades diferentes. Sem valorização, reservas ou disponibilidade. Pares sem história até ao fecho não são saldos zero.'];
});
}
}
@@ -14,6 +14,9 @@ final class StockReadController
{ {
public function index(Request $r, CompanyContext $context, Permissions $permissions) public function index(Request $r, CompanyContext $context, Permissions $permissions)
{ {
if (config('receipt_qa.stock_read_enabled') === true) {
return app(CompositionStockRead::class)->index($r, $context, $permissions);
}
$d = $r->validate(['view' => 'sometimes|in:items,warehouses,balances,movements','search' => 'nullable|string|max:100', $d = $r->validate(['view' => 'sometimes|in:items,warehouses,balances,movements','search' => 'nullable|string|max:100',
'warehouse_id' => 'sometimes|integer|min:1','page' => 'sometimes|integer|min:1|max:100000','per_page' => 'sometimes|integer|min:1|max:100', 'warehouse_id' => 'sometimes|integer|min:1','page' => 'sometimes|integer|min:1|max:100000','per_page' => 'sometimes|integer|min:1|max:100',
'snapshot' => 'sometimes|string|max:8192']); 'snapshot' => 'sometimes|string|max:8192']);
@@ -0,0 +1,58 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{DB,Gate};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,PurchaseOrder,GoodsReceipt,Invoice,AuditLog};
use Tempo\Purchasing\Embedded\Services\SupplierPayables;
/** Read-only view of existing facts, with independent authority for every stage. */
final class DossierController
{
private function guard():void { abort_unless(config('receipt_qa.dossiers_enabled')===true&&config('receipt_qa.requests_enabled')===true,404); }
private function source(int $id,array $binding,object $actor):Requisition {
abort_unless($actor->hasFunctions(['requester','procurement','finance','department_head'])&&RequisitionAccess::allowed($id,$binding),403);
return Requisition::visibleTo($actor)->find($id)??abort(403,'Requisição fora do contexto autorizado.');
}
private function page($query,array $columns,string $name,array $input):array {
$page=$query->orderBy('id')->paginate(25,$columns,$name,(int)($input[$name]??1));
return ['accessible'=>true,'data'=>$page->items(),'pagination'=>['page'=>$page->currentPage(),'last_page'=>$page->lastPage(),'total'=>$page->total(),'per_page'=>25]];
}
public function screen(Request $r,ReceiptRuntime $runtime) {
$this->guard();$runtime->run('purchasing_requests.view',function($a){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);});
return response()->view('purchasing-dossier',['context'=>$r->session()->get('company_context.token')]);
}
public function index(Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:draft,pending,approved,rejected','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $runtime->run('purchasing_requests.view',function($a,$b)use($d){abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(lower(?) in lower(number))>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','process_version'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
}
public function show(int $id,Request $r,ReceiptRuntime $runtime) {
$this->guard();$rules=[];foreach(['quotes','awards','orders','receipts','invoices','obligations','events']as$key)$rules[$key.'_page']='sometimes|integer|min:1|max:10000';$d=$r->validate($rules);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
$host=DB::connection();$c=app(\App\Shared\Context\CompanyContext::class);$scope=$c->identifiers()+['unit_id'=>$c->unitId(),'user_id'=>$c->userId()];
return $runtime->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d,$host,$scope){
$request=$this->source($id,$b,$a);$allowed=[];
foreach(['sourcing','orders','receipts','invoices','obligations']as$stage){$flag=$stage==='receipts'?'enabled':$stage.'_enabled';$allowed[$stage]=config('receipt_qa.'.$flag)===true&&$host->table('permission_grants')->where($scope)->where('permission','purchasing_'.$stage.'.view')->lockForUpdate()->first()!==null;}
$viewer=$a->hasFunctions(['admin','procurement','finance','department_head']);$finance=$a->hasFunctions(['admin','finance']);$allowed['sourcing']=$allowed['sourcing']&&$viewer;$allowed['orders']=$allowed['orders']&&$viewer;$allowed['receipts']=$allowed['receipts']&&Gate::allows('viewAny',GoodsReceipt::class);$allowed['invoices']=$allowed['invoices']&&$finance;$allowed['obligations']=$allowed['obligations']&&$finance;
$orders=fn()=>PurchaseOrder::where('tenant_id',$a->tenant_id)->where('requisition_id',$id)->select('id');
$invoices=fn()=>Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$orders());
$restricted=['accessible'=>false];$stages=['quotes'=>$restricted,'awards'=>$restricted,'orders'=>$restricted,'receipts'=>$restricted,'invoices'=>$restricted,'obligations'=>$restricted];$patterns=['requisition.%','sgf.request.command'];
if($allowed['sourcing']){$stages['quotes']=$this->page(Quotation::where('requisition_id',$id),['id','reference','supplier_id','total_minor','created_by','created_at'],'quotes_page',$d);$stages['awards']=$this->page(PurchaseAward::where('requisition_id',$id),['id','quotation_id','status','total_minor','created_by','created_at'],'awards_page',$d);array_push($patterns,'quotation.%','award.%','sgf.sourcing.command');}
if($allowed['orders']){$stages['orders']=$this->page(PurchaseOrder::visibleTo($a)->where('requisition_id',$id),['id','number','status','total_minor','issued_by','issued_at'],'orders_page',$d);array_push($patterns,'order.%','sgf.order.command');}
if($allowed['receipts']){$stages['receipts']=$this->page(GoodsReceipt::whereIn('purchase_order_id',$orders()),['id','number','purchase_order_id','status','received_on','received_by','confirmed_at'],'receipts_page',$d);array_push($patterns,'receipt.%','sgf.receipt.command','sgf.receipt.document','sgf.receipt.document_downloaded');}
if($allowed['invoices']){$stages['invoices']=$this->page($invoices(),['id','number','purchase_order_id','status','total_minor','created_by','validated_by','validated_at'],'invoices_page',$d);array_push($patterns,'invoice.%','sgf.invoice.command');}
if($allowed['obligations']){
$service=app(SupplierPayables::class);$service->readContext($a);
// The window is on source invoices, not the complete financial universe.
$window=$invoices()->orderBy('id')->paginate(25,['id'],'obligations_page',(int)($d['obligations_page']??1));
$positions=$service->engine($a)->positionsPage($window->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all());
$stages['obligations']=['accessible'=>true,'data'=>$positions['obligations'],'pagination'=>['page'=>$window->currentPage(),'last_page'=>$window->lastPage(),'total_sources'=>$window->total(),'per_page'=>25],'source_window'=>true];$patterns[]='sgf.obligation.command';
}
$events=AuditLog::where('requisition_id',$id)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});$history=$this->page($events,['id','user_id','action','created_at'],'events_page',$d);
$view=['request'=>$request->only(['id','number','status','process_version','requester_id','total_minor']),'stages'=>$stages,'events'=>$history,'read_only'=>true,'revision_scope'=>'visible_pages_only'];
$view=app(ProcessNavigation::class)->decorate($view,$allowed,$id);
$view['view_revision']=hash('sha256',Payables::canonical([$fingerprint,$view]));
return response()->json($view)->header('Cache-Control','no-store');
});
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Support\Facades\DB;
use LogicException;
/** Mounts the existing shared requisition adapter in a fresh, pinned QA composition. */
final class InternalRequisitionQa
{
public const FILES = [
'packages/purchasing/src/RequisitionPort.php',
'packages/purchasing/src/RequisitionFailure.php',
'packages/purchasing/src/InternalRequisitions.php',
'integrations/sgf/internal-requisitions/bootstrap.php',
'integrations/sgf/internal-requisitions/Schema.php',
'integrations/sgf/internal-requisitions/Port.php',
'integrations/sgf/internal-requisitions/Controller.php',
'integrations/sgf/internal-requisitions/routes.php',
'integrations/sgf/internal-requisitions/screen.php',
'integrations/sgf/internal-requisitions/screen.mjs',
];
public static function verifiedBundle(string $bundle, string $pin): string
{
$root = realpath($bundle);
if ($root === false || is_link($bundle) || ! preg_match('/^[a-f0-9]{64}$/D', $pin)
|| ! is_file($root.'/manifest.json') || is_link($root.'/manifest.json')
|| ! hash_equals($pin, hash_file('sha256', $root.'/manifest.json'))) {
throw new LogicException('REQUISITION_QA_BUNDLE_REFUSED');
}
$manifest = json_decode(file_get_contents($root.'/manifest.json'), true, 32, JSON_THROW_ON_ERROR);
$files = $manifest['files'] ?? [];
$expected = self::FILES; $actual = array_keys($files); sort($expected); sort($actual);
if (($manifest['format'] ?? null) !== 'sgf.requisitions.qa-bundle.v1' || $actual !== $expected) {
throw new LogicException('REQUISITION_QA_MANIFEST_REFUSED');
}
foreach ($files as $relative => $hash) {
$file = realpath($root.'/'.$relative);
if ($file === false || ! str_starts_with(strtolower($file), strtolower($root.DIRECTORY_SEPARATOR))
|| ! is_file($file) || ! is_string($hash) || ! preg_match('/^[a-f0-9]{64}$/D', $hash)
|| ! hash_equals($hash, hash_file('sha256', $file))) {
throw new LogicException('REQUISITION_QA_SOURCE_REFUSED');
}
$part = $root;
foreach (explode('/', $relative) as $segment) {
$part .= '/'.$segment;
if (is_link($part)) throw new LogicException('REQUISITION_QA_LINK_REFUSED');
}
}
return $root;
}
public static function mount(): ?string
{
if (config('internal_requisitions.qa_enabled') !== true) return null;
if (! app()->environment('budget-testing')) throw new LogicException('REQUISITION_QA_ENVIRONMENT_REFUSED');
$c = DB::connection(); $config = $c->getConfig();
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
if (! preg_match('/^[a-f0-9]{12}$/D', $run) || ($config['driver'] ?? null) !== 'pgsql'
|| ($config['host'] ?? null) !== '127.0.0.1' || (string) ($config['port'] ?? '') !== '55439'
|| ! preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D', $config['database'] ?? '', $m)
|| ($config['username'] ?? null) !== 'ir_composition_'.$m[1]
|| ! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])
|| ($config['search_path'] ?? 'public') !== 'public') throw new LogicException('REQUISITION_QA_DATABASE_REFUSED');
$identity = $c->selectOne("SELECT current_database() db,current_user role,current_setting('cluster_name') cluster,
rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
if ($identity->db !== $config['database'] || $identity->role !== $config['username'] || $identity->elevated
|| $identity->cluster !== 'sgf_modular_regression_'.$run) throw new LogicException('REQUISITION_QA_IDENTITY_REFUSED');
$bundle = self::verifiedBundle((string) config('internal_requisitions.qa_bundle'), (string) config('internal_requisitions.qa_manifest_sha256'));
$expected = 'C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/requisition-composition-'.$m[1].'/bundle';
if (strtolower(str_replace('\\', '/', $bundle)) !== strtolower($expected)) throw new LogicException('REQUISITION_QA_LOCATION_REFUSED');
require_once $bundle.'/integrations/sgf/internal-requisitions/bootstrap.php';
return $bundle.'/integrations/sgf/internal-requisitions/routes.php';
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{PurchaseOrder,Invoice,AuditLog};
use Tempo\Purchasing\Embedded\Services\{InvoiceWorkflow,ReceiptWorkflow,RequisitionWorkflow,InventoryDispositions,InventoryReversals,CurrentActor,OrganizationAccess};
/** Transport around the original supplier-document matching and budget-consumption workflow. */
final class InvoiceController
{
private function guard():void{abort_unless(config('receipt_qa.invoices_enabled')===true,404);}
private function viewer(object $a):void{abort_unless($a->hasFunctions(['admin','finance']),403);}
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
private function generation():string{return $this->hash(request()->session()->get('company_context'));}
private function order(int $id,array $b,object $a):PurchaseOrder{$this->viewer($a);$r=PurchaseOrder::visibleTo($a)->where('id',$id)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->first();abort_unless($r,403,'Ordem fora da origem autorizada.');return $r;}
private function state(PurchaseOrder $order):array{
$lines=$order->items()->orderBy('id')->get();$effects=[];foreach($lines as$line)$effects[]=['line'=>$line->id,'returned'=>app(InventoryDispositions::class)->returnedForOrderLine($line->id),'reversed'=>app(InventoryReversals::class)->voidedReceiptQuantity($line->id,'accepted_milli')];
return ['supplier'=>\Tempo\Purchasing\Embedded\Models\Supplier::findOrFail($order->supplier_id)->toArray(),'request'=>\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id)->load('steps')->toArray(),'conflicts'=>\Tempo\Purchasing\Embedded\Models\ConflictDeclaration::where(fn($q)=>$q->where('requisition_id',$order->requisition_id)->orWhere('supplier_id',$order->supplier_id))->orderBy('id')->get()->toArray(),'budgets'=>DB::table('budgets')->whereIn('id',DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->select('budget_id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'order'=>$order->load('items')->toArray(),'receipts'=>DB::table('goods_receipt_items')->whereIn('purchase_order_item_id',$lines->pluck('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'physical_corrections'=>$effects,'invoices'=>Invoice::where('purchase_order_id',$order->id)->with('items')->orderBy('id')->get()->toArray(),'budget_entries'=>DB::table('budget_entries')->where('requisition_id',$order->requisition_id)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all(),'budget_consumptions'=>DB::table('budget_consumptions')->whereIn('invoice_id',Invoice::where('purchase_order_id',$order->id)->select('id'))->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()];
}
private function input(Request $http,array $data,object $actor):Request{$r=Request::create('/embedded/supplier-invoice','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;}
private function dispatch(string $action,array $data,PurchaseOrder $order,object $actor,Request $http):array{
$workflow=app(InvoiceWorkflow::class);
if($action==='register'){
abort_if(array_diff(array_keys($data),['number','issued_on','subtotal','tax','total','items']),422);Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:purchase_order_item_id,quantity,unit,unit_price,tax_rate'])->validate();
(new \Tempo\Purchasing\Embedded\Http\Controllers\InvoiceController)->store($this->input($http,$data,$actor),$order,$workflow);
$invoice=Invoice::where('purchase_order_id',$order->id)->where('created_by',$actor->id)->orderByDesc('id')->firstOrFail();
}else{
abort_if(array_diff(array_keys($data),['invoice_id']),422);$d=Validator::make($data,['invoice_id'=>'required|integer|min:1'])->validate();$invoice=Invoice::where('purchase_order_id',$order->id)->findOrFail($d['invoice_id']);$workflow->validate($actor,$invoice);$invoice->refresh();
}
return ['invoice'=>$invoice,'result'=>['invoice_id'=>$invoice->id,'order_id'=>$order->id,'status'=>$invoice->status],'impact'=>['operation'=>$action==='register'?'Registar factura recebida pendente':'Validar conferência da factura','invoice'=>$invoice->load('items')->toArray(),'budget_consumption_minor'=>$action==='validate'?(int)$invoice->total_minor:0,'stock_changed'=>false,'payable_created'=>false,'payment_created'=>false,'ledger_posted'=>false]];
}
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','validate']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_invoices.'.$r,$c->unitId());return $out;}
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_invoices.view',fn($a)=>$this->viewer($a));$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$link=config('receipt_qa.obligations_enabled')===true&&app(\App\Modules\Identity\Services\Permissions::class)->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.view',$c->unitId());return response()->view('supplier-invoice-workspace',['obligations'=>$link,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($d){$this->viewer($a);return response()->json(PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)))->orderBy('id')->paginate(25,['id','number','status','currency'],'page',(int)($d['page']??1)));});}
public function show(int $id,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$rights){$order=$this->order($id,$b,$a);$state=$this->state($order);$revision=$this->hash($state);$matching=config('receipt_qa.invoice_progress_enabled')===true?app(InvoiceMatchingProgress::class)->forOrder($order,$state):null;$workflow=app(InvoiceWorkflow::class);foreach($state['invoices']as&$i)$i['can_validate']=$rights['validate']&&$workflow->reviewerEligible($a,Invoice::findOrFail($i['id']));unset($i);return response()->json(['order'=>$state['order'],'invoices'=>$state['invoices'],'revision'=>$revision,'matching_progress'=>$matching,'can_prepare'=>$rights['prepare']&&$a->hasFunctions(['finance'])&&!app(OrganizationAccess::class)->maintenanceOnly($a),'receipt_progress'=>app(ReceiptWorkflow::class)->progress($order),'physical_corrections'=>$state['physical_corrections']]);});}
public function history(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_invoices.view',function($a,$b)use($id,$d){$order=$this->order($id,$b,$a);return response()->json(AuditLog::where('requisition_id',$order->requisition_id)->whereIn('action',['invoice.created','invoice.validated','sgf.invoice.command'])->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:register,validate','reason'=>'required|string|min:10|max:2000','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536,422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($d['action']==='register'?'prepare':'validate'),function($a,$b,$f)use($id,$d,$r,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(hash_equals($this->hash($this->state($order)),$d['revision']),409,'A conferência mudou. Actualize a ordem.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$order,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if($d['action']==='register'){unset($out['impact']['invoice']['id']);foreach($out['impact']['invoice']['items']as&$line)unset($line['id'],$line['invoice_id']);unset($line);}$cut=$d+['order_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['reason'=>$d['reason']]]);});}
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['register','validate'],true),409);$generation=$this->generation();return $rt->run('purchasing_invoices.'.($cut['action']==='register'?'prepare':'validate'),function($a,$b,$f,$origin)use($id,$r,$cut,$generation){$order=$this->order($id,$b,$a);$a=app(CurrentActor::class)->lock($a,['finance']);abort_unless(($cut['order_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data'],$cut['reason']]);$old=AuditLog::where('requisition_id',$order->requisition_id)->where('user_id',$a->id)->where('action','sgf.invoice.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outro conteúdo.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($order)),$cut['revision']),409,'A base mudou desde a conferência.');$before=$this->state($order);$out=$this->dispatch($cut['action'],$cut['data'],$order,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.invoice.command',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),['invoices'=>$before['invoices']],$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'reason'=>$cut['reason'],'command'=>$cut['action'],'result'=>$out['result'],'invoice'=>$out['invoice']->load('items')->toArray()]);return response()->json($out['result']+['replayed'=>false],201);});}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Support\Facades\DB;
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
/** Read projection of the native matching quantities; never a reservation or writer. */
final class InvoiceMatchingProgress
{
public function quantities(int $ordered,int $accepted,int $returned,int $reversed,int $validated,int $pending):array
{
$net=$accepted-$returned-$reversed;$ceiling=min($ordered,$net);
return ['ordered_milli'=>$ordered,'accepted_milli'=>$accepted,'returned_milli'=>$returned,'reversed_milli'=>$reversed,'accepted_net_milli'=>$net,'validated_milli'=>$validated,'pending_milli'=>$pending,'remaining_milli'=>max(0,$ceiling-$validated),'deficit_milli'=>max(0,$validated-$ceiling)];
}
public function forOrder(object $order,array $state):array
{
$ids=array_column($state['order']['items'],'id');if($ids===[])return [];
$accepted=DB::table('goods_receipt_items')->where('tenant_id',$order->tenant_id)->whereIn('purchase_order_item_id',$ids)->groupBy('purchase_order_item_id')->selectRaw('purchase_order_item_id,SUM(accepted_milli) AS accepted')->get()->keyBy('purchase_order_item_id');
$invoiced=DB::table('invoice_items as l')->join('invoices as i','i.id','=','l.invoice_id')->where(['l.tenant_id'=>$order->tenant_id,'i.tenant_id'=>$order->tenant_id,'i.purchase_order_id'=>$order->id])->whereIn('l.purchase_order_item_id',$ids)->groupBy('l.purchase_order_item_id')->selectRaw("l.purchase_order_item_id,SUM(CASE WHEN i.status='validated' THEN l.quantity_milli ELSE 0 END) AS validated,SUM(CASE WHEN i.status='pending' THEN l.quantity_milli ELSE 0 END) AS pending")->get()->keyBy('purchase_order_item_id');
$effects=array_column($state['physical_corrections'],null,'line');$rows=[];
foreach($state['order']['items']as$line){$id=$line['id'];
$rows[]=['line_id'=>$id,'description'=>$line['description'],'unit'=>$line['unit']]+$this->quantities(ReceiptWorkflow::milli($line['quantity']),(int)($accepted[$id]->accepted??0),(int)($effects[$id]['returned']??0),(int)($effects[$id]['reversed']??0),(int)($invoiced[$id]->validated??0),(int)($invoiced[$id]->pending??0));
}return $rows;
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Invoice,PurchaseOrder,Requisition};
use Tempo\Purchasing\Embedded\Services\{SupplierPayables,RequisitionWorkflow};
/** Explicit recognition in the original native book; no imported mirror or second writer. */
final class ObligationController
{
private function guard():void { abort_unless(config('receipt_qa.obligations_enabled')===true,404); }
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
private function orders(object $a,array $b) {
return PurchaseOrder::visibleTo($a)->where(fn($q)=>$q->whereIn('id',$b['order_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($b)));
}
private function invoice(int $id,object $a,array $b):Invoice {
return Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'))->find($id)??abort(403,'Documento fora da origem autorizada.');
}
private function adapter(object $a):SupplierPayables {
$service=app(SupplierPayables::class);$service->readContext($a);return $service;
}
private function state(object $a,Invoice $invoice,SupplierPayables $service):array {
$scope=['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0];
$sources=DB::table('pa_sources')->where($scope)->where('local_source_id',$invoice->id)->select('id');
return ['document'=>$service->document($a,$invoice->id),
'proposals'=>DB::table('pa_proposals')->where($scope)->whereIn('source_id',$sources)->orderByDesc('id')->limit(1)->get()->all(),
'obligations'=>DB::table('pa_obligations')->where($scope)->whereIn('source_id',$sources)->orderBy('id')->get()->all(),
'identity'=>DB::table('pa_access')->where($scope)->where('user_id',$a->id)->orderBy('action')->get(['action','person'])->all()];
}
private function rights():array {
$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$rights=[];
foreach(['prepare','validate']as$r)$rights[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_obligations.'.$r,$c->unitId());return $rights;
}
private function commandId(string $action,int $proposal,Invoice $invoice,object $a):int {
if($action==='prepare')return $invoice->id;
abort_unless(DB::table('pa_proposals as p')->join('pa_sources as s','s.id','=','p.source_id')->where(['p.id'=>$proposal,'p.tenant_id'=>$a->tenant_id,'p.company_id'=>0,'p.unit_id'=>0,'s.tenant_id'=>$a->tenant_id,'s.company_id'=>0,'s.unit_id'=>0,'s.local_source_id'=>$invoice->id])->exists(),403,'Proposta fora do documento autorizado.');return $proposal;
}
private function execute(Payables $engine,array $cut,Invoice $invoice,object $a):array {
try{return $engine->command($cut['action'],$this->commandId($cut['action'],$cut['proposal_id'],$invoice,$a),$cut['reason'],$cut['operation_key']);}
catch(\DomainException|\InvalidArgumentException $e){abort(422,$e->getMessage());}
}
public function screen(Request $r,ReceiptRuntime $rt) {
$this->guard();$rt->run('purchasing_obligations.view',fn($a)=>$this->adapter($a));return response()->view('supplier-obligation-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);
}
public function index(Request $r,ReceiptRuntime $rt) {
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'sometimes|in:pending,validated','search'=>'sometimes|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $rt->run('purchasing_obligations.view',function($a,$b)use($d){$this->adapter($a);$q=Invoice::where('tenant_id',$a->tenant_id)->whereIn('purchase_order_id',$this->orders($a,$b)->select('id'));if(isset($d['status']))$q->where('status',$d['status']);if(isset($d['search']))$q->whereRaw('position(? in number)>0',[$d['search']]);return response()->json($q->orderBy('id')->paginate(25,['id','number','status','total_minor','purchase_order_id'],'page',(int)($d['page']??1)));});
}
public function show(int $id,Request $r,ReceiptRuntime $rt) {
$this->guard();$d=$r->validate(['proposal_page'=>'sometimes|integer|min:1|max:10000','event_page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$rights=$this->rights();
return $rt->run('purchasing_obligations.view',function($a,$b)use($id,$d,$rights){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$out=$service->engine($a)->listingPage([$id],$d['proposal_page']??1,$d['event_page']??1,25);foreach($rights as$right=>$allowed)$out['permissions'][$right]=$allowed&&$service->can($a,$right);return response()->json($out+['revision'=>$this->hash($this->state($a,$invoice,$service))]);});
}
public function preview(int $id,Request $r,ReceiptRuntime $rt) {
$this->guard();$d=$r->validate(['action'=>'required|in:prepare,validate','proposal_id'=>'required|integer|min:0','operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();
return $rt->run('purchasing_obligations.'.$d['action'],function($a,$b,$f)use($id,$d,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);abort_unless(hash_equals($this->hash($this->state($a,$invoice,$service)),$d['revision']),409,'A conferência mudou.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$result=$this->execute($service->engine($a),$d,$invoice,$a);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
unset($result['proposal_id'],$result['obligation_id']);$cut=$d+['person_revision'=>$this->hash($this->state($a,$invoice,$service)['identity']),'invoice_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['result'=>$result,'principal_minor'=>$invoice->total_minor,'due_on'=>null,'due_status'=>'unknown','budget_changed'=>false,'stock_changed'=>false,'payment_created'=>false,'ledger_posted'=>false,'reason'=>$d['reason']]]);});
}
public function confirm(int $id,Request $r,ReceiptRuntime $rt) {
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['prepare','validate'],true),409);$generation=$this->generation();
return $rt->run('purchasing_obligations.'.$cut['action'],function($a,$b,$f,$origin)use($id,$cut,$generation){$invoice=$this->invoice($id,$a,$b);$service=$this->adapter($a);$engine=$service->engine($a);abort_unless(($cut['invoice_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'O contexto ou a prévia mudou.');
// A committed retry still calls the kernel: current source/payment eligibility is rechecked.
$receipt=DB::table('pa_commands')->where(['tenant_id'=>$a->tenant_id,'company_id'=>0,'unit_id'=>0,'actor_id'=>$a->id,'command_key'=>$cut['operation_key']])->first();if($receipt)abort_unless((json_decode($receipt->result,true)['local_source_id']??null)===$id,409);
$before=$this->state($a,$invoice,$service);abort_unless(($cut['person_revision']??null)===$this->hash($before['identity']),409,'Identidade alterada.');if(!$receipt)abort_unless(hash_equals($this->hash($before),$cut['revision']),409,'A base mudou desde a conferência.');
$result=$this->execute($engine,$cut,$invoice,$a);
if(!($result['replayed']??false))app(RequisitionWorkflow::class)->audit($a,'sgf.obligation.command',Requisition::findOrFail(PurchaseOrder::findOrFail($invoice->purchase_order_id)->requisition_id),$before,$origin+['operation_key'=>$cut['operation_key'],'reason'=>$cut['reason'],'result'=>$result,'after'=>$this->state($a,$invoice,$service)]);
return response()->json($result,($result['state']??'')==='conflict'?409:(($result['replayed']??false)?200:201));});
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Tempo\Purchasing\Embedded\Models\{PurchaseAward,PurchaseOrder,Requisition,Quotation};
use Tempo\Purchasing\Embedded\Services\{PurchaseWorkflow,ProposalComparison,RequisitionWorkflow};
/** Issuance from an already approved award; no supplier selection or approval by this controller. */
final class OrderController
{
private function guard():void{abort_unless(config('receipt_qa.orders_enabled')===true,404);}
private function viewActor(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
private function award(int $id,array $binding,object $actor):array{
$this->viewActor($actor);
$award=PurchaseAward::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($award,403);
$request=Requisition::visibleTo($actor)->findOrFail($award->requisition_id);
return [$award,$request];
}
private function basis(PurchaseAward $award,Requisition $request):array{
$quote=Quotation::findOrFail($award->quotation_id)->load(['items','supplier']);
$basis=['award'=>$award->load('approvals')->toArray(),'requisition'=>$request->load(['items','steps'])->toArray(),'quote'=>$quote->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
return ['revision'=>hash('sha256',json_encode($basis,JSON_THROW_ON_ERROR)),'basis'=>$basis];
}
private function output(PurchaseOrder $order,bool $replayed):array{return ['order'=>$order->load('items')->toArray(),'replayed'=>$replayed,'receipt_url'=>'/purchasing/receipts','stock_created'=>false,'invoice_created'=>false,'payment_created'=>false];}
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_orders.view',fn($actor)=>$this->viewActor($actor));return response()->view('order-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_orders.view',function($actor,$binding)use($d){$this->viewActor($actor);return response()->json(PurchaseAward::where(fn($q)=>$q->whereIn('id',$binding['award_ids']??[])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->whereIn('requisition_id',Requisition::visibleTo($actor)->select('id'))->orderBy('id')->paginate(25,['id','requisition_id','status','total_minor'],'page',(int)($d['page']??1)));});}
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$ctx=app(\App\Shared\Context\CompanyContext::class);$scope=$ctx->identifiers();$can=app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$scope['tenant_id'],$scope['company_id'],'purchasing_orders.issue',$ctx->unitId());return $runtime->run('purchasing_orders.view',function($actor,$binding)use($id,$can){[$award,$request]=$this->award($id,$binding,$actor);$basis=$this->basis($award,$request);$order=PurchaseOrder::where('requisition_id',$request->id)->first();return response()->json($basis+['can_issue'=>$can&&app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),'existing'=>$order?$this->output($order,true):null]);});}
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['revision'=>'required|string|size:64']);abort_if(array_diff(array_keys($r->all()),['revision']),422);return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint)use($id,$d){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(app(PurchaseWorkflow::class)->canIssue($actor,$award,$request),403);$basis=$this->basis($award,$request);abort_unless(hash_equals($basis['revision'],$d['revision']),409,'A base aprovada mudou. Confira novamente.');$cut=['award'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'revision'=>$basis['revision'],'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['supplier'=>$basis['basis']['quote']['supplier']['name'],'total_minor'=>$award->total_minor,'currency'=>$basis['basis']['quote']['currency'],'delivery_date'=>$basis['basis']['award']['delivery_date'],'delivery_location'=>$award->delivery_location,'items'=>$basis['basis']['quote']['items'],'budget'=>'O workflow original revalida e regista o compromisso, se configurado.','stock'=>false,'invoice'=>false,'payment'=>false]]);});}
public function issue(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:32768']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}return $runtime->run('purchasing_orders.issue',function($actor,$binding,$fingerprint,$origin)use($id,$cut){[$award,$request]=$this->award($id,$binding,$actor);abort_unless(($cut['award']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O contexto ou a prévia mudou.');$existing=PurchaseOrder::where('requisition_id',$request->id)->first();abort_unless($existing||hash_equals($this->basis($award,$request)['revision'],$cut['revision']),409,'A base mudou desde a conferência.');$order=app(PurchaseWorkflow::class)->issue($actor,$award);if(!$existing)app(RequisitionWorkflow::class)->audit($actor,'sgf.order.issued',$request,null,$origin+['order_id'=>$order->id,'award_id'=>$award->id,'binding_revision'=>$fingerprint]);return response()->json($this->output($order,(bool)$existing),$existing?200:201);});}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Modules\Purchasing;
use Tempo\Purchasing\Embedded\Models\AuditLog;
/** A stage's history must not become a back door into downstream financial payloads. */
final class ProcessHistory
{
public function query(int $request,string $stage)
{
$patterns=match($stage){
'requests'=>['requisition.%','sgf.request.command'],
'sourcing'=>['requisition.%','sgf.request.command','quotation.%','award.%','sgf.sourcing.command'],
default=>throw new \InvalidArgumentException('Unknown history stage'),
};
return AuditLog::where('requisition_id',$request)->where(function($q)use($patterns){foreach($patterns as$pattern)$q->orWhere('action','like',$pattern);});
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Modules\Purchasing;
/** Links to already visible records; every destination retains its own authority checks. */
final class ProcessNavigation
{
public function decorate(array $view,array $allowed,int $request):array
{
if(config('receipt_qa.focus_enabled')!==true)return $view;
$link=fn(string $stage,array $ids,string $label)=>['href'=>'/purchasing/'.$stage.'?'.http_build_query($ids),'label'=>$label];
$view['_links']=[$link('requests',['request'=>$request],'Consultar esta requisição')];
if($allowed['sourcing'])$view['_links'][]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
foreach($view['stages']as$key=>&$stage){
if(!($stage['accessible']??false))continue;
$stage['data']=array_map(function($record)use($key,$allowed,$request,$link){
$row=is_array($record)?$record:(method_exists($record,'toArray')?$record->toArray():(array)$record);$links=[];
if($key==='quotes'&&$allowed['sourcing'])$links[]=$link('sourcing',['request'=>$request],'Consultar propostas desta requisição');
if($key==='awards'&&$allowed['orders'])$links[]=$link('orders',['award'=>$row['id']],'Consultar esta adjudicação e ordem');
if($key==='orders'){
if($allowed['receipts'])$links[]=$link('receipts',['order'=>$row['id']],'Consultar recepções desta ordem');
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['id']],'Consultar facturas desta ordem');
}
if($key==='receipts'&&$allowed['receipts'])$links[]=$link('receipts',['order'=>$row['purchase_order_id'],'receipt'=>$row['id']],'Consultar esta recepção e os seus anexos');
if($key==='invoices'){
if($allowed['invoices'])$links[]=$link('invoices',['order'=>$row['purchase_order_id'],'invoice'=>$row['id']],'Consultar esta factura');
if($allowed['obligations'])$links[]=$link('obligations',['invoice'=>$row['id']],'Consultar obrigação desta factura');
}
return $row+['_links'=>$links];
},$stage['data']);
}unset($stage);return $view;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Purchasing;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit access to existing synthetic identities, only in the selected local installation. */
final class PurchasingEntry
{
public static function profiles(array $fixture):array
{
$definitions=['requester'=>['Solicitante','Prepara e submete a necessidade.'],'approver'=>['Chefia','Decide requisições e adjudicações por identidade distinta.'],'actor'=>['Compras','Prepara fornecedores, propostas, comparação e ordens.'],'issuer'=>['Finanças','Revê fornecedores, avalia propostas, regista facturas e prepara obrigações.'],'validator'=>['Armazém','Confere e confirma a recepção física.'],'reviewer'=>['Revisor financeiro','Valida facturas e obrigações por identidade distinta.'],'auditor'=>['Consulta financeira','Consulta autorizada dos documentos financeiros.']];
$stages=['requests'=>'Requisições','suppliers'=>'Fornecedores','sourcing'=>'Propostas e adjudicação','orders'=>'Ordens','receipts'=>'Recepções','invoices'=>'Facturas','obligations'=>'Obrigações'];$profiles=[];
$grants=DB::table('permission_grants')->where($fixture['scope'])->whereIn('user_id',array_intersect_key($fixture,$definitions))->get(['user_id','permission'])->groupBy('user_id');
foreach($definitions as$key=>[$label,$description]){
if(!isset($fixture[$key]))continue;$available=[];
$rights=($grants[$fixture[$key]]??collect())->pluck('permission')->all();foreach($stages as$stage=>$name)if(in_array('purchasing_'.$stage.'.view',$rights,true))$available[$stage]=$name;
$profiles[$key]=['user_id'=>$fixture[$key],'label'=>$label,'description'=>$description,'stages'=>$available];
}return $profiles;
}
private static function guard():void
{
abort_unless(config('purchasing_workspace.single_installation')===true&&app()->environment('budget-testing')&&in_array(request()->ip(),['127.0.0.1','::1'],true),404);
BudgetAdapter::guard();
$id=(string)config('purchasing_workspace.id');$db=DB::connection();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$id)&&preg_match('/^[a-f0-9]{12}$/D',$run)
&&$db->getDatabaseName()==='budget_core_sgf_'.$id&&$db->getConfig('username')==='ir_composition_'.$id&&(int)$db->getConfig('port')===55439,403);
$identity=$db->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$identity->elevated&&$identity->role==='ir_composition_'.$id&&$identity->cluster==='sgf_modular_regression_'.$run,403);
}
public function screen(Request $r)
{
self::guard();$scope=config('purchasing_workspace.scope');
return response()->view('purchasing-entry',['profiles'=>config('purchasing_workspace.profiles'),'company'=>DB::table('companies')->find($scope['company_id']),'unit'=>DB::table('units')->find($scope['unit_id']),'returnTo'=>(string)$r->query('return_to','')])->header('Cache-Control','no-store')->header('X-SGF-Installation',config('purchasing_workspace.id'));
}
public function enter(Request $r)
{
self::guard();$d=$r->validate(['role'=>'required|string','stage'=>'required|string','return_to'=>'nullable|string|max:300']);abort_if(array_diff(array_keys($r->all()),['_token','role','stage','return_to']),422);
$profile=config('purchasing_workspace.profiles.'.$d['role']);abort_unless(is_array($profile)&&isset($profile['stages'][$d['stage']]),403);
$target='/purchasing/'.$d['stage'];
if(!empty($d['return_to'])){
$parts=parse_url($d['return_to']);abort_unless(is_array($parts)&&!isset($parts['scheme'])&&!isset($parts['host'])&&!isset($parts['fragment'])&&($parts['path']??null)===$target,422);
parse_str($parts['query']??'',$query);$allowed=['requests'=>['request'],'suppliers'=>['supplier'],'sourcing'=>['request'],'orders'=>['award'],'receipts'=>['order','receipt','movement'],'invoices'=>['order','invoice'],'obligations'=>['invoice']][$d['stage']];
abort_if(array_diff(array_keys($query),$allowed),422);foreach($query as$v)abort_unless(is_string($v)&&preg_match('/^[1-9][0-9]{0,14}$/D',$v),422);
$pairs=isset($parts['query'])?explode('&',$parts['query']):[];abort_unless(count($pairs)===count($query),422);
if($query){$primary=$d['stage']==='receipts'&&isset($query['movement'])?'movement':$allowed[0];abort_unless(isset($query[$primary]),422);if($primary==='movement')abort_unless(count($query)===1,422);}
$target.=isset($parts['query'])?'?'.http_build_query($query):'';
}
$user=User::findOrFail($profile['user_id']);auth()->login($user);$scope=config('purchasing_workspace.scope');$r->session()->put('auth_version',$user->auth_version);
$r->session()->put('company_context',$scope+['module_id'=>'purchasing','token'=>bin2hex(random_bytes(24))]);
return redirect($target);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Support\Facades\Schema;
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{StockItem,Requisition};
use Tempo\Purchasing\Embedded\Services\{SupplyPlanning,EquipmentWorkflow};
/** Bounded catalogue discovery; the existing receipt workflow remains the physical authority. */
final class ReceiptCatalogue
{
public function query(object $order,object $line) {
$q=StockItem::query()->whereRaw('lower(btrim(unit))=lower(btrim(?))',[$line->unit]);
if($line->purchase_type==='service')return $q->whereRaw('1=0');
$fixed=$order->snapshot['catalogue_sources'][$line->requisition_item_id]['stock_item_id']??null;
if($fixed!==null)$q->whereKey($fixed);
if(Schema::hasTable('supply_records')){
$mapping=app(SupplyPlanning::class)->latest('delivery')->where('purchase_order_id',$order->id)->where('data->purchase_order_item_id',$line->id)->first();
if($mapping)$q->whereKey($mapping->stock_item_id);
}
if($line->purchase_type==='spare_part'){
abort_unless($line->spare_part_id&&$line->equipment_id,422,'A ordem não contém associação autorizada da peça.');
$part=app(EquipmentWorkflow::class)->linkedPart((int)$line->spare_part_id,(int)$line->equipment_id,Requisition::findOrFail($order->requisition_id)->department_id);$q->whereKey($part->stock_item_id);
}
return $q;
}
public function revision(StockItem $item):string { return hash('sha256',Payables::canonical($item->toArray())); }
public function selected(array $data):array {
$ids=array_values(array_unique(array_filter(array_column($data['items'],'stock_item_id'))));sort($ids,SORT_NUMERIC);$revisions=[];
foreach($ids as$id){$item=StockItem::whereKey($id)->lockForUpdate()->first();abort_unless($item,422,'Artigo fora do catálogo autorizado.');$revisions[(string)$id]=$this->revision($item);}
ksort($revisions);return $revisions;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Gate,Validator};
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,StockItem,Warehouse};
use Tempo\Purchasing\Embedded\Services\ReceiptWorkflow;
final class ReceiptController
{
private function canConfirm():bool{$ctx=app(\App\Shared\Context\CompanyContext::class);$s=$ctx->identifiers();return app(\App\Modules\Identity\Services\Permissions::class)->has($ctx->userId(),$s['tenant_id'],$s['company_id'],'purchasing_receipts.confirm',$ctx->unitId());}
private function order(int $id,array $binding):PurchaseOrder{$order=PurchaseOrder::where('id',$id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);return $order;}
private function revision(PurchaseOrder $order,array $binding):string{return hash('sha256',json_encode([$order->getAttributes(),app(ReceiptWorkflow::class)->progress($order),$binding],JSON_THROW_ON_ERROR));}
private function result(GoodsReceipt $receipt,bool $replayed):array{return ['receipt'=>$receipt->load('items')->toArray(),'replayed'=>$replayed,'movements'=>DB::table('stock_movements')->where('tenant_id',$receipt->tenant_id)->whereIn('goods_receipt_item_id',$receipt->items->pluck('id'))->orderBy('id')->get(['id','goods_receipt_item_id','quantity_milli','warehouse_id','stock_item_id','movement_type'])->all()];}
public function catalogue(int $id,int $line,Request $r,ReceiptRuntime $runtime) {
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $runtime->run('purchasing_receipts.confirm',function($actor,$binding)use($id,$line,$d){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$source=$order->items()->find($line);abort_unless($source,403,'Linha fora da ordem autorizada.');$catalogue=app(ReceiptCatalogue::class);$q=$catalogue->query($order,$source);if(isset($d['search'])&&$d['search']!=='')$q->where(fn($w)=>$w->whereRaw('position(lower(?) in lower(code))>0',[$d['search']])->orWhereRaw('position(lower(?) in lower(name))>0',[$d['search']]));$page=$q->orderBy('code')->orderBy('id')->paginate((int)($d['per_page']??25),['*'],'page',(int)($d['page']??1));$page->through(fn($item)=>$item->only(['id','code','name','unit'])+['selection_revision'=>$catalogue->revision($item)]);return response()->json($page)->header('Cache-Control','no-store');});
}
public function history(int $id,Request $r,ReceiptRuntime $runtime) {
$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d){$order=$this->order($id,$binding);return response()->json(GoodsReceipt::where('purchase_order_id',$order->id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1)))->header('Cache-Control','no-store');});
}
public function screen(Request $r,ReceiptRuntime $runtime){$runtime->run('purchasing_receipts.view',fn()=>true);return response()->view('receipt-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($d){Gate::authorize('viewAny',GoodsReceipt::class);$orders=PurchaseOrder::where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->orderBy('id')->paginate((int)($d['per_page']??25),['id','number','status'], 'page',(int)($d['page']??1));return response()->json($orders);});}
public function show(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$d,$can){$order=$this->order($id,$binding);return response()->json(['order'=>$order->only(['id','number','status']),'progress'=>app(ReceiptWorkflow::class)->progress($order),'revision'=>$this->revision($order,$binding),'can_confirm'=>$can&&Gate::allows('create',[GoodsReceipt::class,$order]),'warehouses'=>Warehouse::whereIn('id',$binding['warehouse_ids'])->orderBy('id')->get(['id','code','name']),'items'=>StockItem::orderBy('id')->limit(25)->get(['id','code','name','unit']),'history'=>GoodsReceipt::where('purchase_order_id',$id)->orderByDesc('id')->paginate(25,['id','number','status','received_on','received_by'],'page',(int)($d['page']??1))]);});}
public function preview(int $id,Request $r,ReceiptRuntime $runtime){return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint)use($id,$r){$order=$this->order($id,$binding);Gate::authorize('create',[GoodsReceipt::class,$order]);$input=$r->all();$revision=$input['revision']??null;$selected=$input['item_revisions']??null;unset($input['revision'],$input['item_revisions']);abort_if(array_diff(array_keys($input),['idempotency_key','delivery_note','received_on','warehouse_id','items']),422);$rules=(new \Tempo\Purchasing\Embedded\Http\Requests\ConfirmReceiptRequest)->setUserResolver(fn()=>$actor)->rules();$data=Validator::make($input,$rules)->validate();abort_unless(in_array((int)($data['warehouse_id']??0),$binding['warehouse_ids'],true),403);abort_unless(is_string($revision)&&hash_equals($this->revision($order,$binding),$revision),409,'A origem mudou. Actualize e confira novamente.');$items=app(ReceiptCatalogue::class)->selected($data);if($selected!==null){Validator::make(['selected'=>$selected],['selected'=>'array|max:50','selected.*'=>'required|string|size:64'])->validate();foreach($selected as$key=>$value)abort_unless(isset($items[(string)$key])&&hash_equals($items[(string)$key],$value),409,'O artigo mudou desde a escolha. Pesquise novamente.');} $cut=['item_revisions'=>$items,'actor'=>$actor->id,'fingerprint'=>$fingerprint,'order'=>$id,'revision'=>$revision,'data'=>$data,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['document'=>'Recepção confirmada, com histórico imutável','stock'=>'Apenas quantidades físicas aceites geram entrada, uma única vez.','payments'=>false,'accounting'=>false],'data'=>$data]);});}
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$d=$r->validate(['preview'=>'required|string|max:100000']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida. Confira novamente.');}return $runtime->run('purchasing_receipts.confirm',function($actor,$binding,$fingerprint,$origin)use($id,$cut){$order=$this->order($id,$binding);abort_unless(($cut['actor']??null)===$actor->id&&($cut['order']??null)===$id&&($cut['fingerprint']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou. Confira novamente.');$existing=GoodsReceipt::where('purchase_order_id',$id)->where('idempotency_key',$cut['data']['idempotency_key'])->first();abort_unless($existing||hash_equals($this->revision($order,$binding),$cut['revision']),409,'Recepção alterada por outra operação. Confira novamente.');if(!$existing){$current=app(ReceiptCatalogue::class)->selected($cut['data']);abort_unless(($cut['item_revisions']??null)===$current,409,'O artigo mudou desde a previa. Confira novamente.');}$receipt=app(ReceiptWorkflow::class)->confirm($actor,$order,$cut['data']);if(!$existing)app(\Tempo\Purchasing\Embedded\Services\RequisitionWorkflow::class)->audit($actor,'sgf.receipt.confirmed',\Tempo\Purchasing\Embedded\Models\Requisition::findOrFail($order->requisition_id),null,$origin+['receipt_id'=>$receipt->id,'binding_revision'=>$fingerprint]);return response()->json($this->result($receipt,(bool)$existing),$existing?200:201);});}
public function receipt(int $id,ReceiptRuntime $runtime){$can=$this->canConfirm();return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$can){$receipt=GoodsReceipt::findOrFail($id);$this->order($receipt->purchase_order_id,$binding);Gate::authorize('view',$receipt);return response()->json($this->result($receipt,true)+['documents_enabled'=>config('receipt_qa.receipt_documents_enabled')===true,'document_revision'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical([$receipt->getAttributes()])),'can_attach'=>$can&&config('receipt_qa.receipt_documents_enabled')===true&&app(\Tempo\Purchasing\Embedded\Services\ReceiptDocuments::class)->canAdd($actor,$receipt)]);});}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\{Request,UploadedFile};
use Illuminate\Support\Facades\{Crypt,DB,Gate};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder,ReceiptDocument,AuditLog,Requisition};
use Tempo\Purchasing\Embedded\Services\{ReceiptDocuments,RequisitionWorkflow};
/** Private transport; all attachment decisions and writes belong to the pinned native service. */
final class ReceiptDocumentsController
{
private const FIELDS=['id','goods_receipt_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'];
private function guard():void { abort_unless(config('receipt_qa.receipt_documents_enabled')===true,404); }
private function hash(array $v):string { return hash('sha256',Payables::canonical($v)); }
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
private function source(int $id,array $binding):GoodsReceipt {
$receipt=GoodsReceipt::findOrFail($id);
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);return $receipt;
}
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
private function integrity(ReceiptDocument $doc,string $root):void {
$file=app(SupplierDocumentFiles::class)->path($root,$doc->path,(int)$doc->tenant_id,(int)$doc->goods_receipt_id);
abort_unless(filesize($file)===(int)$doc->size&&hash_equals($doc->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
}
private function same(GoodsReceipt $receipt,array $cut):?ReceiptDocument { return ReceiptDocument::where('goods_receipt_id',$receipt->id)->where('document_type',$cut['document_type'])->where('sha256',$cut['file']['sha256'])->first(); }
public function index(int $id,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $runtime->run('purchasing_receipts.view',function($a,$b)use($id,$d){$receipt=$this->source($id,$b);abort_unless(app(ReceiptDocuments::class)->canRead($a,$receipt),403);return response()->json(ReceiptDocument::where('goods_receipt_id',$id)->orderByDesc('id')->paginate(15,self::FIELDS,'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
}
public function preview(int $id,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f)use($id,$r,$d,$generation,$meta){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$d['revision']),409,'A recepção mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true,'receipt');$db=DB::connection();$level=$db->transactionLevel();$document=null;$existing=$this->same($receipt,$d+['file'=>$meta]);$db->beginTransaction();
try{$document=$files->using($stage,fn()=>$service->add($a,$receipt,$d,$r->file('file')),'receipt_documents');$impact=['document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at']);}
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document&&!$existing)$files->discard($stage,$document->path,(int)$receipt->tenant_id,$id,'receipt_documents');}
// A reused document belongs to permanent storage; never delete or stage it.
if($existing)$this->integrity($existing,$files->root(false,'receipt'));
$cut=$d+['receipt_id'=>$id,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
}
public function confirm(int $id,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$meta=$this->meta($r->file('file'));$generation=$this->generation();$created=null;
try{return $runtime->run('purchasing_receipts.confirm',function($a,$b,$f,$o)use($id,$r,$cut,$meta,$generation,&$created){$receipt=$this->source($id,$b);$service=app(ReceiptDocuments::class);abort_unless($service->canAdd($a,$receipt),403);
abort_unless(($cut['receipt_id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
$hash=$this->hash([$id,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$old=AuditLog::where('action','sgf.receipt.document')->where('after->operation_key',$cut['operation_key'])->first();
if($old){abort_unless($old->user_id===$a->id&&($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($old->after['result']['document_id']);$this->integrity($doc,$root);return response()->json($old->after['result']+['replayed'=>true]);}
abort_unless(hash_equals($this->hash([$receipt->getAttributes()]),$cut['revision']),409,'A recepção mudou desde a conferência.');$existing=$this->same($receipt,$cut);$document=$files->using($root,fn()=>$service->add($a,$receipt,$cut,$r->file('file')),'receipt_documents');if(!$existing)$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$receipt->tenant_id,'receipt'=>$id];$this->integrity($document,$root);
$result=['receipt_id'=>$id,'document_id'=>$document->id,'document'=>$document->only(self::FIELDS),'reused'=>$existing!==null,'receipt_unchanged'=>true,'stock_unchanged'=>true];$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document',Requisition::findOrFail($order->requisition_id),null,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
catch(\Throwable $e){if($created){try{$exists=DB::connection('receipt_native')->table('receipt_documents')->where(['tenant_id'=>$created['tenant'],'goods_receipt_id'=>$id,'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$id,'receipt_documents');}catch(\Throwable){/* An uncertain commit retains its evidence. */}}throw $e;}
}
public function download(int $id,int $document,Request $r,ReceiptRuntime $runtime) {
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_receipts.view',function($a,$b,$f,$o)use($id,$document,$r){$receipt=$this->source($id,$b);$doc=ReceiptDocument::where('goods_receipt_id',$id)->findOrFail($document);$files=app(SupplierDocumentFiles::class);$root=$files->root(false,'receipt');$files->path($root,$doc->path,(int)$doc->tenant_id,$id);$input=Request::create('/embedded/receipt-document','GET');$input->setUserResolver(fn($guard=null)=>$a);
$response=$files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\ReceiptDocumentController)->download($input,$receipt,$document,app(ReceiptDocuments::class)),'receipt_documents');$order=PurchaseOrder::findOrFail($receipt->purchase_order_id);app(RequisitionWorkflow::class)->audit($a,'sgf.receipt.document_downloaded',Requisition::findOrFail($order->requisition_id),null,$o+['receipt_id'=>$id,'document_id'=>$document,'sha256'=>$doc->sha256]);return $response;});
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Modules\Purchasing;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\{Auth,DB,Gate,Schema};
/** Local QA composition of the original receipt workflow, never an HTTP proxy or second writer. */
final class ReceiptRuntime
{
private static array $loaded=[];
private static function loadClass(string $class,string $bundle,array $files):void
{
foreach(['Tempo\\Purchasing\\Embedded\\'=>'app/','Laravel\\Fortify\\'=>'fortify/']as$prefix=>$directory){
if(!str_starts_with($class,$prefix))continue;
$tail=substr($class,strlen($prefix));abort_unless(preg_match('/^[A-Za-z0-9_\\\\]+$/D',$tail),503);
$relative=$directory.str_replace('\\','/',$tail).'.php';$pin=$files[$relative]??null;$file=realpath($bundle.'/'.$relative);$base=realpath($bundle);
abort_unless(is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin)&&$base&&$file&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR))&&!is_link($bundle.'/'.$relative)&&hash_equals($pin,hash_file('sha256',$file)),503,'Dependência do pacote incompatível.');
require_once $file;return;
}
}
public function run(string $right,\Closure $work):mixed
{
abort_unless(config('receipt_qa.enabled')===true,404);
if(str_starts_with($right,'purchasing_obligations.'))abort_unless(config('receipt_qa.obligations_enabled')===true,404);
if(str_starts_with($right,'purchasing_invoices.'))abort_unless(config('receipt_qa.invoices_enabled')===true,404);
if(str_starts_with($right,'purchasing_requests.'))abort_unless(config('receipt_qa.requests_enabled')===true,404);
if(str_starts_with($right,'purchasing_orders.'))abort_unless(config('receipt_qa.orders_enabled')===true,404);
if(str_starts_with($right,'purchasing_suppliers.'))abort_unless(config('receipt_qa.suppliers_enabled')===true,404);
if(str_starts_with($right,'purchasing_sourcing.'))abort_unless(config('receipt_qa.sourcing_enabled')===true,404);
abort_unless(app()->environment('budget-testing'),403);
BudgetAdapter::guard();
$host=DB::connection();$db=$host->getDatabaseName();$role=$host->getConfig('username');
abort_unless(preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$db,$m)&&$role==='ir_composition_'.$m[1],403);
$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
$hostConfig=$host->getConfig();abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&($hostConfig['host']??null)==='127.0.0.1'&&(int)($hostConfig['port']??0)===55439&&empty($hostConfig['read'])&&empty($hostConfig['write'])&&($hostConfig['search_path']??'public')==='public',403);
$identity=$host->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$identity->elevated&&$identity->role===$role&&$identity->cluster==='sgf_modular_regression_'.$run,403);
$expected='C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-'.$m[1];
$profile=realpath((string)config('receipt_qa.profile'));
abort_unless($profile&&strtolower(str_replace('\\','/',$profile))===strtolower($expected.'/binding.json'),403);
$p=json_decode(file_get_contents($profile),true,64,JSON_THROW_ON_ERROR);
$ctx=app(CompanyContext::class);$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();
abort_unless($scope['unit_id']&&request()->user()->id===$user,403);
$key=implode(':',array_values($scope)).':'.$user;$binding=$p['bindings'][$key]??null;abort_unless($binding,403,'Origem de recepção não autorizada neste contexto.');
if(config('receipt_qa.obligations_enabled')===true)abort_unless(($binding['payable_instance']??null)==='qa-stock-ledger-'.$m[1]&&config('common-payables.source_instance')===$binding['payable_instance']&&config('common-payables.history_enabled')===false&&config('common-payables.history_decisions_enabled')===false,403);
$p['context_key']=$key;
$bundle=realpath($expected.'/bundle');$manifest=$bundle.'/manifest.json';
abort_unless($bundle&&hash_equals($p['manifest_sha256'],hash_file('sha256',$manifest)),503);
$files=json_decode(file_get_contents($manifest),true,64,JSON_THROW_ON_ERROR)['files'];
foreach($files as$file=>$pin)abort_unless(preg_match('~^(app|fortify)/[A-Za-z0-9_/]+\.php$~D',$file)&&!str_contains($file,'..')&&is_string($pin)&&preg_match('/^[a-f0-9]{64}$/D',$pin),503);
if(config('receipt_qa.orders_enabled')===true||config('receipt_qa.sourcing_enabled')===true){
$economic=$expected.'/economic';abort_unless(isset($p['economic_manifest_sha256'])&&is_file($economic.'/manifest.json')&&hash_equals($p['economic_manifest_sha256'],hash_file('sha256',$economic.'/manifest.json')),503);
$economicFiles=json_decode(file_get_contents($economic.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($economicFiles);sort($names);abort_unless($names===['ProposalScoring.php','QuotationPricing.php'],503);
foreach($economicFiles as$file=>$pin){abort_unless(is_file($economic.'/'.$file)&&!is_link($economic.'/'.$file)&&hash_equals($pin,hash_file('sha256',$economic.'/'.$file)),503);$class='Tempo\\Purchasing\\Economic\\'.basename($file,'.php');if(class_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $economic.'/'.$file;}
}
if(config('receipt_qa.requests_enabled')===true){
$internal=$expected.'/internal';abort_unless(isset($p['internal_manifest_sha256'])&&hash_equals($p['internal_manifest_sha256'],hash_file('sha256',$internal.'/manifest.json')),503);$pins=json_decode(file_get_contents($internal.'/manifest.json'),true,32,JSON_THROW_ON_ERROR)['files'];$names=array_keys($pins);sort($names);abort_unless($names===['InternalRequisitions.php','RequisitionFailure.php','RequisitionPort.php'],503);foreach(['RequisitionPort.php','RequisitionFailure.php','InternalRequisitions.php']as$file){$pin=$pins[$file];abort_unless(preg_match('/^[a-f0-9]{64}$/D',$pin)&&!is_link($internal.'/'.$file)&&hash_equals($pin,hash_file('sha256',$internal.'/'.$file)),503);$class='Tempo\\Purchasing\\Internal\\'.basename($file,'.php');if(class_exists($class,false)||interface_exists($class,false)){abort_unless(hash_equals($pin,hash_file('sha256',(new \ReflectionClass($class))->getFileName())),503);}else require_once $internal.'/'.$file;}
}
if(!isset(self::$loaded[$bundle])){
abort_unless(self::$loaded===[],503,'Uma só versão do workflow por processo.');
spl_autoload_register(fn($class)=>self::loadClass($class,$bundle,$files));
Gate::policy(\Tempo\Purchasing\Embedded\Models\GoodsReceipt::class,\Tempo\Purchasing\Embedded\Policies\GoodsReceiptPolicy::class);Gate::policy(\Tempo\Purchasing\Embedded\Models\Supplier::class,\Tempo\Purchasing\Embedded\Policies\SupplierPolicy::class);self::$loaded[$bundle]=true;
app()->singleton(\Tempo\Purchasing\Embedded\Services\ReplenishmentRun::class);
}
$connection=$p['connection'];abort_unless(($connection['driver']??null)==='pgsql'&&($connection['host']??null)==='127.0.0.1'&&(int)($connection['port']??0)===55439
&&($connection['database']??null)==='compras_receipts_test_'.$m[1]&&($connection['username']??null)==='payable_history_native_'.$m[1]
&&empty($connection['url'])&&empty($connection['read'])&&empty($connection['write'])&&($connection['search_path']??null)==='public',403);
config(['database.connections.receipt_native'=>$connection]);$native=DB::connection('receipt_native');
$i=$native->selectOne("SELECT current_user role,current_database() db,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$i->elevated&&$i->role===$connection['username']&&$i->db===$connection['database']&&$i->cluster==='sgf_modular_regression_'.$run,403);
return $host->transaction(function()use($host,$scope,$user,$right,$binding,$work,$p,$native){
// Hold existing authority until the native transaction ends; no host business writes.
abort_unless($host->table('users')->where('id',$user)->lockForUpdate()->first(),403);
abort_unless($host->table('company_memberships')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id']])->lockForUpdate()->first(),403);
$view=str_starts_with($right,'purchasing_invoices.')?'purchasing_invoices.view':(str_starts_with($right,'purchasing_requests.')?'purchasing_requests.view':(str_starts_with($right,'purchasing_suppliers.')?'purchasing_suppliers.view':(str_starts_with($right,'purchasing_sourcing.')?'purchasing_sourcing.view':(str_starts_with($right,'purchasing_orders.')?'purchasing_orders.view':'purchasing_receipts.view'))));
if(str_starts_with($right,'purchasing_obligations.'))$view='purchasing_obligations.view';
foreach(['company.view'=>null,'unit.view'=>$scope['unit_id'],$view=>$scope['unit_id'],$right=>$scope['unit_id']]as$permission=>$unit)
abort_unless($host->table('permission_grants')->where(['user_id'=>$user,'tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'unit_id'=>$unit,'permission'=>$permission])->lockForUpdate()->first(),403);
$default=DB::getDefaultConnection();$guard=Auth::getDefaultDriver();$environment=app()->environment();
config(['auth.guards.receipt_native'=>['driver'=>'session','provider'=>'receipt_native'],'auth.providers.receipt_native'=>['driver'=>'eloquent','model'=>\Tempo\Purchasing\Embedded\Models\User::class]]);
DB::setDefaultConnection('receipt_native');Schema::clearResolvedInstance('db.schema');Auth::shouldUse('receipt_native');app()->instance('env','testing');
try{
$actor=\Tempo\Purchasing\Embedded\Models\User::where('tenant_id',$binding['tenant_id'])->findOrFail($binding['actor_id']);Auth::guard('receipt_native')->setUser($actor);
return $native->transaction(function()use($native,$actor,$binding,$work,$p){
$native->table('tenants')->where('id',$actor->tenant_id)->lockForUpdate()->firstOrFail();
$version=[$p['context_key'],$binding,$p['manifest_sha256']];$origin=['sgf_context_key'=>$p['context_key'],'workflow_manifest'=>$p['manifest_sha256']];
if(isset($p['internal_manifest_sha256'])){$version[]=$p['internal_manifest_sha256'];$origin['internal_manifest']=$p['internal_manifest_sha256'];}
if(isset($p['economic_manifest_sha256'])){$version[]=$p['economic_manifest_sha256'];$origin['economic_manifest']=$p['economic_manifest_sha256'];}
return $work($actor,$binding,hash('sha256',json_encode($version,JSON_THROW_ON_ERROR)),$origin);
});
}finally{Auth::forgetGuards();Auth::shouldUse($guard);DB::setDefaultConnection($default);Schema::clearResolvedInstance('db.schema');app()->instance('env',$environment);}
});
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Requisition,AuditLog,CostCenter};
use Tempo\Purchasing\Embedded\Services\{RequisitionWorkflow,OrganizationAccess};
/** Native requisition lifecycle in SGF, sharing the same reservation and order book. */
final class RequestController
{
private function guard():void{abort_unless(config('receipt_qa.requests_enabled')===true,404);}
private function hash(mixed $v):string{return hash('sha256',Payables::canonical([$v]));}
private function state(?Requisition $r):?array{return $r?->load(['items','steps'])->toArray();}
private function source(int $id,array $b,object $a):?Requisition{abort_unless($a->hasFunctions(['requester','procurement','finance','department_head']),403);if(!$id)return null;abort_unless(RequisitionAccess::allowed($id,$b),403);return Requisition::visibleTo($a)->findOrFail($id);}
private function right(string $a):string{return ['save'=>'prepare','submit'=>'submit','decide'=>'approve'][$a];}
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$v=[];foreach(['prepare','submit','approve']as$r)$v[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_requests.'.$r,$c->unitId());return $v;}
private function historyPage(int $id,object $actor,string $binding,int $size=25,?string $token=null):array {
$generation=$this->hash(request()->session()->get('company_context'));$cursor=null;
if($token!==null){try{$cut=json_decode(Crypt::decryptString($token),true,32,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Cursor inválido. Actualize o histórico.');}
abort_unless(($cut['request']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$binding&&($cut['generation']??null)===$generation&&($cut['size']??null)===$size&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time()&&is_string($cut['cursor']??null),409,'A consulta ou o contexto mudou. Actualize o histórico.');
$cursor=\Illuminate\Pagination\Cursor::fromEncoded($cut['cursor']);abort_unless($cursor,409,'Cursor inválido. Actualize o histórico.');
}
$page=app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->cursorPaginate($size,['id','user_id','action','created_at','before','after'],'cursor',$cursor);
$encode=fn($value)=>$value?Crypt::encryptString(json_encode(['request'=>$id,'actor'=>$actor->id,'binding'=>$binding,'generation'=>$generation,'size'=>$size,'observed'=>time(),'cursor'=>$value->encode()],JSON_THROW_ON_ERROR)):null;
return ['data'=>$page->items(),'navigation'=>['next'=>$encode($page->nextCursor()),'previous'=>$encode($page->previousCursor()),'per_page'=>$size,'order'=>'sequence_desc']];
}
public function history(int $id,Request $r,ReceiptRuntime $rt){
$this->guard();abort_unless(config('receipt_qa.request_history_enabled')===true,404);abort_unless($id>0,404);$d=$r->validate(['per_page'=>'sometimes|integer|in:25,100','cursor'=>'sometimes|required|string|max:16384']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$d){$this->source($id,$b,$a);return response()->json($this->historyPage($id,$a,$fingerprint,(int)($d['per_page']??25),$d['cursor']??null))->header('Cache-Control','private, no-store');});
}
private function dispatch(string $action,array $data,?Requisition $record,object $actor,array $binding,string $key):array{
$workflow=app(RequisitionWorkflow::class);$before=$this->state($record);
if($action==='save'){
abort_if(array_diff(array_keys($data),['cost_center_id','priority','needed_by','justification','items']),422);
Validator::make($data,['items'=>'required|array|min:1|max:50','items.*'=>'array:description,category,purchase_type,unit,quantity,unit_price,tax_rate','items.*.purchase_type'=>'required|in:consumable,service,other'])->validate();
$input=(new \Tempo\Purchasing\Embedded\Http\Requests\StoreRequisitionRequest)->setUserResolver(fn()=>$actor);
$data=Validator::make($data,$input->rules())->validate();abort_unless(in_array((int)$data['cost_center_id'],$binding['centre_ids']??[],true),403);
$record=$workflow->save($actor,$data+['operation_key'=>$key]+($record?['process_version'=>(int)$record->process_version]:[]),$record);
}elseif($action==='submit'){
abort_unless($record&&$data===[],422);$workflow->submit($actor,$record,$key,(int)$record->process_version);
}else{
abort_unless($record,422);abort_if(array_diff(array_keys($data),['decision','comment']),422);$data=Validator::make($data,['decision'=>'required|in:approved,rejected','comment'=>'required|string|min:10|max:2000'])->validate();$workflow->decide($actor,$record,$data['decision'],$data['comment'],$key,(int)$record->process_version);
}
$record->refresh();return ['result'=>['request_id'=>$record->id,'status'=>$record->status,'version'=>(int)$record->process_version],'impact'=>['operation'=>$action,'before'=>$before,'after'=>$this->state($record),'budget_authority'=>'Compras: mesmo livro da ordem; sem reserva no host SGF','reservation_on_submit'=>$action==='submit','release_on_rejection'=>$action==='decide'&&$data['decision']==='rejected']];
}
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_requests.view',fn($a)=>$this->source(0,[],$a));return response()->view('request-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','status'=>'nullable|in:draft,pending,approved,rejected']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_requests.view',function($a,$b)use($d,$rights){$this->source(0,$b,$a);$q=Requisition::visibleTo($a)->whereIn('id',RequisitionAccess::ids($b));if($d['status']??'')$q->where('status',$d['status']);return response()->json(['requests'=>$q->orderByDesc('id')->paginate(15,['id','number','status','process_version'],'page',(int)($d['page']??1)),'centres'=>CostCenter::whereIn('id',$b['centre_ids']??[])->where('department_id',$a->department_id)->get(['id','code','name']),'new_revision'=>$this->hash(null),'can_prepare'=>$rights['prepare']&&app(OrganizationAccess::class)->canRequest($a)]);});}
public function show(int $id,ReceiptRuntime $rt){$this->guard();abort_unless($id>0,404);$rights=$this->rights();return $rt->run('purchasing_requests.view',function($a,$b,$fingerprint)use($id,$rights){$r=$this->source($id,$b,$a);$state=$this->state($r);$own=$r->requester_id===$a->id&&$r->status==='draft';$paged=config('receipt_qa.request_history_enabled')===true?$this->historyPage($id,$a,$fingerprint):null;return response()->json(['request'=>$state,'revision'=>$this->hash($state),'capabilities'=>['save'=>$rights['prepare']&&$own,'submit'=>$rights['submit']&&$own,'decide'=>$rights['approve']&&$r->canDecide($a)],'history'=>$paged?$paged['data']:app(ProcessHistory::class)->query($id,'requests')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at','before','after']),'history_navigation'=>$paged['navigation']??null]);});}
public function preview(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:save,submit,decide','data'=>'present|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>65536||($id===0&&$d['action']!=='save'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_requests.'.$this->right($d['action']),function($a,$b,$f)use($r,$id,$d){$record=$this->source($id,$b,$a);abort_unless(hash_equals($this->hash($this->state($record)),$d['revision']),409,'A requisição mudou. Confira novamente.');$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$record,$a,$b,$d['operation_key']);}finally{while($db->transactionLevel()>$level)$db->rollBack();}if(!$id){unset($out['impact']['after']['id'],$out['impact']['after']['number']);foreach($out['impact']['after']['items']as&$line)unset($line['id'],$line['requisition_id']);unset($line);}$cut=$d+['id'=>$id,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
public function confirm(int $id,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'',['save','submit','decide'],true),409);return $rt->run('purchasing_requests.'.$this->right($cut['action']),function($a,$b,$f,$o)use($id,$cut){$record=$this->source($id,$b,$a);abort_unless(($cut['id']??null)===$id&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'A prévia ou o contexto mudou.');$hash=$this->hash([$id,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.request.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($record)),$cut['revision']),409,'A requisição mudou desde a conferência.');$before=$this->state($record);$out=$this->dispatch($cut['action'],$cut['data'],$record,$a,$b,$cut['operation_key']);$record=Requisition::findOrFail($out['result']['request_id']);app(RequisitionWorkflow::class)->audit($a,'sgf.request.command',$record,$before,$o+['request_scope'=>RequisitionAccess::scope(),'operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$f,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Modules\Purchasing;
use Tempo\Purchasing\Embedded\Models\Requisition;
use App\Shared\Context\CompanyContext;
/** One native record/book; admission is an explicit binding or an audited host origin. */
final class RequisitionAccess
{
public static function scope():string{$c=app(CompanyContext::class);return hash('sha256',json_encode($c->identifiers()+['unit_id'=>$c->unitId()],JSON_THROW_ON_ERROR));}
public static function ids(array $binding){return Requisition::query()->select('requisitions.id')->where(function($q)use($binding){$q->whereIn('requisitions.id',$binding['requisition_ids']??[]);if(config('receipt_qa.requests_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as ra')->whereColumn('ra.tenant_id','requisitions.tenant_id')->whereColumn('ra.requisition_id','requisitions.id')->where('ra.action','sgf.request.command')->where('ra.after->request_scope',self::scope()));});}
public static function allowed(int $id,array $binding):bool{return self::ids($binding)->where('requisitions.id',$id)->exists();}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Requisition,Quotation,PurchaseAward,Supplier,AuditLog};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,QuotationParticipation,ProposalComparison,PurchaseWorkflow,RequisitionWorkflow};
/** Transport and receipts around the original quotation/award commands; no pricing or scoring engine. */
final class SourcingController
{
private const ROLES=['quote'=>['procurement'],'evaluate'=>['procurement','finance','department_head'],'select'=>['procurement'],'decide'=>['procurement','finance','department_head']];
private const RIGHTS=['quote'=>'prepare','evaluate'=>'evaluate','select'=>'select','decide'=>'approve'];
private function guard():void{abort_unless(config('receipt_qa.sourcing_enabled')===true,404);}
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
private function source(int $id,array $binding,object $actor):Requisition{$this->viewer($actor);abort_unless(RequisitionAccess::allowed($id,$binding),403);return Requisition::visibleTo($actor)->findOrFail($id);}
private function hash(mixed $value):string{return hash('sha256',Payables::canonical($value));}
private function state(Requisition $request):array{
return ['request'=>$request->load(['items','steps'])->toArray(),'quotes'=>Quotation::where('requisition_id',$request->id)->with(['items','supplier','evaluations'])->orderBy('id')->get()->toArray(),'award'=>PurchaseAward::where('requisition_id',$request->id)->with('approvals')->first()?->toArray(),'comparison'=>app(ProposalComparison::class)->inspect($request)];
}
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','evaluate','select','approve']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_sourcing.'.$r,$c->unitId());return $out;}
private function input(Request $r,array $data,object $actor):Request{$input=Request::create('/embedded/sourcing','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);$input->setUserResolver(fn($guard=null)=>$actor);if($r->hasSession())$input->setLaravelSession($r->session());return $input;}
private function supplierAccess(string $action,array $data,array $binding,string $context):?string {
if($action!=='quote')return null;
$d=Validator::make($data,['supplier_id'=>'required|integer|min:1','supplier_revision'=>'sometimes|required|string|size:64'])->validate();
$supplier=SupplierAccess::query($binding,$context)->find($d['supplier_id']);abort_unless($supplier,404);
$revision=$this->hash($supplier->toArray());
if(isset($d['supplier_revision']))abort_unless(hash_equals($revision,$d['supplier_revision']),409,'O fornecedor mudou desde a selecção. Pesquise e confira novamente.');
return $revision;
}
private function eligibleSuppliers(array $binding,string $context) {
return SupplierAccess::query($binding,$context)->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today());
}
public function suppliers(int $id,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','per_page'=>'sometimes|integer|in:25,100','search'=>'sometimes|nullable|string|max:100']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $runtime->run('purchasing_sourcing.prepare',function($actor,$binding,$fingerprint,$origin)use($id,$d){
$request=$this->source($id,$binding,$actor);abort_unless(app(QuotationParticipation::class)->canPrepare($actor,$request),403);
$q=$this->eligibleSuppliers($binding,$origin['sgf_context_key']);
if(isset($d['search'])&&$d['search']!=='')$q->where(fn($query)=>$query->whereRaw('position(lower(?) in lower(suppliers.name))>0',[$d['search']])->orWhereRaw('position(? in suppliers.nuit)>0',[$d['search']]));
$page=$q->orderBy('suppliers.name')->orderBy('suppliers.id')->paginate((int)($d['per_page']??25),['suppliers.*'],'page',(int)($d['page']??1));
$page->through(fn($s)=>['id'=>$s->id,'name'=>$s->name,'nuit'=>$s->nuit,'version'=>$s->version,'selection_revision'=>$this->hash($s->toArray())]);
return response()->json($page)->header('Cache-Control','no-store');
});
}
private function dispatch(string $action,array $data,Requisition $request,object $actor,Request $http):array{
$keys=match($action){'quote'=>['supplier_id','supplier_revision','reference','delivery_days','valid_until','payment_terms','no_conflict','items'],'evaluate'=>['quotation_id','quality','history','payment','comment','no_conflict'],'select'=>['quotation_id','justification','delivery_date','delivery_location'],'decide'=>['award_id','decision','comment']};abort_if(array_diff(array_keys($data),$keys),422);
$audit=app(RequisitionWorkflow::class);
if($action==='quote'){
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->store($this->input($http,array_diff_key($data,['supplier_revision'=>true]),$actor),$request,$audit);
$quote=Quotation::where('requisition_id',$request->id)->where('supplier_id',$data['supplier_id'])->with(['supplier','items'])->firstOrFail();
return ['result'=>['quotation_id'=>$quote->id],'impact'=>['operation'=>'Registar proposta','supplier'=>$quote->supplier->name,'total_minor'=>$quote->total_minor,'currency'=>$quote->currency,'lines'=>$quote->items->map(fn($i)=>$i->only(['description','quantity','unit_price_minor','tax_minor','total_minor']))->all()]];
}
if($action==='evaluate'){
$quote=Quotation::where('requisition_id',$request->id)->findOrFail($data['quotation_id']??0);$payload=$data;unset($payload['quotation_id']);
(new \Tempo\Purchasing\Embedded\Http\Controllers\QuotationController)->evaluate($this->input($http,$payload,$actor),$quote,$audit);
return ['result'=>['quotation_id'=>$quote->id,'evaluator_id'=>$actor->id],'impact'=>['operation'=>'Registar avaliação individual','supplier'=>$quote->supplier->name,'quality'=>$data['quality'],'history'=>$data['history'],'payment'=>$data['payment'],'comment'=>$data['comment']]];
}
if($action==='select'){
$validated=Validator::make($data,['quotation_id'=>'required|integer','justification'=>'required|string|min:10|max:5000','delivery_date'=>'required|date_format:Y-m-d|after_or_equal:today','delivery_location'=>'required|string|min:3|max:500'])->validate();
$award=app(PurchaseWorkflow::class)->select($actor,$request,$validated);
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Submeter adjudicação para aprovação distinta','supplier'=>Quotation::findOrFail($award->quotation_id)->supplier->name,'total_minor'=>$award->total_minor,'delivery_date'=>$validated['delivery_date'],'delivery_location'=>$award->delivery_location,'required_approvals'=>$award->approvals->pluck('role')->all()]];
}
$validated=Validator::make($data,['award_id'=>'required|integer','decision'=>'required|in:approved,rejected','comment'=>'nullable|required_if:decision,rejected|string|max:2000'])->validate();$award=PurchaseAward::where('requisition_id',$request->id)->findOrFail($validated['award_id']);
app(PurchaseWorkflow::class)->decide($actor,$award,$validated['decision'],$validated['comment']??null);$award->refresh();
return ['result'=>['award_id'=>$award->id,'status'=>$award->status],'impact'=>['operation'=>'Decidir adjudicação','decision'=>$validated['decision'],'resulting_status'=>$award->status,'total_minor'=>$award->total_minor,'comment'=>$validated['comment']??null]];
}
public function screen(Request $r,ReceiptRuntime $runtime){$this->guard();$runtime->run('purchasing_sourcing.view',fn($actor)=>$this->viewer($actor));return response()->view('sourcing-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($d){$this->viewer($actor);return response()->json(Requisition::visibleTo($actor)->whereIn('id',RequisitionAccess::ids($binding))->where('status','approved')->orderBy('id')->paginate(25,['id','number','status'],'page',(int)($d['page']??1)));});}
public function history(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $runtime->run('purchasing_sourcing.view',function($actor,$binding)use($id,$d){$this->source($id,$binding,$actor);return response()->json(app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->paginate(25,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
public function show(int $id,ReceiptRuntime $runtime){$this->guard();$rights=$this->rights();return $runtime->run('purchasing_sourcing.view',function($actor,$binding,$fingerprint,$origin)use($id,$rights){$request=$this->source($id,$binding,$actor);$state=$this->state($request);$part=app(QuotationParticipation::class);$quotes=Quotation::where('requisition_id',$id)->get()->keyBy('id');foreach($state['comparison']['rows']as&$row)$row['can_evaluate']=$rights['evaluate']&&$part->canEvaluate($actor,$request,$quotes[$row['id']]);unset($row);$award=PurchaseAward::where('requisition_id',$id)->first();return response()->json($state+['revision'=>$this->hash($this->state($request)),'capabilities'=>['quote'=>$rights['prepare']&&$part->canPrepare($actor,$request),'select'=>$rights['select']&&$actor->hasFunctions(['procurement'])&&$actor->id!==$request->requester_id&&!$award&&$state['comparison']['ready'],'decide'=>$rights['approve']&&$award&&app(PurchaseWorkflow::class)->canDecide($actor,$award,$request)],'suppliers'=>SupplierAccess::query($binding,$origin['sgf_context_key'])->where('status','active')->where('blocked',false)->whereDate('next_review','>=',today())->orderBy('id')->limit(25)->get(['id','name']),'history'=>app(ProcessHistory::class)->query($id,'sourcing')->orderByDesc('id')->limit(25)->get(['id','user_id','action','created_at'])]);});}
public function preview(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:quote,evaluate,select,decide','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data'],JSON_THROW_ON_ERROR))>65536,422);$d['operation_key']=strtolower($d['operation_key']);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$d['action']],function($actor,$binding,$fingerprint,$origin)use($id,$d,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$d['action']]);abort_unless(hash_equals($this->hash($this->state($request)),$d['revision']),409,'O processo mudou. Actualize e confira novamente.');
// Exact native validation/pricing in a rollback-only savepoint; no preview record is committed.
$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$supplierRevision=$this->supplierAccess($d['action'],$d['data'],$binding,$origin['sgf_context_key']);$out=$this->dispatch($d['action'],$d['data'],$request,$actor,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}
$cut=$d+['supplier_revision'=>$supplierRevision,'request_id'=>$id,'actor'=>$actor->id,'binding'=>$fingerprint,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']+['stock'=>false,'order'=>false,'payment'=>false],'operation_key'=>$d['operation_key']]);});}
public function confirm(int $id,Request $r,ReceiptRuntime $runtime){$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,128,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(isset(self::RIGHTS[$cut['action']??'']),409);return $runtime->run('purchasing_sourcing.'.self::RIGHTS[$cut['action']],function($actor,$binding,$fingerprint,$origin)use($id,$cut,$r){$request=$this->source($id,$binding,$actor);$actor=app(CurrentActor::class)->lock($actor,self::ROLES[$cut['action']]);abort_unless(($cut['request_id']??null)===$id&&($cut['actor']??null)===$actor->id&&($cut['binding']??null)===$fingerprint&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou o contexto mudou.');$hash=$this->hash([$cut['action'],$cut['data']]);$old=AuditLog::where('requisition_id',$id)->where('user_id',$actor->id)->where('action','sgf.sourcing.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){$receipt=$old->after;abort_unless(($receipt['request_hash']??null)===$hash&&($receipt['binding_revision']??null)===$fingerprint,409,'Chave já usada com outros dados.');return response()->json($receipt['result']+['replayed'=>true]);}abort_unless(hash_equals($this->hash($this->state($request)),$cut['revision']),409,'O processo mudou desde a conferência.');$supplierRevision=$this->supplierAccess($cut['action'],$cut['data'],$binding,$origin['sgf_context_key']);if($cut['action']==='quote')abort_unless(is_string($cut['supplier_revision']??null)&&hash_equals($cut['supplier_revision'],$supplierRevision),409,'Fornecedor alterado; exige nova conferencia.');$out=$this->dispatch($cut['action'],$cut['data'],$request,$actor,$r);app(RequisitionWorkflow::class)->audit($actor,'sgf.sourcing.command',$request,null,$origin+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding_revision'=>$fingerprint,'command'=>$cut['action'],'supplier_revision'=>$supplierRevision,'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Modules\Purchasing;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{DB,Gate};
use Tempo\Purchasing\Embedded\Models\{GoodsReceipt,PurchaseOrder};
/** Resolve an actual movement to its authorised receipt; URL parent ids are never trusted. */
final class StockReceiptTraceController
{
public function show(int $id,Request $r,ReceiptRuntime $runtime,CompanyContext $ctx)
{
abort_unless(config('receipt_qa.stock_trace_enabled')===true&&config('receipt_qa.stock_read_enabled')===true,404);
abort_if($r->query()!==[],422);
$scope=$ctx->identifiers()+['unit_id'=>$ctx->unitId()];$user=$ctx->userId();$host=DB::connection();
return $runtime->run('purchasing_receipts.view',function($actor,$binding)use($id,$scope,$user,$host){
abort_unless($host->table('permission_grants')->where($scope+['user_id'=>$user,'permission'=>'stock.view'])->sharedLock()->first(),403);
$tag=substr($host->getDatabaseName(),strlen('budget_core_sgf_'));
$file='C:/laragon/data/sgf-tempo/run/modular-regression-'.getenv('SGF_ISOLATED_REGRESSION_RUN').'/private/receipt-composition-'.$tag.'/stock-binding.json';
abort_unless(is_file($file)&&!is_link($file),503,'Origem de stock indisponível.');
try{$profile=json_decode(file_get_contents($file),true,32,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(503,'Origem de stock incompatível.');}
$stock=$profile['bindings'][implode(':',array_values($scope)).':'.$user]??null;
abort_unless($stock&&($stock['source_tenant_id']??null)===$binding['tenant_id']
&&($profile['connection']['database']??null)===DB::connection()->getDatabaseName()
&&($stock['schema']??null)==='public'&&($stock['append_only']??false)===true,403);
$movement=DB::table('stock_movements')->where('tenant_id',$binding['tenant_id'])->where('id',$id)
->whereIn('warehouse_id',array_values(array_intersect($stock['warehouse_ids']??[],$binding['warehouse_ids'])))
->where('movement_type','receipt_in')->first(['id','goods_receipt_item_id','stock_item_id','warehouse_id','quantity_milli','snapshot']);
abort_unless($movement,403,'Movimento fora da origem autorizada.');
$line=DB::table('goods_receipt_items')->where('tenant_id',$binding['tenant_id'])->where('id',$movement->goods_receipt_item_id)->first();
abort_unless($line,409,'A evidência de origem exige nova conferência.');
$receipt=GoodsReceipt::findOrFail($line->goods_receipt_id);
$order=PurchaseOrder::where('id',$receipt->purchase_order_id)->where(fn($q)=>$q->whereIn('id',$binding['order_ids'])->orWhereIn('requisition_id',RequisitionAccess::ids($binding)))->first();
abort_unless($order,403);Gate::authorize('viewOrder',[GoodsReceipt::class,$order]);Gate::authorize('view',$receipt);
try{$snapshot=json_decode($movement->snapshot,true,64,JSON_THROW_ON_ERROR);}catch(\JsonException){abort(409,'A evidência de origem exige nova conferência.');}
abort_unless($receipt->tenant_id===$binding['tenant_id']&&(int)$receipt->warehouse_id===(int)$movement->warehouse_id
&&(int)$line->stock_item_id===(int)$movement->stock_item_id&&(string)$line->accepted_milli===(string)$movement->quantity_milli
&&(int)$movement->quantity_milli>0&&($snapshot['receipt_id']??null)===$receipt->id,409,'O movimento e a recepção exigem nova conferência.');
return response()->json(['movement_id'=>(int)$movement->id,'order_id'=>$order->id,'receipt_id'=>$receipt->id,
'href'=>'/purchasing/receipts?order='.$order->id.'&receipt='.$receipt->id,'read_only'=>true])->header('Cache-Control','no-store');
});
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Modules\Purchasing;
use Tempo\Purchasing\Embedded\Models\Supplier;
/** Explicit fixture bindings and suppliers actually prepared in this company/unit. */
final class SupplierAccess
{
public static function scope(string $context):string{return hash('sha256',substr($context,0,strrpos($context,':')));}
public static function query(array $binding,string $context){
return Supplier::query()->where(function($q)use($binding,$context){
$q->whereIn('suppliers.id',$binding['supplier_ids']??[]);
if(config('receipt_qa.suppliers_enabled')===true)$q->orWhereExists(fn($a)=>$a->selectRaw('1')->from('audit_logs as sa')->whereColumn('sa.tenant_id','suppliers.tenant_id')->where('sa.action','sgf.supplier.command')->where('sa.after->supplier_scope',self::scope($context))->whereRaw("sa.after->'result'->>'supplier_id'=suppliers.id::text"));
});
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Crypt,DB};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Supplier,AuditLog};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,SupplierParticipation,RequisitionWorkflow};
/** QA transport around the original supplier controller and FormRequest. */
final class SupplierController
{
private function guard():void{abort_unless(config('receipt_qa.suppliers_enabled')===true,404);}
private function viewer(object $actor):void{abort_unless($actor->hasFunctions(['admin','procurement','finance','department_head']),403);}
private function hash(mixed $data):string{return hash('sha256',Payables::canonical([$data]));}
private function source(int $id,array $b,object $a,string $context):?Supplier{$this->viewer($a);return $id===0?null:SupplierAccess::query($b,$context)->findOrFail($id);}
private function revision(?Supplier $s):string{return $this->hash($s?->toArray());}
private function rights():array{$c=app(\App\Shared\Context\CompanyContext::class);$s=$c->identifiers();$p=app(\App\Modules\Identity\Services\Permissions::class);$out=[];foreach(['prepare','review']as$r)$out[$r]=$p->has($c->userId(),$s['tenant_id'],$s['company_id'],'purchasing_suppliers.'.$r,$c->unitId());return $out;}
private function right(string $action):string{return $action==='review'?'review':'prepare';}
private function dispatch(string $action,array $data,?Supplier $supplier,object $actor,Request $http):array{
$controller=new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierController;$workflow=app(RequisitionWorkflow::class);
$class=$action==='review'?Request::class:\Tempo\Purchasing\Embedded\Http\Requests\SaveSupplierRequest::class;
$input=$class::create('/embedded/suppliers','POST',$data,[],[],['HTTP_ACCEPT'=>'application/json']);
$input->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$input->setLaravelSession($http->session());
if($action!=='review'){
$input->setContainer(app())->setRedirector(app('redirect'));
$route=new \Illuminate\Routing\Route(['POST'],'embedded/suppliers/{supplier}',fn()=>null);$route->bind($input);if($supplier)$route->setParameter('supplier',$supplier);else $route->forgetParameter('supplier');$input->setRouteResolver(fn()=>$route);
abort_if(array_diff(array_keys($data),array_keys($input->rules())),422);$input->validateResolved();
}else abort_if(array_diff(array_keys($data),['status','comment','version']),422);
if($action==='create'){$controller->store($input,$workflow);$supplier=Supplier::where('nuit',$data['nuit'])->firstOrFail();}
elseif($action==='update')$controller->update($input,$supplier,$workflow);
else $controller->review($input,$supplier,$workflow);
$supplier->refresh();return ['result'=>['supplier_id'=>$supplier->id,'status'=>$supplier->status,'version'=>$supplier->version],'impact'=>['operation'=>$action,'before'=>$action==='create'?null:$supplier->getOriginal(),'after'=>$supplier->toArray()]];
}
public function screen(Request $r,ReceiptRuntime $rt){$this->guard();$rt->run('purchasing_suppliers.view',fn($a)=>$this->viewer($a));return response()->view('supplier-workspace',['context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function index(Request $r,ReceiptRuntime $rt){$this->guard();$rights=$this->rights();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000','search'=>'nullable|string|max:100','status'=>'nullable|in:pending,active,suspended']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($d,$rights){$this->viewer($a);$q=SupplierAccess::query($b,$o['sgf_context_key']);if($d['search']??'')$q->where(fn($w)=>$w->where('name','ilike','%'.$d['search'].'%')->orWhere('nuit','like','%'.$d['search'].'%'));if($d['status']??'')$q->where('status',$d['status']);$part=app(SupplierParticipation::class);return response()->json(['suppliers'=>$q->orderBy('name')->orderBy('id')->paginate(15,['id','name','nuit','status','version'],'page',(int)($d['page']??1)),'new_revision'=>$this->revision(null),'can_prepare'=>$rights['prepare']&&$part->canPrepare($a)]);});}
public function show(int $supplier,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$rights=$this->rights();return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$rights){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$part=app(SupplierParticipation::class);return response()->json(['supplier'=>$s,'revision'=>$this->revision($s),'documents_enabled'=>config('receipt_qa.supplier_documents_enabled')===true&&\Illuminate\Support\Facades\Gate::allows('viewDocuments',$s),'geography_enabled'=>config('geography.enabled')===true,'capabilities'=>['prepare'=>$rights['prepare']&&$part->canPrepare($a),'review'=>$rights['review']&&$part->canReview($a,$s)]]);});}
public function history(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();abort_unless($supplier>0,404);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);return $rt->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);return response()->json(AuditLog::where('tenant_id',$s->tenant_id)->where(function($q)use($supplier){$q->where(fn($w)=>$w->whereIn('action',['supplier.created','supplier.updated','supplier.reviewed'])->where('after->id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_uploaded')->where('after->document->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','supplier.document_downloaded')->where('after->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.document')->where('after->result->supplier_id',$supplier))->orWhere(fn($w)=>$w->where('action','sgf.supplier.command')->where('after->result->supplier_id',$supplier));})->orderByDesc('id')->paginate(15,['id','user_id','action','created_at','before','after'],'page',(int)($d['page']??1)));});}
public function preview(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','action'=>'required|in:create,update,review','data'=>'required|array']);abort_if(array_diff(array_keys($r->all()),array_keys($d))||strlen(json_encode($d['data']))>16384,422);abort_unless(($supplier===0)===($d['action']==='create'),422);$d['operation_key']=strtolower($d['operation_key']);return $rt->run('purchasing_suppliers.'.$this->right($d['action']),function($a,$b,$f,$o)use($supplier,$r,$d){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$d['action']==='review'?'finance':'procurement']);abort_unless(hash_equals($this->revision($s),$d['revision']),409,'A ficha mudou. Confira novamente.');$before=$s?->toArray();$db=DB::connection();$level=$db->transactionLevel();$db->beginTransaction();try{$out=$this->dispatch($d['action'],$d['data'],$s,$a,$r);}finally{while($db->transactionLevel()>$level)$db->rollBack();}$out['impact']['before']=$before;if($d['action']==='create')unset($out['impact']['after']['id'],$out['impact']['after']['created_at'],$out['impact']['after']['updated_at']);$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$out['impact']]);});}
public function confirm(int $supplier,Request $r,ReceiptRuntime $rt){$this->guard();$d=$r->validate(['preview'=>'required|string|max:65536']);abort_if(array_diff(array_keys($r->all()),['preview']),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(in_array($cut['action']??'', ['create','update','review'],true),409);return $rt->run('purchasing_suppliers.'.$this->right($cut['action']),function($a,$b,$f,$o)use($supplier,$r,$cut){$s=$this->source($supplier,$b,$a,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,[$cut['action']==='review'?'finance':'procurement']);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'A prévia ou contexto mudou.');$hash=$this->hash([$supplier,$cut['action'],$cut['data']]);$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.command')->where('after->operation_key',$cut['operation_key'])->first();if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding_revision']??null)===$f,409,'Chave usada com outros dados.');return response()->json($old->after['result']+['replayed'=>true]);}abort_unless(hash_equals($this->revision($s),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s?->toArray();$out=$this->dispatch($cut['action'],$cut['data'],$s,$a,$r);app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.command',null,$before,$o+['supplier_scope'=>SupplierAccess::scope($o['sgf_context_key']),'operation_key'=>$cut['operation_key'],'binding_revision'=>$f,'request_hash'=>$hash,'command'=>$cut['action'],'result'=>$out['result']]);return response()->json($out['result']+['replayed'=>false],201);});}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Support\Facades\Storage;
/** Storage is confined to the already verified composition, never public or client storage. */
final class SupplierDocumentFiles
{
public function root(bool $preview=false,string $resource='supplier'):string {
abort_unless(in_array($resource,['supplier','receipt'],true),403);
$profile=realpath((string)config('receipt_qa.profile'));abort_unless($profile,403);
$base=str_replace('\\','/',dirname($profile));$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&preg_match('~^'.preg_quote('C:/laragon/data/sgf-tempo/run/modular-regression-'.$run.'/private/receipt-composition-','~').'[a-f0-9]{12}$~iD',$base),403);
$container=$base.'/'.$resource.($preview?'-document-previews':'-documents');abort_if(is_link($container),403);
if(!is_dir($container))mkdir($container,0700);
abort_unless(realpath($container)&&str_starts_with(strtolower(realpath($container)),strtolower(realpath($base).DIRECTORY_SEPARATOR)),403);
$root=$preview?$container.'/'.bin2hex(random_bytes(16)):$container;
if(!is_dir($root))mkdir($root,0700);abort_unless(realpath($root)&&!is_link($root),403);return $root;
}
public function using(string $root,\Closure $work,string $disk='supplier_documents'):mixed {
abort_unless(in_array($disk,['supplier_documents','receipt_documents'],true),403);
$before=config('filesystems.disks.'.$disk);config(['filesystems.disks.'.$disk=>['driver'=>'local','root'=>$root,'throw'=>true,'serve'=>false]]);Storage::forgetDisk($disk);
try{return $work(Storage::disk($disk));}finally{Storage::forgetDisk($disk);config(['filesystems.disks.'.$disk=>$before]);}
}
public function path(string $root,string $path,int $tenant,int $supplier):string {
abort_unless(preg_match('~^'.$tenant.'/'.$supplier.'/[A-Za-z0-9]{40}\.(pdf|png|jpe?g)$~D',$path),409,'Referência de anexo inválida.');
$file=realpath($root.'/'.$path);$base=realpath($root);abort_unless($file&&$base&&!is_link($root.'/'.$path)&&str_starts_with(strtolower($file),strtolower($base.DIRECTORY_SEPARATOR)),409,'Anexo indisponível.');return $file;
}
public function discard(string $root,string $path,int $tenant,int $supplier,string $disk='supplier_documents'):void {
$this->path($root,$path,$tenant,$supplier);$this->using($root,fn($storage)=>$storage->delete($path),$disk);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Modules\Purchasing;
use Illuminate\Http\{Request,UploadedFile};
use Illuminate\Support\Facades\{Crypt,DB,Gate};
use Tempo\Finance\Payables\Payables;
use Tempo\Purchasing\Embedded\Models\{Supplier,SupplierDocument,AuditLog};
use Tempo\Purchasing\Embedded\Services\{CurrentActor,RequisitionWorkflow};
/** Transport around the original upload/review invalidation and private audited download. */
final class SupplierDocumentsController
{
private function guard():void { abort_unless(config('receipt_qa.supplier_documents_enabled')===true,404); }
private function hash(array $value):string { return hash('sha256',Payables::canonical($value)); }
private function generation():string { return $this->hash([request()->session()->get('company_context')]); }
private function source(int $id,array $binding,string $context):Supplier { return SupplierAccess::query($binding,$context)->find($id)??abort(404); }
private function meta(UploadedFile $file):array { return ['sha256'=>hash_file('sha256',$file->getPathname()),'size'=>$file->getSize(),'mime'=>$file->getMimeType(),'name'=>$file->getClientOriginalName()]; }
private function input(Request $http,object $actor,string $type):Request {
$files=$http->file('file')?['file'=>$http->file('file')]:[];
$r=Request::create('/embedded/supplier-documents','POST',['document_type'=>$type],[],$files,['HTTP_ACCEPT'=>'application/json']);$r->setUserResolver(fn($guard=null)=>$actor);if($http->hasSession())$r->setLaravelSession($http->session());return $r;
}
private function store(Request $http,object $actor,Supplier $supplier,string $type):SupplierDocument {
(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->store($this->input($http,$actor,$type),$supplier,app(RequisitionWorkflow::class));return $supplier->documents()->orderByDesc('id')->firstOrFail();
}
private function document(Supplier $supplier,int $id):SupplierDocument { return $supplier->documents()->find($id)??abort(404); }
private function integrity(SupplierDocument $document,string $root):void {
$file=app(SupplierDocumentFiles::class)->path($root,$document->path,(int)$document->tenant_id,(int)$document->supplier_id);
abort_unless(filesize($file)===(int)$document->size&&hash_equals($document->sha256,hash_file('sha256',$file)),409,'O ficheiro difere da evidência registada.');
}
public function index(int $supplier,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['page'=>'sometimes|integer|min:1|max:10000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$d){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);return response()->json($s->documents()->orderByDesc('id')->paginate(15,['id','supplier_id','document_type','original_name','mime_type','size','sha256','uploaded_by','created_at'],'page',(int)($d['page']??1)))->header('Cache-Control','private, no-store');});
}
public function preview(int $supplier,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['operation_key'=>'required|uuid','revision'=>'required|string|size:64','reason'=>'required|string|min:10|max:2000','document_type'=>'required|string|max:30','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);unset($d['file']);$d['operation_key']=strtolower($d['operation_key']);$generation=$this->generation();$meta=$this->meta($r->file('file'));
return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$d,$generation,$meta){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(hash_equals($this->hash([$s->toArray()]),$d['revision']),409,'A ficha mudou. Confira novamente.');$files=app(SupplierDocumentFiles::class);$stage=$files->root(true);$db=DB::connection();$level=$db->transactionLevel();$document=null;$db->beginTransaction();
try{$files->using($stage,function()use($r,$a,$s,$d,&$document){$document=$this->store($r,$a,$s,$d['document_type']);});$impact=['document'=>$document->toArray(),'supplier_status'=>'pending','supplier_version'=>$s->fresh()->version,'reason'=>$d['reason']];unset($impact['document']['id'],$impact['document']['created_at'],$impact['document']['updated_at']);}
finally{while($db->transactionLevel()>$level)$db->rollBack();if($document)$files->discard($stage,$document->path,(int)$s->tenant_id,$s->id);}
$cut=$d+['supplier_id'=>$supplier,'actor'=>$a->id,'binding'=>$f,'generation'=>$generation,'file'=>$meta,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>$impact]);});
}
public function confirm(int $supplier,Request $r,ReceiptRuntime $runtime) {
$this->guard();$d=$r->validate(['preview'=>'required|string|max:131072','file'=>'required|file|max:2048']);abort_if(array_diff(array_keys($r->all()),array_keys($d)),422);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}$generation=$this->generation();$meta=$this->meta($r->file('file'));$created=null;
try{return $runtime->run('purchasing_suppliers.prepare',function($a,$b,$f,$o)use($supplier,$r,$cut,$generation,$meta,&$created){$s=$this->source($supplier,$b,$o['sgf_context_key']);$a=app(CurrentActor::class)->lock($a,['procurement']);Gate::authorize('update',$s);abort_unless(($cut['supplier_id']??null)===$supplier&&($cut['actor']??null)===$a->id&&($cut['binding']??null)===$f&&($cut['generation']??null)===$generation&&($cut['file']??null)===$meta&&is_int($cut['observed']??null)&&$cut['observed']<=time()&&$cut['observed']>=time()-600,409,'O ficheiro, contexto ou prévia mudou.');
$hash=$this->hash([$supplier,$cut['file'],$cut['document_type'],$cut['reason']]);$files=app(SupplierDocumentFiles::class);$root=$files->root();$old=AuditLog::where('user_id',$a->id)->where('action','sgf.supplier.document')->where('after->operation_key',$cut['operation_key'])->first();
if($old){abort_unless(($old->after['request_hash']??null)===$hash&&($old->after['binding']??null)===$f,409,'Chave usada com outro conteúdo.');$this->integrity($this->document($s,$old->after['result']['document_id']),$root);return response()->json($old->after['result']+['replayed'=>true]);}
abort_unless(hash_equals($this->hash([$s->toArray()]),$cut['revision']),409,'A ficha mudou desde a conferência.');$before=$s->toArray();$document=$files->using($root,fn()=>$this->store($r,$a,$s,$cut['document_type']));$created=['root'=>$root,'path'=>$document->path,'tenant'=>(int)$s->tenant_id,'supplier'=>$s->id];$this->integrity($document,$root);$result=['supplier_id'=>$s->id,'document_id'=>$document->id,'document'=>$document->toArray(),'supplier_version'=>$s->fresh()->version,'supplier_status'=>'pending'];
app(RequisitionWorkflow::class)->audit($a,'sgf.supplier.document',null,$before,$o+['operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'binding'=>$f,'reason'=>$cut['reason'],'result'=>$result]);return response()->json($result+['replayed'=>false],201);});}
catch(\Throwable $e){
// Delete only a newly created file after observing that its DB row did not commit.
// If rollback/commit is uncertain or verification fails, retain it for recovery.
if($created){try{$exists=DB::connection('receipt_native')->table('supplier_documents')->where(['tenant_id'=>$created['tenant'],'supplier_id'=>$created['supplier'],'path'=>$created['path']])->exists();if(!$exists)app(SupplierDocumentFiles::class)->discard($created['root'],$created['path'],$created['tenant'],$created['supplier']);}catch(\Throwable){/* Preserve evidence if outcome cannot be verified. */}}throw $e;
}
}
public function download(int $supplier,int $document,Request $r,ReceiptRuntime $runtime) {
$this->guard();abort_if($r->query(),422);return $runtime->run('purchasing_suppliers.view',function($a,$b,$f,$o)use($supplier,$document,$r){$s=$this->source($supplier,$b,$o['sgf_context_key']);Gate::authorize('viewDocuments',$s);$doc=$this->document($s,$document);$files=app(SupplierDocumentFiles::class);$root=$files->root();$this->integrity($doc,$root);return $files->using($root,fn()=>(new \Tempo\Purchasing\Embedded\Http\Controllers\SupplierDocumentController)->download($this->input($r,$a,$doc->document_type),$s,$doc,app(RequisitionWorkflow::class)));});
}
}
@@ -5,6 +5,7 @@ namespace App\Modules\Purchasing;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tempo\Finance\Payables\HistoricalPayments; use Tempo\Finance\Payables\HistoricalPayments;
use Tempo\Finance\Payables\HistoricalCapture;
use Tempo\Finance\Payables\Payables; use Tempo\Finance\Payables\Payables;
/** Reads explicitly reconciled immutable receipts; never imports or decides payments. */ /** Reads explicitly reconciled immutable receipts; never imports or decides payments. */
@@ -34,7 +35,7 @@ final class SupplierHistoryReceipts
} }
/** Local read snapshots only; no command obtains its source through this batch. */ /** Local read snapshots only; no command obtains its source through this batch. */
public function page(User $user, array $documents): array public function page(User $user, array $documents, bool $validatedCapture = false): array
{ {
abort_unless($this->available(), 503); abort_unless($this->available(), 503);
$adapter = app(SupplierPayables::class); $scope = $adapter->scope($user); $adapter = app(SupplierPayables::class); $scope = $adapter->scope($user);
@@ -43,14 +44,14 @@ final class SupplierHistoryReceipts
->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id'); ->distinct('local_source_id')->orderBy('local_source_id')->orderByDesc('version')->get()->keyBy('local_source_id');
$result = []; $result = [];
foreach ($documents as $id => $source) { foreach ($documents as $id => $source) {
try { $result[$id] = $this->decode($source, $receipts->get($id)); } try { $result[$id] = $this->decode($source, $receipts->get($id), $validatedCapture ? $scope : null); }
catch (\DomainException $e) { $result[$id] = $e; } catch (\DomainException $e) { $result[$id] = $e; }
} }
return $result; return $result;
} }
private function decode(array $source, ?object $receipt): array private function decode(array $source, ?object $receipt, ?array $captureScope = null): array|HistoricalCapture
{ {
if (! $receipt) { if (! $receipt) {
throw new \DomainException('Histórico não reconciliado para esta origem e contexto.'); throw new \DomainException('Histórico não reconciliado para esta origem e contexto.');
@@ -66,11 +67,12 @@ final class SupplierHistoryReceipts
&& is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) { && is_array($payload['payments'] ?? null) && array_is_list($payload['payments']))) {
throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.'); throw new \DomainException('Recibo histórico antigo ou incompatível; exige nova conferência da origem.');
} }
$capture = HistoricalPayments::capture($source, $payload['payments']); $value = $captureScope === null ? null : HistoricalCapture::fromPayments($captureScope, $source, $payload['payments']);
$capture = $value ? $value->forSource($captureScope, $source) : HistoricalPayments::capture($source, $payload['payments']);
if (($payload['history_hash'] ?? null) !== $capture['history_hash']) { if (($payload['history_hash'] ?? null) !== $capture['history_hash']) {
throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.'); throw new \DomainException('Fotografia histórica incompatível com os pagamentos originais.');
} }
return $capture['payments']; return $value ?? $capture['payments'];
} }
} }
@@ -0,0 +1,26 @@
<?php
namespace App\Modules\Purchasing;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
final class SupplierPayableReportController extends Controller
{
private function criteria(Request $request): array
{
$data = $request->validate(['q' => 'sometimes|nullable|string|max:100', 'source' => 'sometimes|required|integer|min:1']);
abort_if(array_diff(array_keys($request->query()), array_keys($data)) !== [], 422);
return $data;
}
public function index(Request $request, SupplierPayables $service)
{
return response()->json($service->report($request->user(), $this->criteria($request)))->header('Cache-Control', 'no-store');
}
public function page(Request $request, SupplierPayables $service)
{
return response()->view('purchasing.payable-report', ['report' => $service->report($request->user(), $this->criteria($request))])->header('Cache-Control', 'no-store');
}
}
+106 -13
View File
@@ -13,7 +13,9 @@ final class SupplierPayables
public static function guard(): void public static function guard(): void
{ {
$c = DB::connection(); $c = DB::connection();
abort_unless(app()->environment('payable-testing'), 403, 'Obrigações apenas no ensaio isolado.'); $composedQa=app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true);
abort_unless(app()->environment('payable-testing')||$composedQa, 403, 'Obrigações apenas no ensaio isolado.');
if($composedQa)\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) { if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^payable_sgf_[a-f0-9]{12}$/D', $c->getDatabaseName()) || $c->getConfig('host') !== '127.0.0.1' || $c->selectOne('select current_database() as d')->d !== $c->getDatabaseName()) {
throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED'); throw new \LogicException('PAYABLE_QA_DATABASE_REFUSED');
} }
@@ -81,7 +83,32 @@ final class SupplierPayables
}; };
return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source, return new Payables(DB::connection(), $u->id, $person, $scope, fn (string $a) => $authorize($a), $source,
app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false)); app(SupplierHistoryReceipts::class)->available() ? $history : null, fn () => $authorize('view', false), fn () => $authorize('payments_view', false));
}
/** One predicate for list windows and complete position reports. */
private function sourceQuery(array $scope, array $options): array
{
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
abort_unless(mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
$query = DB::table('pa_imported_sources AS r')->where($scope);
$sourceId = array_key_exists('source', $options) ? filter_var($options['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) : null;
abort_if($sourceId === false || (isset($options['source']) && is_bool($options['source'])), 422, 'Origem inválida.');
if ($sourceId !== null) {
abort_unless((clone $query)->where('r.id', $sourceId)->exists(), 403, 'Origem não mapeada neste contexto.');
$query->where('r.id', $sourceId);
}
if ($q !== '') {
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
}
if ($recognition !== 'all') {
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
}
return [$query, $sourceId];
} }
public function listing(User $u, array $options = []): array public function listing(User $u, array $options = []): array
@@ -93,17 +120,7 @@ final class SupplierPayables
$page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25); $page = (int) ($options['page'] ?? 1); $perPage = (int) ($options['per_page'] ?? 25);
$q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all'; $q = trim((string) ($options['q'] ?? '')); $recognition = $options['recognition'] ?? 'all';
abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422); abort_unless($page >= 1 && $page <= 100000 && in_array($perPage, [25, 100], true) && mb_strlen($q) <= 100 && in_array($recognition, ['all', 'recognised', 'unrecognised'], true), 422);
$query = DB::table('pa_imported_sources AS r')->where($scope); [$query, $sourceId] = $this->sourceQuery($scope, $options);
if ($q !== '') {
$needle = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q).'%';
$query->where(fn ($w) => $w->whereRaw("payload->>'number' ILIKE ?", [$needle])->orWhereRaw("payload->>'supplier_name' ILIKE ?", [$needle]));
}
if ($recognition !== 'all') {
$method = $recognition === 'recognised' ? 'whereExists' : 'whereNotExists';
$query->$method(fn ($w) => $w->selectRaw('1')->from('pa_sources AS s')->join('pa_obligations AS o', 'o.source_id', '=', 's.id')
->whereColumn('s.local_source_id', 'r.id')->whereColumn('s.tenant_id', 'r.tenant_id')->whereColumn('s.company_id', 'r.company_id')->whereColumn('s.unit_id', 'r.unit_id')
->whereColumn('o.tenant_id', 'r.tenant_id')->whereColumn('o.company_id', 'r.company_id')->whereColumn('o.unit_id', 'r.unit_id'));
}
$pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = []; $pager = $query->orderByDesc('r.id')->paginate($perPage, ['r.*'], 'page', $page); $documents = [];
foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); } foreach ($pager->items() as $row) { $documents[(int) $row->id] = $this->receiptDocument($row); }
$provider = app(SupplierHistoryReceipts::class); $provider = app(SupplierHistoryReceipts::class);
@@ -111,6 +128,7 @@ final class SupplierPayables
$d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage); $d = $this->buildEngine($u, $documents, $histories)->listingPage(array_keys($documents), (int) ($options['proposal_page'] ?? 1), (int) ($options['event_page'] ?? 1), $perPage);
$d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()]; $d['pagination']['documents'] = ['page' => $pager->currentPage(), 'last_page' => $pager->lastPage(), 'total' => $pager->total(), 'per_page' => $pager->perPage()];
$d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)]; $d['listing_query'] = ['page' => $page, 'per_page' => $perPage, 'q' => $q, 'recognition' => $recognition, 'proposal_page' => (int) ($options['proposal_page'] ?? 1), 'event_page' => (int) ($options['event_page'] ?? 1)];
if ($sourceId !== null) { $d['listing_query']['source'] = $sourceId; }
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')]; $d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
$d['history_import_available'] = app(SupplierHistoryReceipts::class)->available(); $d['history_import_available'] = app(SupplierHistoryReceipts::class)->available();
$d['history_decisions_available'] = false; $d['history_decisions_available'] = false;
@@ -122,4 +140,79 @@ final class SupplierPayables
return $d; return $d;
}); });
} }
/** Current recognised positions, aggregated through the existing engine in bounded windows. */
public function report(User $u, array $criteria = []): array
{
abort_if(array_diff(array_keys($criteria), ['q', 'source']) !== [], 422);
abort_if(isset($criteria['q']) && !is_string($criteria['q']), 422);
if (array_key_exists('source', $criteria)) {
$id = filter_var($criteria['source'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
abort_if($id === false || is_bool($criteria['source']), 422);
$criteria['source'] = $id;
}
$own = DB::transactionLevel() === 0;
return DB::transaction(function () use ($u, $criteria, $own): array {
if ($own) DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
else abort_unless(in_array(DB::selectOne('SHOW transaction_isolation')->transaction_isolation, ['repeatable read', 'serializable'], true), 409, 'O relatório exige uma fotografia coerente própria.');
$scope = $this->scope($u);
abort_unless($this->can($u, 'view'), 403);
$criteria['q'] = trim((string) ($criteria['q'] ?? ''));
$hash = hash_init('sha256');
hash_update($hash, Payables::canonical(['scope' => $scope, 'criteria' => $criteria, 'basis' => 'current_recognised_positions', 'window' => 100])."\n");
$total = ['count' => 0, 'principal_minor' => 0, 'unknown_balance_count' => 0, 'unknown_due_count' => 0];
$known = ['count' => 0, 'principal_minor' => 0, 'historical_confirmed_minor' => 0, 'net_allocated_minor' => 0, 'balance_minor' => 0];
[$query] = $this->sourceQuery($scope, $criteria + ['recognition' => 'recognised']);
$person = DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person');
abort_unless(is_string($person) && $person !== '', 403);
$provider = app(SupplierHistoryReceipts::class); $historical = $provider->available();
$read = function () use ($u, $scope, $person): void {
$context = app(CompanyContext::class);
abort_unless(auth()->id() === $u->id && $context->userId() === $u->id, 403);
abort_unless(($context->identifiers() + ['unit_id' => $context->unitId()]) === $scope, 409);
abort_unless($this->can($u, 'view'), 403);
abort_unless(DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409);
};
$last = null; $windows = 0;
do {
$window = clone $query;
if ($last !== null) $window->where('r.id', '<', $last);
$rows = $window->orderByDesc('r.id')->limit(100)->get(['r.*']);
if ($rows->isEmpty()) break;
$documents = [];
foreach ($rows as $row) $documents[(int) $row->id] = $this->receiptDocument($row);
$histories = $historical ? $provider->page($u, $documents, true) : [];
$engine = new Payables(DB::connection(), $u->id, $person, $scope,
static fn () => throw new \LogicException('Relatórios nunca executam comandos.'),
fn (int $id) => $documents[$id] ?? abort(403),
$historical ? static function (int $id) use ($histories) {
$result = $histories[$id] ?? new \DomainException('Histórico fora da janela.');
if ($result instanceof \DomainException) throw $result;
return $result;
} : null, $read);
$data = $engine->positionsPage(array_keys($documents));
foreach ($data['documents'] as $doc) hash_update($hash, Payables::canonical(['local_source_id' => $doc['local_id'], 'current_revision' => $doc['revision']])."\n");
foreach ($data['obligations'] as $o) {
abort_unless($o['currency'] === 'MZN', 409, 'Moeda incompatível no relatório; nenhuma soma entre moedas.');
$total['count']++; $total['principal_minor'] += $o['principal_minor'];
if ($o['due_on'] === null) $total['unknown_due_count']++;
if ($o['source_conflict'] || $o['balance_minor'] === null) $total['unknown_balance_count']++;
else {
$known['count']++; $known['principal_minor'] += $o['principal_minor'];
$known['historical_confirmed_minor'] += $o['confirmed_payments_minor'];
$known['net_allocated_minor'] += $o['allocated_payments_minor'];
$known['balance_minor'] += $o['balance_minor'];
}
hash_update($hash, Payables::canonical($o)."\n");
}
$windows++; $last = (int) $rows->last()->id;
} while ($rows->count() === 100);
abort_unless($known['principal_minor'] - $known['historical_confirmed_minor'] - $known['net_allocated_minor'] === $known['balance_minor'], 409, 'Componentes financeiros não reconciliados.');
$total['balance_minor'] = $total['unknown_balance_count'] === 0 ? $known['balance_minor'] : null;
return ['version' => 'SGF-PAYABLE-POSITION-REPORT-SIM-v1', 'synthetic_only' => true,
'basis' => 'current_recognised_positions', 'captured_at_utc' => DB::selectOne("SELECT transaction_timestamp() AT TIME ZONE 'UTC' AS captured")->captured,
'scope' => $scope, 'criteria' => $criteria, 'currency' => 'MZN', 'totals' => $total, 'known_positions' => $known,
'positions_sha256' => hash_final($hash), 'window_size' => 100, 'windows' => $windows];
});
}
} }
@@ -9,9 +9,9 @@ use Tempo\Finance\Payables\Workspace;
final class SupplierPayablesController extends Controller final class SupplierPayablesController extends Controller
{ {
private function options(Request $r): array public function listingOptions(Request $r): array
{ {
$d = $r->validate(['page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000', $d = $r->validate(['source' => 'sometimes|required|integer|min:1', 'page' => 'sometimes|integer|min:1|max:100000', 'proposal_page' => 'sometimes|integer|min:1|max:100000',
'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100', 'event_page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|in:25,100',
'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']); 'q' => 'sometimes|nullable|string|max:100', 'recognition' => 'sometimes|in:all,recognised,unrecognised']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422); abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
@@ -21,17 +21,21 @@ final class SupplierPayablesController extends Controller
public function index(Request $r, SupplierPayables $s) public function index(Request $r, SupplierPayables $s)
{ {
return response()->json($s->listing($r->user(), $this->options($r)))->header('Cache-Control', 'no-store'); return response()->json($s->listing($r->user(), $this->listingOptions($r)))->header('Cache-Control', 'no-store');
} }
public function page(Request $r, SupplierPayables $s) public function page(Request $r, SupplierPayables $s)
{ {
$data = $s->listing($r->user(), $this->options($r)); $data = $s->listing($r->user(), $this->listingOptions($r));
$scope = $s->scope($r->user()); $scope = $s->scope($r->user());
$company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name'); $company = DB::table('companies')->where('tenant_id', $scope['tenant_id'])->where('id', $scope['company_id'])->value('name');
$unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name'); $unit = DB::table('units')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('id', $scope['unit_id'])->value('name');
$flow=[];
if(config('general_ledger.synthetic_enabled')===true&&isset($data['listing_query']['source'])&&app(\App\Modules\Identity\Services\Permissions::class)->has($r->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id'])){
$flow=['ledger_flow_url'=>'/general-ledger/flow?source='.$data['listing_query']['source'],'ledger_flow_scope'=>$scope];
}
return response(Workspace::render($data, ['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store'); return response(Workspace::render($data, $flow+['host' => 'ERP SGF', 'actor' => $r->user()->name, 'organisation' => $company.' / '.$unit, 'api' => '/api/supplier-payables', 'document_filter' => 'source', 'report_url' => '/supplier-payables/report', 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token')]))->header('Cache-Control', 'no-store');
} }
public function command(Request $r, string $action, SupplierPayables $s) public function command(Request $r, string $action, SupplierPayables $s)
@@ -2,6 +2,7 @@
namespace App\Modules\Purchasing; namespace App\Modules\Purchasing;
use Illuminate\Http\Request;
use Tempo\Finance\Payables\SettlementHttp; use Tempo\Finance\Payables\SettlementHttp;
final class SupplierPaymentsController extends SettlementHttp final class SupplierPaymentsController extends SettlementHttp
@@ -9,4 +10,27 @@ final class SupplierPaymentsController extends SettlementHttp
protected const MODE = 'sgf'; protected const MODE = 'sgf';
protected const SERVICE = SupplierPayables::class; protected const SERVICE = SupplierPayables::class;
private const PAYMENT_FILTERS = ['payments_page', 'allocation_proposals_page', 'allocations_page', 'payment_events_page', 'payment_per_page', 'payment_q', 'payment_state', 'payment'];
protected function ledgerSettings(Request $request):array
{
if(config('general_ledger.cycle_enabled')!==true)return [];
$scope=app(SupplierPayables::class)->scope($request->user());
if(!app(\App\Modules\Identity\Services\Permissions::class)->has($request->user()->id,$scope['tenant_id'],$scope['company_id'],'general_ledger.view',$scope['unit_id']))return [];
return ['ledger_cycle_url'=>'/general-ledger/cycle','ledger_scope'=>$scope];
}
protected function obligationOptions(Request $request): array
{
return app(SupplierPayablesController::class)->listingOptions($request->duplicate(array_diff_key($request->query(), array_flip(self::PAYMENT_FILTERS))));
}
protected function settlementOptions(Request $request): ?array
{
return $request->validate(['payments_page' => 'sometimes|integer|min:1|max:100000',
'allocation_proposals_page' => 'sometimes|integer|min:1|max:100000', 'allocations_page' => 'sometimes|integer|min:1|max:100000',
'payment_events_page' => 'sometimes|integer|min:1|max:100000', 'payment_per_page' => 'sometimes|integer|in:25,100',
'payment_q' => 'sometimes|nullable|string|max:100', 'payment_state' => 'sometimes|in:all,proposed,confirmed,rejected',
'payment' => 'sometimes|required|integer|min:1']);
}
} }
@@ -0,0 +1,37 @@
<?php
namespace App\Modules\Purchasing;
use App\Shared\Context\CompanyContext;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Http\Request;
/** Navigation only: every destination retains its own current native authority checks. */
final class WorkspaceNavigation
{
public function links():array
{
if(config('receipt_qa.enabled')!==true||!app()->environment('budget-testing'))return [];
$c=app(CompanyContext::class);$scope=$c->identifiers();if(!$c->unitId())return [];
$definitions=[
['requests','requests','Requisições','Preparar, submeter e obter aprovação distinta.'],
['suppliers','suppliers','Fornecedores','Conferir cadastro e revisão antes das propostas.'],
['sourcing','sourcing','Propostas e adjudicação','Comparar propostas e fundamentar a escolha; aprovação distinta.'],
['orders','orders','Ordens','Emitir a partir da adjudicação aprovada.'],
['enabled','receipts','Recepções','Conferir quantidades e qualidade da ordem emitida.'],
['invoices','invoices','Facturas','Registar e validar o documento recebido.'],
['obligations','obligations','Obrigações','Preparar fotografia e reconhecer por pessoa distinta.'],
];$links=[];$permissions=app(Permissions::class);
foreach($definitions as[$flag,$path,$label,$description]){
$flag=$flag==='enabled'?$flag:$flag.'_enabled';$right='purchasing_'.$path.'.view';
if(config('receipt_qa.'.$flag)===true&&$permissions->has($c->userId(),$scope['tenant_id'],$scope['company_id'],$right,$c->unitId()))$links[]=['url'=>'/purchasing/'.$path,'label'=>$label,'description'=>$description,'right'=>$right];
}
if(config('receipt_qa.dossiers_enabled')===true&&array_filter($links,fn($link)=>$link['right']==='purchasing_requests.view'))$links[]=['url'=>'/purchasing/dossiers','label'=>'Dossiês e evidência','description'=>'Consultar as etapas e o histórico autorizado da mesma requisição.','right'=>'purchasing_requests.view'];
return $links;
}
public function screen(Request $r,ReceiptRuntime $runtime)
{
abort_unless(config('receipt_qa.enabled')===true,404);$links=$this->links();abort_unless($links,403);
$runtime->run($links[0]['right'],fn()=>null);
return response()->view('purchasing-workspace',['links'=>$links]);
}
}
@@ -225,6 +225,11 @@ final class ServiceReceivables
return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()]; return ['blocked'=>false,'notice'=>self::NOTICE,'simulation'=>self::SIMULATION,'records'=>$records->map(function($r){$r->data=$this->data($r);return $r;})->all(),'obligations'=>$records->where('kind','obligation')->map(fn($r)=>$this->state($r->id))->values()->all(),'documents'=>\App\Shared\Context\ModuleRecords::query('si_invoices')->where($s)->get(['id','series_code','number'])->all()];
}); });
} }
/** Scoped single-position read reuses the authoritative state calculation without writer locks. */
public function position(int $obligation,int $actor):array
{
self::guard();$scope=$this->scope($actor,'view');$this->row($obligation,$scope,'obligation');return $this->state($obligation);
}
/** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */ /** Idempotent internal delivery only: validates ledger, never posts accounts or calls banks. */
public function deliver(int $event): bool public function deliver(int $event): bool
{ {
+6
View File
@@ -110,6 +110,12 @@ final class DirectCosts
$id=DB::table('dc_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>'decision','target_id'=>$r->id]; $id=DB::table('dc_decisions')->insertGetId([$field=>$r->id,'version'=>$r->version,'decision'=>$d['decision'],'reason'=>$d['reason'],'actor_id'=>$actor,'created_at'=>now()]);return ['id'=>$id,'version'=>$r->version,'kind'=>'decision','target_id'=>$r->id];
}); });
} }
/** Authoritative scoped conference for read-only consumers; no second cost calculator. */
public function conference(int $order,int $actor):array {
self::guard();$s=$this->scope($actor,'view');$o=$this->order($order,$s);$row=DB::table('dc_calculations')->where($s)->where('order_id',$order)->orderByDesc('version')->first();
$decision=$row?DB::table('dc_decisions')->where('calculation_id',$row->id)->first():null;$current=$row?$this->calculationCurrent($row):false;
return ['order'=>(array)$o,'calculation'=>$row?(array)$row:null,'snapshot'=>$row?json_decode($row->snapshot,true,64,JSON_THROW_ON_ERROR):null,'decision'=>$decision?(array)$decision:null,'current'=>$current];
}
public function listing(int $actor): array { public function listing(int $actor): array {
$s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[],'notice'=>self::NOTICE];self::guard(); $s=$this->scope($actor,'view');if(!ServiceCalculation::isolated())return ['blocked'=>true,'orders'=>[],'notice'=>self::NOTICE];self::guard();
return DB::transaction(function()use($s){ return DB::transaction(function()use($s){
+4
View File
@@ -17,6 +17,7 @@ class AppServiceProvider extends ServiceProvider
{ {
require_once base_path('packages/budget-control/bootstrap.php'); require_once base_path('packages/budget-control/bootstrap.php');
require_once base_path('packages/payables/bootstrap.php'); require_once base_path('packages/payables/bootstrap.php');
require_once base_path('packages/general-ledger/bootstrap.php');
if (is_file(base_path('packages/purchasing/current.json'))) { if (is_file(base_path('packages/purchasing/current.json'))) {
require_once base_path('packages/purchasing/bootstrap.php'); require_once base_path('packages/purchasing/bootstrap.php');
} }
@@ -28,6 +29,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
if ($routes = \App\Modules\Purchasing\InternalRequisitionQa::mount()) {
$this->loadRoutesFrom($routes);
}
if ($this->app->environment(['testing', 'browser-testing']) || defined('PHPUNIT_COMPOSER_INSTALL')) { if ($this->app->environment(['testing', 'browser-testing']) || defined('PHPUNIT_COMPOSER_INSTALL')) {
TestingDatabaseGuard::assertSafe(); TestingDatabaseGuard::assertSafe();
} }
+16 -2
View File
@@ -9,10 +9,10 @@ final class ModuleNavigation
public const LABELS = ['commercial'=>'Comercial', 'purchasing'=>'Compras', 'stock'=>'Stock', 'workshop'=>'Oficinas', 'finance'=>'Financeiro', 'accounting'=>'Contabilidade']; public const LABELS = ['commercial'=>'Comercial', 'purchasing'=>'Compras', 'stock'=>'Stock', 'workshop'=>'Oficinas', 'finance'=>'Financeiro', 'accounting'=>'Contabilidade'];
private const RIGHTS = [ private const RIGHTS = [
'commercial'=>['clients.', 'services.', 'service_drafts.', 'billing_drafts.', 'issuer_documents.', 'client_documents.', 'draft_documents.', 'service_invoices.'], 'commercial'=>['clients.', 'services.', 'service_drafts.', 'billing_drafts.', 'issuer_documents.', 'client_documents.', 'draft_documents.', 'service_invoices.'],
'purchasing'=>['purchasing_budget.', 'supplier_payables.'], 'purchasing'=>['purchasing_budget.', 'purchasing_requisitions.', 'purchasing_receipts.', 'purchasing_orders.', 'purchasing_sourcing.', 'purchasing_suppliers.', 'purchasing_requests.', 'purchasing_invoices.', 'purchasing_obligations.', 'supplier_payables.'],
'stock'=>['stock.'], 'workshop'=>['workshop.', 'workshop_billing.', 'workshop_corrections.'], 'stock'=>['stock.'], 'workshop'=>['workshop.', 'workshop_billing.', 'workshop_corrections.'],
'finance'=>['direct_costs.', 'industrial_costs.', 'industrial_budgets.', 'industrial_budget_comparisons.', 'service_receivables.'], 'finance'=>['direct_costs.', 'industrial_costs.', 'industrial_budgets.', 'industrial_budget_comparisons.', 'service_receivables.'],
'accounting'=>['catalogue_accounting.', 'service_fiscal.'], 'accounting'=>['catalogue_accounting.', 'service_fiscal.', 'general_ledger.'],
]; ];
public function available(CompanyContext $context): array public function available(CompanyContext $context): array
@@ -50,6 +50,20 @@ final class ModuleNavigation
// Company, unit and access configuration are explicitly general. // Company, unit and access configuration are explicitly general.
public static function owner(string $path): ?string public static function owner(string $path): ?string
{ {
if (preg_match('~^(geography|geography/segments|api/geography/entities|api/geography/segments)/client(/|$)~', $path)) return 'commercial';
if (preg_match('~^(geography|geography/segments|api/geography/entities|api/geography/segments)/supplier(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/dossiers|api/purchasing-dossiers)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^purchasing/workspace(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/obligations|api/purchasing-obligations)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/invoices|api/purchasing-invoices)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/requests|api/purchasing-requests)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/suppliers|api/purchasing-suppliers)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/sourcing|api/purchasing-sourcing)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/orders|api/purchasing-orders)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(purchasing/receipts|api/purchasing-receipts)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^api/stock-valuation-read(/|$)~', $path)) return 'accounting';
if (preg_match('~^(purchasing/internal-requisitions|api/internal-requisitions)(/|$)~', $path)) return 'purchasing';
if (preg_match('~^(general-ledger|api/general-ledger)(/|$)~', $path)) return 'accounting';
if (preg_match('~^api/stock-read(/|$)~', $path)) return 'stock'; if (preg_match('~^api/stock-read(/|$)~', $path)) return 'stock';
if (preg_match('~^(supplier-payables|supplier-payments|api/(supplier-payables|supplier-payments|purchasing-budget))(/|$)~', $path)) return 'purchasing'; if (preg_match('~^(supplier-payables|supplier-payments|api/(supplier-payables|supplier-payments|purchasing-budget))(/|$)~', $path)) return 'purchasing';
if (preg_match('~^api/(workshop|workshop-stock|workshop-billing|workshop-corrections)(/|$)~', $path)) return 'workshop'; if (preg_match('~^api/(workshop|workshop-stock|workshop-billing|workshop-corrections)(/|$)~', $path)) return 'workshop';
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Shared\Geography;
use InvalidArgumentException;
use DomainException;
/** Versioned preparation of a supplied territorial reference, without changing business records. */
final class Cartography
{
public const FIELDS=['province','district','administrative_post','locality','urban_neighbourhood','village_quarter'];
public const HEADERS=['A'=>'ID','B'=>'Provincia','C'=>'Distrito','D'=>'Posto_Administrativo','E'=>'Localidade','F'=>'Bairro_Urbano','G'=>'Aldeia_Quarteirao','H'=>'Designacao'];
public static function prepare(array $source):array
{
if(!preg_match('/^[a-f0-9]{64}$/D',$source['sha256']??'')||($source['rows'][0]['cells']??null)!==self::HEADERS||($source['formula_cells']??1)!==0)throw new InvalidArgumentException('CARTOGRAPHY_SOURCE_REFUSED');
$nodes=[];$paths=[];$ids=[];$levels=array_fill(1,6,0);
foreach(array_slice($source['rows'],1)as$row){
$c=$row['cells'];$id=$c['A']??'';if(!is_string($id)||!preg_match('/^[0-9]+$/D',$id)||isset($ids[$id]))throw new InvalidArgumentException('CARTOGRAPHY_SOURCE_ID_REFUSED');$ids[$id]=true;
$parts=[];foreach(['B','C','D','E','F','G']as$col){$code=$c[$col]??'';if(!is_string($code)||($code!==''&&!preg_match('/^[A-Za-z0-9]+$/D',$code)))throw new InvalidArgumentException('CARTOGRAPHY_CODE_REFUSED');$parts[]=$code;}
$level=0;foreach($parts as$i=>$code)if($code!=='')$level=$i+1;
if(!$level||in_array('',array_slice($parts,0,$level),true))throw new InvalidArgumentException('CARTOGRAPHY_PATH_REFUSED');
$path=implode('.',$parts);$key=$source['sha256'].':'.$id;$index=count($nodes);$paths[$path][]=$index;$levels[$level]++;
$nodes[]=['key'=>$key,'source_id'=>$id,'source_row'=>$row['row'],'level'=>$level,'designation'=>$c['H']??null,'codes'=>array_combine(self::FIELDS,$parts),'path'=>$path,'parent_candidates'=>[],'issues'=>[]];
}
$summary=['duplicate_path_groups'=>0,'duplicate_extra_rows'=>0,'conflicting_designation_groups'=>0,'missing_designations'=>0,'missing_parent_records'=>0,'ambiguous_parent_records'=>0,'incomplete_ancestry_records'=>0,'selectable_records'=>0];
foreach($paths as$group)if(count($group)>1){$summary['duplicate_path_groups']++;$summary['duplicate_extra_rows']+=count($group)-1;if(count(array_unique(array_map(fn($i)=>$nodes[$i]['designation'],$group)))>1)$summary['conflicting_designation_groups']++;}
foreach($nodes as&$node){
if($node['designation']===null||trim($node['designation'])===''){$node['issues'][]='missing_designation';$summary['missing_designations']++;}
if(count($paths[$node['path']])>1)$node['issues'][]='duplicate_path';
if($node['level']>1){$parts=array_values($node['codes']);$parts[$node['level']-1]='';$parents=$paths[implode('.',$parts)]??[];$node['parent_candidates']=array_map(fn($i)=>$nodes[$i]['key'],$parents);if(!$parents){$node['issues'][]='parent_record_missing';$summary['missing_parent_records']++;}elseif(count($parents)>1){$node['issues'][]='parent_record_ambiguous';$summary['ambiguous_parent_records']++;}}
$ancestry=true;for($depth=1;$depth<$node['level'];$depth++){$prefix=array_values($node['codes']);for($i=$depth;$i<6;$i++)$prefix[$i]='';$ancestors=$paths[implode('.',$prefix)]??[];if(count($ancestors)!==1||trim($nodes[$ancestors[0]]['designation']??'')===''){$ancestry=false;break;}}
if(!$ancestry){$node['issues'][]='ancestry_requires_review';$summary['incomplete_ancestry_records']++;}
$node['selectable']=$node['issues']===[];if($node['selectable'])$summary['selectable_records']++;
}unset($node);
return ['format'=>'sgf.cartography.staging.v1','source'=>['name'=>$source['source_name'],'sha256'=>$source['sha256'],'sheet'=>'Sheet1','range'=>$source['dimension'],'authority_declared_by_user'=>'INE','authority_independently_verified'=>false],'status'=>'prepared_for_review','levels'=>$levels,'total'=>count($nodes),'diagnostics'=>$summary,'nodes'=>$nodes];
}
/** A human selection carries version and source identity; a name alone cannot select a territory. */
public static function selection(array $catalogue,string $version,string $key):array
{
if(!hash_equals($catalogue['source']['sha256'],$version))throw new DomainException('CARTOGRAPHY_VERSION_CHANGED');
foreach($catalogue['nodes']as$node)if($node['key']===$key){if(!$node['selectable'])throw new DomainException('CARTOGRAPHY_REVIEW_REQUIRED');return ['version'=>$version,'territory_key'=>$key,'source_id'=>$node['source_id'],'codes'=>$node['codes'],'designation'=>$node['designation']];}
throw new DomainException('CARTOGRAPHY_TERRITORY_UNKNOWN');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Shared\Geography;
use Illuminate\Support\Facades\DB;
final class Catalogue
{
public static function guard():void
{
abort_unless(config('geography.enabled')===true,404);
abort_unless(app()->environment('budget-testing'),403);
if(request()->is('api/geography/*'))abort_unless(request()->hasSession()&&hash_equals((string)request()->session()->get('company_context.token',''),(string)request()->header('X-SGF-Context')),409,'O contexto mudou. Reabra o formulário.');
\App\Modules\Purchasing\BudgetAdapter::guard();
$c=DB::connection();$p=$c->getConfig();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');
abort_unless(preg_match('/^[a-f0-9]{12}$/D',$run)&&preg_match('/^budget_core_sgf_([a-f0-9]{12})$/D',$c->getDatabaseName(),$m)&&$p['username']==='ir_composition_'.$m[1]&&$p['host']==='127.0.0.1'&&(int)$p['port']===55439&&empty($p['url'])&&empty($p['read'])&&empty($p['write'])&&($p['search_path']??'public')==='public',403);
$i=$c->selectOne("SELECT current_user role,current_setting('cluster_name') cluster,rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls elevated FROM pg_roles WHERE rolname=current_user");
abort_unless(!$i->elevated&&$i->role===$p['username']&&$i->cluster==='sgf_modular_regression_'.$run,403);
}
public static function active(bool $lock=false):object
{
$q=DB::table('geo_catalogue_state')->where('id',1);$row=($lock?$q->lockForUpdate():$q)->first();
abort_unless($row&&hash_equals((string)config('geography.source_sha256'),$row->source_version),409,'A versão do catálogo mudou. Reabra a conferência.');return $row;
}
public static function describe(object $row):array
{
return ['key'=>$row->territory_key,'source_id'=>$row->source_id,'level'=>(int)$row->level,'designation'=>$row->designation,'codes'=>array_combine(Cartography::FIELDS,array_map(fn($f)=>$row->$f,Cartography::FIELDS)),'issues'=>json_decode($row->issues,true),'selectable'=>(bool)$row->selectable];
}
public static function selection(string $version,string $key):array
{
$active=self::active(true);abort_unless(hash_equals($active->source_version,$version),409,'O catálogo mudou.');
$row=DB::table('geo_territories')->where('source_version',$version)->where('territory_key',$key)->first();abort_unless($row,422,'Território desconhecido nesta versão.');abort_unless($row->selectable,422,'Este território tem pendências de conferência na fonte.');return ['country_code'=>'MZ','version'=>$version]+self::describe($row);
}
public static function page(array $d):array
{
$version=self::active()->source_version;$q=DB::table('geo_territories')->where('source_version',$version);
if(isset($d['parent'])){$parent=DB::table('geo_territories')->where('source_version',$version)->where('territory_key',$d['parent'])->first();abort_unless($parent&&$parent->selectable,422,'Ascendente por conferir.');$q->where('parent_key',$parent->territory_key);}
elseif(empty($d['search']))$q->where('level',1);
if(!empty($d['search']))$q->where('designation','ilike','%'.$d['search'].'%');
return ['version'=>$version,'records'=>$q->orderBy('designation')->orderBy('source_id')->paginate(25,['*'],'page',(int)($d['page']??1))->through(fn($r)=>self::describe($r))];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Shared\Geography;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{DB,Crypt};
final class GeographyController
{
private function input(Request $r,array $rules):array{$v=$r->validate($rules);abort_if(array_diff(array_keys($r->all()),array_keys($v)),422);return $v;}
public function catalogue(Request $r){Catalogue::guard();$d=$this->input($r,['parent'=>'nullable|string|max:100','search'=>'nullable|string|max:100','page'=>'sometimes|integer|min:1|max:10000']);return response()->json(Catalogue::page($d))->header('Cache-Control','no-store');}
public function segments(string $kind,Request $r){Catalogue::guard();$rules=['after'=>'sometimes|integer|min:0'];foreach(Cartography::FIELDS as$f)$rules[$f]=['sometimes','string','regex:/^[A-Za-z0-9]{1,20}$/D'];return response()->json(Segments::page($kind,$this->input($r,$rules)))->header('Cache-Control','no-store');}
public function segmentScreen(string $kind,Request $r){$this->segments($kind,$r);return response()->view('geography-workspace',['kind'=>$kind,'entityId'=>0,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function screen(string $kind,int $id,Request $r,Locations $locations){$locations->run($kind,$id,false,fn()=>null);return response()->view('geography-workspace',['kind'=>$kind,'entityId'=>$id,'context'=>$r->session()->get('company_context.token'),'csrf'=>csrf_token()]);}
public function show(string $kind,int $id,Locations $locations){return $locations->run($kind,$id,false,function($db,$scope,$entity,$actor,$binding){return Locations::useHost($db,function()use($db,$scope,$entity){$old=Locations::current($db,$scope);return response()->json(['entity'=>$entity,'location'=>$old,'revision'=>Locations::hash([$scope,$entity,$old]),'catalogue_version'=>Catalogue::active()->source_version,'purpose'=>'segmentation']);});});}
public function history(string $kind,int $id,Request $r,Locations $locations){Catalogue::guard();$d=$this->input($r,['page'=>'sometimes|integer|min:1|max:10000']);return $locations->run($kind,$id,false,fn($db,$scope)=>response()->json($db->table('geo_location_history')->where($scope)->orderByDesc('id')->paginate(15,['id','actor_id','reason','before','after','created_at'],'page',(int)($d['page']??1))->through(function($row){$row->before=json_decode($row->before,true);$row->after=json_decode($row->after,true);return $row;})));}
public function preview(string $kind,int $id,Request $r,Locations $locations){Catalogue::guard();$d=$this->input($r,['operation_key'=>'required|uuid','revision'=>'required|string|size:64','catalogue_version'=>'required|string|size:64','territory_key'=>'present|nullable|string|max:100','reason'=>'required|string|min:10|max:2000']);$d['operation_key']=strtolower($d['operation_key']);return $locations->run($kind,$id,true,function($db,$scope,$entity,$actor,$binding)use($d){return Locations::useHost($db,function()use($db,$scope,$entity,$actor,$binding,$d){$old=Locations::current($db,$scope);abort_unless(hash_equals(Locations::hash([$scope,$entity,$old]),$d['revision']),409,'O cadastro ou a localização mudou. Confira novamente.');abort_unless(hash_equals(Catalogue::active(true)->source_version,$d['catalogue_version']),409,'O catálogo mudou.');$selection=$d['territory_key']===null?null:Catalogue::selection($d['catalogue_version'],$d['territory_key']);$cut=$d+['scope'=>$scope,'entity'=>$entity,'actor'=>$actor,'binding'=>$binding,'observed'=>time()];return response()->json(['preview'=>Crypt::encryptString(json_encode($cut,JSON_THROW_ON_ERROR)),'impact'=>['entity'=>$entity['name'],'purpose'=>'Localização para segmentação','before'=>$old['selection']??null,'after'=>$selection,'reason'=>$d['reason'],'canonical_address_changed'=>false,'dependent_records_changed'=>false]]);});});}
public function confirm(string $kind,int $id,Request $r,Locations $locations){Catalogue::guard();$d=$this->input($r,['preview'=>'required|string|max:131072']);try{$cut=json_decode(Crypt::decryptString($d['preview']),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}return $locations->run($kind,$id,true,function($db,$scope,$entity,$actor,$binding)use($cut){return Locations::useHost($db,function()use($db,$scope,$entity,$actor,$binding,$cut){abort_unless(($cut['scope']??null)===$scope&&($cut['actor']??null)===$actor&&($cut['binding']??null)===$binding&&is_int($cut['observed']??null)&&$cut['observed']>=time()-600&&$cut['observed']<=time(),409,'A prévia ou o contexto mudou.');$hash=Locations::hash([$scope,$cut['revision'],$cut['catalogue_version'],$cut['territory_key'],$cut['reason']]);$old=$db->table('geo_commands')->where(['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'actor_id'=>$actor,'operation_key'=>$cut['operation_key']])->first();if($old){abort_unless(hash_equals($old->request_hash,$hash),409,'Chave já usada com outros dados.');return response()->json(json_decode($old->result,true)+['replayed'=>true]);}$before=Locations::current($db,$scope);abort_unless(hash_equals(Locations::hash([$scope,$entity,$before]),$cut['revision']),409,'O cadastro ou a localização mudou desde a conferência.');$state=Catalogue::active(true);abort_unless(hash_equals($state->source_version,$cut['catalogue_version']),409,'O catálogo mudou.');$selection=$cut['territory_key']===null?null:Catalogue::selection($cut['catalogue_version'],$cut['territory_key']);$revision=($before['revision']??0)+1;$values=['revision'=>$revision,'selection'=>$selection===null?null:json_encode($selection,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),'actor_id'=>$actor,'reason'=>$cut['reason'],'updated_at'=>now()];$db->table('geo_locations')->updateOrInsert($scope,$values);$after=Locations::current($db,$scope);$db->table('geo_location_history')->insert($scope+['actor_id'=>$actor,'reason'=>$cut['reason'],'before'=>json_encode($before,JSON_THROW_ON_ERROR),'after'=>json_encode($after,JSON_THROW_ON_ERROR),'created_at'=>now()]);$result=['revision'=>$revision,'selection'=>$selection];$db->table('geo_commands')->insert(['tenant_id'=>$scope['tenant_id'],'company_id'=>$scope['company_id'],'actor_id'=>$actor,'operation_key'=>$cut['operation_key'],'request_hash'=>$hash,'result'=>json_encode($result,JSON_THROW_ON_ERROR),'created_at'=>now()]);return response()->json($result+['replayed'=>false],201);});});}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Shared\Geography;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Modules\Identity\Services\Permissions;
use App\Modules\Purchasing\{ReceiptRuntime,SupplierAccess};
use Illuminate\Support\Facades\DB;
/** Geographic segmentation metadata; canonical addresses and supplier financial review are unchanged. */
final class Locations
{
public function run(string $kind,int $id,bool $edit,\Closure $work):mixed
{
Catalogue::guard();abort_unless(in_array($kind,['company','unit','client','supplier'],true)&&$id>0,404);
$ctx=app(CompanyContext::class);$s=$ctx->identifiers();$unit=$ctx->unitId();$actor=$ctx->userId();$host=DB::connection();$generation=hash('sha256',json_encode([$s,$unit,request()->session()->get('company_context.module_id'),request()->session()->get('company_context.token')],JSON_THROW_ON_ERROR));
return $host->transaction(function()use($kind,$id,$edit,$work,$ctx,$s,$unit,$actor,$host,$generation){
$host->table('companies')->where('id',$s['company_id'])->where('tenant_id',$s['tenant_id'])->lockForUpdate()->firstOrFail();$host->table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();
abort_unless($host->table('company_memberships')->where($s)->where('user_id',$actor)->lockForUpdate()->first(),403);
$require=function(string $right,?int $u=null)use($host,$s,$actor){abort_unless($host->table('permission_grants')->where($s)->where(['user_id'=>$actor,'permission'=>$right,'unit_id'=>$u])->lockForUpdate()->first(),403);};
$require('company.view');if($unit!==null)$require('unit.view',$unit);
$base=$s+['unit_key'=>0,'module_key'=>'general','kind'=>$kind,'entity_id'=>$id,'provider'=>'sgf'];
if($kind==='supplier'){
abort_unless($unit!==null,403);$base['unit_key']=$unit;$base['module_key']='purchasing';
return app(ReceiptRuntime::class)->run($edit?'purchasing_suppliers.prepare':'purchasing_suppliers.view',function($native,$binding,$fingerprint,$origin)use($id,$edit,$work,$base,$host,$actor,$generation){
abort_unless($native->hasFunctions($edit?['procurement']:['admin','procurement','finance','department_head']),403);
$supplier=SupplierAccess::query($binding,$origin['sgf_context_key'])->lockForUpdate()->findOrFail($id);
if($edit)abort_unless(app(\Tempo\Purchasing\Embedded\Services\SupplierParticipation::class)->canPrepare($native),403);
$scope=$base;$scope['provider']=hash('sha256',DB::connection()->getDatabaseName());$entity=['id'=>$id,'name'=>$supplier->name,'version'=>(int)$supplier->version,'editable'=>$host->table('permission_grants')->where(['tenant_id'=>$base['tenant_id'],'company_id'=>$base['company_id'],'unit_id'=>$base['unit_key'],'user_id'=>$actor,'permission'=>'purchasing_suppliers.prepare'])->exists()&&app(\Tempo\Purchasing\Embedded\Services\SupplierParticipation::class)->canPrepare($native)];
return $work($host,$scope,$entity,$actor,hash('sha256',$fingerprint.$generation));
});
}
if($kind==='company'){abort_unless($id===$s['company_id'],403);$require($edit?'company.edit':'company.view');$entity=$host->table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$id)->lockForUpdate()->first();}
elseif($kind==='unit'){abort_unless($id===$unit,403);$require($edit?'unit.edit':'unit.view',$id);$base['unit_key']=$id;$entity=$host->table('units')->where($s)->where('id',$id)->lockForUpdate()->first();}
else{$require('clients.view');if($edit)$require('clients.edit');abort_unless(ModuleRecords::active()==='commercial',403);$base['module_key']='commercial';$entity=ModuleRecords::query('a_clients')->where($s)->whereNull('unit_id')->where('id',$id)->lockForUpdate()->first();}
abort_unless($entity,403,'Registo fora do âmbito autorizado.');return $work($host,$base,['id'=>$id,'name'=>$entity->name,'version'=>(int)($entity->version??0),'updated_at'=>$entity->updated_at??null,'editable'=>$host->table('permission_grants')->where($s)->where(['unit_id'=>$kind==='unit'?$id:null,'user_id'=>$actor,'permission'=>match($kind){'company'=>'company.edit','unit'=>'unit.edit','client'=>'clients.edit'}])->exists()],$actor,$generation);
});
}
public static function current($db,array $scope):?array
{
$r=$db->table('geo_locations')->where($scope)->first();return $r?['revision'=>(int)$r->revision,'selection'=>$r->selection===null?null:json_decode($r->selection,true),'actor_id'=>(int)$r->actor_id,'reason'=>$r->reason,'updated_at'=>$r->updated_at]:null;
}
public static function hash(mixed $v):string{return hash('sha256',json_encode($v,JSON_THROW_ON_ERROR));}
public static function useHost($host,\Closure $work):mixed
{
$old=DB::getDefaultConnection();DB::setDefaultConnection($host->getName());try{return $work();}finally{DB::setDefaultConnection($old);}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Shared\Geography;
use Illuminate\Support\Facades\DB;
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Modules\Identity\Services\Permissions;
use App\Modules\Purchasing\{ReceiptRuntime,SupplierAccess};
final class Segments
{
public static function page(string $kind,array $filters):array
{
Catalogue::guard();abort_unless(in_array($kind,['company','unit','client','supplier'],true),404);$ctx=app(CompanyContext::class);$s=$ctx->identifiers();$unit=$ctx->unitId();$host=DB::connection();$version=Catalogue::active()->source_version;$p=app(Permissions::class);$actor=$ctx->userId();
$read=function(string $provider,int $unitKey,string $module,\Closure $names,?int $only=null)use($host,$s,$kind,$filters,$version){
$query=$host->table('geo_locations')->where($s)->where(['unit_key'=>$unitKey,'module_key'=>$module,'kind'=>$kind,'provider'=>$provider])->whereNotNull('selection')->where('selection->version',$version);if($only!==null)$query->where('entity_id',$only);foreach(Cartography::FIELDS as$f)if(isset($filters[$f]))$query->where('selection->codes->'.$f,$filters[$f]);$after=(int)($filters['after']??0);$out=[];
do{$rows=(clone $query)->where('entity_id','>',$after)->orderBy('entity_id')->limit(100)->get();if($rows->isEmpty())break;$allowed=$names($rows->pluck('entity_id')->all());foreach($rows as$row){$after=(int)$row->entity_id;if(isset($allowed[$after]))$out[]=['entity_id'=>$after,'name'=>$allowed[$after],'location'=>json_decode($row->selection,true),'revision'=>(int)$row->revision];if(count($out)===26)break 2;}}while($rows->count()===100);
$more=count($out)>25;if($more)array_pop($out);return ['version'=>$version,'data'=>$out,'next_after'=>$more?end($out)['entity_id']:null,'page_size'=>25,'population'=>'explicit_segment_locations'];
};
if($kind==='supplier')return app(ReceiptRuntime::class)->run('purchasing_suppliers.view',function($native,$binding,$fingerprint,$origin)use($read,$unit){abort_unless($native->hasFunctions(['admin','procurement','finance','department_head']),403);return $read(hash('sha256',DB::connection()->getDatabaseName()),$unit,'purchasing',fn($ids)=>SupplierAccess::query($binding,$origin['sgf_context_key'])->whereIn('id',$ids)->pluck('name','id')->all());});
if($kind==='client'){$p->require($actor,$s['tenant_id'],$s['company_id'],'clients.view');abort_unless(ModuleRecords::active()==='commercial',403);return $read('sgf',0,'commercial',fn($ids)=>ModuleRecords::query('a_clients')->where($s)->whereNull('unit_id')->whereIn('id',$ids)->pluck('name','id')->all());}
if($kind==='unit'){abort_unless($unit!==null,403);$p->require($actor,$s['tenant_id'],$s['company_id'],'unit.view',$unit);return $read('sgf',$unit,'general',fn($ids)=>$host->table('units')->where($s)->whereIn('id',$ids)->pluck('name','id')->all(),$unit);}
return $read('sgf',0,'general',fn($ids)=>$host->table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->whereIn('id',$ids)->pluck('name','id')->all(),$s['company_id']);
}
}
@@ -11,6 +11,14 @@ final class TestingDatabaseGuard
{ {
$name = $connection ?: config('database.default'); $name = $connection ?: config('database.default');
$config = config("database.connections.{$name}", []); $config = config("database.connections.{$name}", []);
if(app()->environment(['testing','browser-testing'])&&(config('sales_ledger.synthetic_enabled')===true||config('cost_ledger.synthetic_enabled')===true||config('industrial_ledger.synthetic_enabled')===true)
&&($config['driver']??null)==='pgsql'&&($config['host']??null)==='127.0.0.1'&&(string)($config['port']??'')==='55439'
&&preg_match('/^payable_sgf_([a-f0-9]{12})$/D',$config['database']??'',$m)&&($config['username']??null)==='payable_history_sgf_'.$m[1]
&&empty($config['url'])&&empty($config['read'])&&empty($config['write'])&&($config['search_path']??'public')==='public'){
\Tempo\Finance\GeneralLedger\LedgerSchema::assertSynthetic(DB::connection($name));
$actual=DB::connection($name)->selectOne('SELECT current_database() db,current_user role');
if($actual->db!==$config['database']||$actual->role!==$config['username'])throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: configuração QA e destino efectivo divergem.');return;
}
$expected = [ $expected = [
'driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432', 'driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432',
'database' => 'sgf_tempo_test', 'username' => 'sgf_test_runner', 'database' => 'sgf_tempo_test', 'username' => 'sgf_test_runner',
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'path'=>base_path('packages/payables'),
'source_instance'=>env('PAYABLE_SOURCE_INSTANCE',''),
'enabled'=>false,
'history_enabled'=>false,
'history_decisions_enabled'=>false,
];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['synthetic_enabled'=>env('SGF_COST_LEDGER_QA',false),'correction_enabled'=>env('SGF_COST_CORRECTION_QA',false)];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['synthetic_enabled'=>false,'cycle_enabled'=>false,'composition_enabled'=>env('SGF_LEDGER_COMPOSITION_QA',false)];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['enabled'=>env('SGF_GEOGRAPHY_QA_ENABLED',false),'source_sha256'=>env('SGF_GEOGRAPHY_SOURCE_SHA256','')];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['synthetic_enabled'=>env('SGF_INDUSTRIAL_LEDGER_QA',false),'correction_enabled'=>env('SGF_INDUSTRIAL_CORRECTION_QA',false)];
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
// Explicit QA composition only. Never selects an installed Purchasing release.
'qa_enabled' => env('SGF_REQUISITIONS_QA_ENABLED', false),
'qa_bundle' => env('SGF_REQUISITIONS_QA_BUNDLE'),
'qa_manifest_sha256' => env('SGF_REQUISITIONS_QA_MANIFEST'),
];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['stock_period_enabled'=>env('SGF_STOCK_PERIOD_QA_ENABLED',false),'stock_trace_enabled'=>env('SGF_STOCK_RECEIPT_TRACE_QA_ENABLED',false),'stock_read_enabled'=>env('SGF_COMPOSITION_STOCK_READ_QA_ENABLED',false),'obligation_evidence_enabled'=>env('SGF_OBLIGATION_EVIDENCE_QA_ENABLED',false),'invoice_progress_enabled'=>env('SGF_INVOICE_PROGRESS_QA_ENABLED',false),'request_history_enabled'=>env('SGF_REQUEST_HISTORY_QA_ENABLED',false),'continuity_enabled'=>env('SGF_PURCHASING_CONTINUITY_QA_ENABLED',false),'focus_enabled'=>env('SGF_PURCHASING_FOCUS_QA_ENABLED',false),'receipt_documents_enabled'=>env('SGF_RECEIPT_DOCUMENTS_QA_ENABLED',false),'enabled'=>env('SGF_RECEIPTS_QA_ENABLED',false),'orders_enabled'=>env('SGF_ORDERS_QA_ENABLED',false),'sourcing_enabled'=>env('SGF_SOURCING_QA_ENABLED',false),'supplier_documents_enabled'=>env('SGF_SUPPLIER_DOCUMENTS_QA_ENABLED',false),'suppliers_enabled'=>env('SGF_SUPPLIERS_QA_ENABLED',false),'dossiers_enabled'=>env('SGF_DOSSIERS_QA_ENABLED',false),'requests_enabled'=>env('SGF_REQUESTS_QA_ENABLED',false),'obligations_enabled'=>env('SGF_OBLIGATIONS_QA_ENABLED',false),'invoices_enabled'=>env('SGF_INVOICES_QA_ENABLED',false),'profile'=>env('SGF_RECEIPTS_QA_PROFILE','')];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['synthetic_enabled'=>env('SGF_SALES_LEDGER_QA',false)];
+2
View File
@@ -0,0 +1,2 @@
<?php
return ['read_sources'=>[]];
+2
View File
@@ -1,5 +1,7 @@
# Menus de Contabilidade # Menus de Contabilidade
Continuação posterior autorizada: [primeiro lançamento contabilístico sintético](GENERAL_LEDGER_DELIVERY.md), com ligação condicionada a direito próprio na unidade. Supera apenas a ausência absoluta do motor no recorte QA; não transforma os restantes menus/relatórios previstos em funcionalidades operacionais nem presume revisão visual.
29/10/2026. Acrescentada a navegação do documento `Menus de contabilidade.docx` ao SGF existente. A entrega é de menu, organização funcional e ligações autorizadas; **não implementa contabilidade geral ou demonstrações financeiras**. 29/10/2026. Acrescentada a navegação do documento `Menus de contabilidade.docx` ao SGF existente. A entrega é de menu, organização funcional e ligações autorizadas; **não implementa contabilidade geral ou demonstrações financeiras**.
## Fonte e âmbito ## Fonte e âmbito
+37
View File
@@ -0,0 +1,37 @@
# Referência territorial para segmentação dos cadastros
**Continuação funcional:** o [selector comum, gravação/histórico e segmentação estão agora entregues na composição QA8069](GEOGRAPHY_SEGMENTATION_DELIVERY.md), com83 verificações. As indicações de implementação pendente abaixo descrevem o estado desta preparação inicial; não se aplicam às capacidades demonstradas nesse recorte. Correcção/publicação de versões, migração histórica, utilização real, revisão visual e implantação permanecem separadas.
Fonte fornecida pelo utilizador: `cartografia_moz.xlsx`, declarada como cartografia validada pelo INE. A análise é da versão exacta do ficheiro recebido, não uma verificação independente de validade/vigência nacional. SHA256: `70996d9a9133785a848de90bedb4bf685e24d8f823cd153c0adc43eac642a292`. Não foi modificado nem reexportado.
## Estrutura observada
`Sheet1!A1:H20408`:20.407 registos, sem fórmulas ou linhas ocultas. Campos `ID`, `Provincia`, `Distrito`, `Posto_Administrativo`, `Localidade`, `Bairro_Urbano`, `Aldeia_Quarteirao`, `Designacao`.
| Nível mais detalhado do registo na fonte | Registos |
| --- | ---: |
| Província | 11 |
| Distrito | 148 |
| Posto administrativo | 437 |
| Localidade | 1.334 |
| Bairro urbano | 1.870 |
| Aldeia/quarteirão | 16.607 |
| Total | 20.407 |
Os componentes são códigos de texto, incluindo zeros iniciais e os valores alfanuméricos `7A`/`7B`. O `ID` é único nesta folha; não se presume que seja um código INE estável entre versões. A identidade técnica preparada usa versão da fonte + ID original. Designações e códigos originais ficam intactos.
Foram conservados48 grupos com caminho de códigos repetido,39 dos quais com designações diferentes, e nove registos sem designação. Há8.610 registos sem uma linha correspondente ao pai imediato, abrangendo8.585 caminhos distintos, e377 com mais de um candidato a pai imediato. Isto descreve relações ausentes/ambíguas **na folha recebida**, não prova que um território administrativo seja inválido. Códigos como99 também permanecem intactos, sem significado inventado. Nomes repetidos entre territórios são normais e não servem de chave de associação.
## Preparação implementada
`App\Shared\Geography\Cartography` prepara um catálogo versionado com todas as linhas, componentes, nível, designação, candidatos a pai e pendências. Não funde duplicados, não inventa designações e não executa conteúdo do Excel. A selecção técnica exige chave e versão exactas; recusa nomes soltos, versão antiga e entradas com pendências próprias ou de ascendência. Permite precisão apenas de província quando é essa a informação conhecida. A fonte extraída e o catálogo preparado ficam no directório privado de execução; não se incorporou a carga de dados no repositório nem no destino.
[Prova técnica](proofs/cartography-preparation-20261103.json):16 verificações, incluindo reconciliação dos níveis, conservação integral, zeros, alfanuméricos, duplicados, designações ausentes, selecção explícita, recusa de ambiguidades/versões antigas e preparação determinística. Sem ligação a bases ou atribuição a cadastros.
## Aplicação comum no SGF
Usar um único catálogo de referência para empresas/unidades, clientes, fornecedores e outras entidades. Cada localização deve guardar país, versão da fonte, chave do território, precisão realmente conhecida, finalidade do endereço e endereço livre complementar. Sede, endereço fiscal, estabelecimento e destino de entrega podem ser diferentes; a cartografia não os torna equivalentes.
O formulário deve seleccionar em cascata os níveis disponíveis e exibir pendências da fonte. A associação é uma decisão explícita do cadastro, com permissões e isolamento da entidade, autor/data e histórico. Uma actualização de cartografia cria outra versão e conferência de diferenças, preservando associações históricas. Segmentação e relatórios filtram pelos componentes territoriais da localização escolhida, sem confundir localização com direitos de acesso ou inventar precisão mais fina.
**Ainda não entregue:** tabela/API/selector comuns montados no SGF, ligação dos formulários de empresas/clientes/fornecedores, persistência/histórico das localizações e filtros dos relatórios. O que existe nesta passagem é leitura, preparação do catálogo, contrato de utilização e provas. Não foram atribuídas localizações aos registos existentes, nem alteradas as seis associações protegidas, a cache, acessos ou dados reais. As pendências da fonte exigem conferência antes da activação, sem impedir o desenvolvimento sintético das interfaces.
+23
View File
@@ -0,0 +1,23 @@
# Artefacto financeiro autónomo — composição QA
Continuação funcional do ciclo aprovado, sem novos efeitos financeiros. **Demo própria SGC: http://127.0.0.1:8052/qa/ledger-demo**. Mantém as três funções, uma obrigação60 e nenhum pagamento/lançamento inicial. SGF8050, SGC8051 e demos anteriores permanecem preservados.
Foi acrescentado um artefacto versionado por conteúdo com **25 ficheiros** de runtime de Payables/GeneralLedger e verificador, extraídos da fonte canónica, sem credenciais, bases, fixtures ou suites. Manifesto SHA-256 desta prova: `6a507ea7d708a12a8e526e4c610696d8db84079d18aede470237b65505630e7e`. O pacote foi criado em pasta QA privada; não foi instalado no destino, publicado ou activado no SGC existente.
O host nativo tem agora um ponto de composição explícito em `FinanceBundle::configure`, antes de carregar Payables. Sem configuração de artefacto mantém exactamente o caminho anterior. Com configuração exige ambiente de teste, base/role própria não elevada no cluster55439, manifesto cujo hash está fixado na configuração e hash do carregador. Só depois carrega o verificador e confere todos os ficheiros, caminhos permitidos, duplicações, ausências, alterações, extras e links simbólicos. Falha recusa o arranque; não regressa silenciosamente à pasta antiga. O manifesto é lido/conferido pelos mesmos bytes usados na descodificação.
As chaves novas `common-finance-bundle.root/manifest_sha256` seleccionam fonte de código, **não concedem direitos nem activam contabilidade**. Flags sintéticas continuamfalse nos defaults; ausência de root é o comportamento normal. Configuração real, migrações, cache e pacote instalado continuam intactos. Hashes fixam integridade do conteúdo; não são certificação fiscal ou validação de distribuição comercial.
Provas novas, sem repetir o ciclo inteiro já válido:
- **9 verificações** numa base nativa nova: cinco classes financeiras, incluindo o verificador, carregadas exclusivamente do artefacto; preparação/revisão/aplicação de reconhecimento60 e replay com uma só gravação; duas linhas; totais e fingerprint original de factura/stock/orçamento preservados.
- **6 verificações** do contrato de artefacto: conteúdo válido aceite; ficheiro alterado, ausente, extra, caminho de travessia e hash fixado errado recusados.
- **4 recusas efectivas no arranque do host**: ficheiro alterado, extra, hash errado e carregador alterado. O carregador alterado continha um marcador de execução; o marcador nunca foi produzido. Sem fallback, efeitos de negócio ou alterações à prova original.
- **28 verificações** de preparação do demo8052: três funções, cookies separados, estados iniciais vazios e ligações financeiras/contabilísticas. Sem comandos de negócio na base da equipa.
- PHPStan nível7 nos dois ficheiros nativos novos: zero erros; formatação e sintaxe dirigidas. A cache de análise é privada; não se usou a suite Composer que limpa configuração/cache.
[Manifesto e hashes](proofs/common-finance-bundle-20261103.json). O primeiro construtor encontrou um README opcional ausente em Payables; a cópia incompleta foi retida como diagnóstico, sem arranque ou uso. A construção final exclui ficheiros opcionais inexistentes e exige os bootstraps. Nenhuma prova aprovada foi sobrescrita.
Reprodução: `scripts/finance-bundle-build.mjs` constrói novo artefacto QA; `scripts/finance-bundle-check.mjs` verifica integridade/recusas; `scripts/ledger-cycle-native-run.mjs new-bundle` cria outra base configurada com o hash fixado; fase PHP `native-cycle-bundle` prova o writer do artefacto; `scripts/finance-bundle-boot-check.mjs <tag>` prova as recusas do host. Demo exige outra fixture, `ledger-cycle-native-demo.mjs` e `ledger-cycle-demo-check.mjs native <tag>`. Preservar as fixtures consumidas pelas provas.
O artefacto contém apenas estes dois núcleos; não empacota todo o Laravel, dependências, orçamento, Compras ou instalação comercial. Não se ensaiaram outras plataformas, implantação, actualização de base instalada, carga sustentada, ACLs ou revisão visual. Não é uma passagem operacional de dados entre SGF/SGC. Seis associações, cache, dados/acessos reais, candidatas, demos e trabalho paralelo preservados. Sem publicação, aceitação ou checkpoint presumidos.
+36
View File
@@ -0,0 +1,36 @@
# Usar Compras na instalação local
Abrir **http://127.0.0.1:8085/compras**. Esta é a entrada única do percurso de Compras. Não é necessário conhecer endereços de demonstrações anteriores ou copiar identificadores.
O ambiente é sintético. Escolher uma função e uma etapa; a empresa e unidade aparecem na entrada e no cabeçalho. Para passar a outra função, usar **Entrada de Compras / trocar função**. A opção **Continuar o documento actual** mantém a origem que estava aberta, quando essa função tem acesso. Campos por confirmar são protegidos ao sair e uma confirmação em curso impede a mudança.
## Percurso novo
1. **Compras → Fornecedores:** preparar a ficha com dados sintéticos, referências documentais e próxima revisão. Conferir impacto e confirmar. **Finanças** abre a mesma ficha, confere e decide a activação por outra identidade. Há também três fornecedores QA previamente preparados.
2. **Solicitante → Requisições:** preparar a necessidade, escolher explicitamente o centro autorizado, indicar data, fundamento e linhas. Para experimentar:1UN a100,00MZN, imposto documental0. Guardar e submeter são confirmações distintas. A submissão reserva o montante no orçamento de Compras.
3. **Chefia → Requisições:** abrir a necessidade e aprovar a versão com fundamento. O solicitante não aprova a própria necessidade. A ligação de continuação abre as propostas da mesma requisição.
4. **Compras → Propostas e adjudicação:** registar três propostas de fornecedores elegíveis. Compras e Finanças fazem avaliações individuais; só depois conferir a comparação, escolher expressamente e fundamentar a adjudicação. **Chefia** decide a adjudicação.
5. **Compras → Ordens:** conferir a adjudicação aprovada e o impacto antes de emitir. A ordem conserva fornecedor, linhas, valores e aprovações. A ligação seguinte abre a recepção da mesma ordem.
6. **Armazém → Recepções:** abrir a ordem, marcar as linhas, pesquisar/escolher artigo e armazém, indicar guia/data/quantidades e qualidade. Conferir e confirmar. Só as quantidades aceites geram movimento. Se a resposta se perder, repetir a mesma confirmação recupera o resultado sem duplicar.
7. **Finanças → Facturas:** abrir a ordem, registar número/data e valores do documento recebido, seleccionar as linhas e quantidades e fundamentar a conferência. **Revisor financeiro** valida por outra identidade. Essa validação consome o compromisso uma vez; não reconhece automaticamente a obrigação.
8. **Finanças → Obrigações:** abrir a factura validada, conferir e confirmar a preparação da fotografia. **Revisor financeiro** escolhe essa proposta, confere o reconhecimento e confirma com fundamento. A página passa a apresentar a responsabilidade única e deixa de oferecer nova preparação para o documento reconhecido.
O painel **Percurso de Compras** encontra os documentos existentes por etapa e abre-os directamente. Os históricos e dossiês permitem conferir origens e decisões autorizadas. As listas são filtradas pelas permissões: mudar de função não concede direitos reais nem torna todas as etapas disponíveis ao mesmo utilizador.
## Exemplo já concluído
Está gravado um processo com o fornecedor **Fornecedor Percurso Completo QA**, três propostas100/120/140MZN, duas avaliações por proposta, ordem/recepção1UN, factura100MZN e obrigação100MZN. Pode abrir os documentos nas etapas acima e consultar as decisões. Para praticar outra compra, criar uma nova requisição; não repetir o documento já concluído.
O vencimento dessa obrigação não foi fornecido: permanece **desconhecido**, com atraso indeterminado. Não há pagamento ou contabilização dessa origem. O orçamento sintético é limitado e o motor recusa operações sem dotação; os saldos não são inventados ou reiniciados para permitir testes.
## Voltar a iniciar
Se a página não estiver aberta, executar no PowerShell:
```powershell
& 'C:\laragon\www\sgf-tempo\scripts\abrir-compras.ps1'
```
O comando reutiliza a instalação8085 e não cria bases ou demonstrações. O cluster QA existente tem de estar disponível. O arranque após reinício completo do computador não foi ensaiado nesta entrega.
Esta conclusão cobre aquisição até obrigação no ambiente local. Pagamentos, contabilidade dessa origem, emissão fiscal real, migração histórica e utilização empresarial permanecem fora deste percurso. Revisão visual, carga sustentada e implantação não são presumidas por esta prova funcional.
@@ -0,0 +1,17 @@
# Recuperação de comprovativos financeiros integrada — 03/11/2026
A consulta contabilística por chave passa a oferecer três domínios explícitos: Contabilidade, reconhecimento de fornecedor e liquidação de fornecedor. A escolha nunca procura automaticamente noutro motor. O resultado é histórico, do próprio autor no âmbito actual, não valida dependências actuais nem executa/reenvia comandos.
`GET /api/general-ledger/financial-receipts/{domain}/{key}`, com `domain=recognition|settlement`, reutiliza os motores financeiros e os callbacks de leitura existentes. Exige acesso à Contabilidade e à origem; liquidação exige ainda o direito próprio `payments_view`. Sem herança entre direitos, concessões novas ou activação de Compras. O percurso antigo de comprovativos contabilísticos mantém-se.
`ReceiptReader` restringe os dois domínios a tabelas fixas, filtra tenant/empresa/unidade/autor/chave e exige autorização própria de leitura. Não usa a autorização de comandos como fallback. O host cobre a consulta com `REPEATABLE READ READ ONLY`. Ausência de domínio instalado é recusa explícita, não saldo zero; chave ausente e chave de outro autor não revelam comprovativos. Respostas usam `no-store` e recusam parâmetros de contexto.
O formulário partilhado das páginas contabilísticas permite escolher o domínio, faz só GET, limpa o resultado anterior na recusa e mostra JSON como texto. Não guarda pedidos, não inventa resultado quando a chave falta e não permite executar operações pela consulta. Nenhum novo direito, migração, dependência ou efeito financeiro.
**43 verificações dirigidas** numa base/role nova QA55439 (`6070063f42f2`):13 de serviço,6 do leitor comum,19 HTTP e5 do JavaScript renderizado em Node VM. Cobrem chave igual nos dois domínios, direitos separados/revogados, autor diferente, filtro de tenant/empresa/unidade, domínio inválido, ausência de autorizador de leitura, UUID/contexto/módulo inválidos, ausência de fallback para Contabilidade, transacção só de leitura, zero bloqueios, escape como texto e ausência de repetição automática.
O comprovativo de reconhecimento provém do workflow efectivo de fornecedor60 já aprovado, executado exclusivamente na fixture nova. Para provar colisão de chaves, o comprovativo de liquidação foi inserido como **fixture de infra-estrutura**, com `qa_fixture=true` e `state=no_business_effect`. Não representa preparação, confirmação ou execução de pagamento. Há zero pagamentos na base; a consulta conserva fontes, direitos e contagens dos dois livros de comprovativos. Um auxiliar de teste precisou de capturar explicitamente o contador no closure antes de gerar a prova válida; a tentativa parcial não é contada.
Manifesto: [provas e hashes](proofs/financial-receipt-recovery-20261103.json). Provas anteriores preservadas por hash; suites financeiras e benchmarks alheios não repetidos. Demos anteriores, seis associações, cache bloqueada e trabalho em curso preservados. Sem acessos reais, publicação, activação de Compras ou checkpoint.
Limites: sem arquivo de pedidos perdidos quando a chave já não existe, histórico SGF vivo, comunicação bancária ou nova validação do estado financeiro. Não se instalou esta capacidade no SGC nem se substituíram os pacotes congelados. Revisão visual, carga sustentada e implantação continuam pendentes; HTTP e Node VM não são prova visual ou capacidade operacional.
@@ -0,0 +1,81 @@
# Ciclo contabilístico sintético integrado — entrega local
`COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1`, autorizado expressamente por «Autorizar o ciclo sintético proposto». **Implementação funcional concluída no recorte autorizado**, com demonstrações SGF e SGC separadas. Não equivale a contabilidade geral/fiscal completa ou implantação aceite.
## Demonstrar o ciclo inteiro
| Composição | Entrada persistente | Estado inicial na entrega |
|---|---|---|
| SGF | **http://127.0.0.1:8050/qa/ledger-demo** | Uma obrigação60MZN, sem pagamento, afectação, proposta contabilística, lançamento ou fecho |
| SGC autónomo | **http://127.0.0.1:8051/qa/ledger-demo** | Factura efectivamente conferida no workflow nativo e uma obrigação60MZN, restantes efeitos vazios |
Escolher Preparar/aplicar, Rever com outra pessoa ou Consultar. Cada decisão exige fundamento. As duas instalações são **livros financeiros independentes**, não duas autoridades operacionais para a mesma dívida. O SGC usa o pacote comum local, sem serviço SGF remoto, facturação de vendas SGF ou cópia divergente do motor. O pacote instalado e a configuração operacional do SGC não foram activados/substituídos.
Guião, presente também nas entradas:
1. Capturar reconhecimento contabilístico60, conferir o impacto D `QA-CONTRAPARTIDA` / C `QA-FORNECEDOR`, rever com outra pessoa e aplicar.
2. Abrir liquidações financeiras pelo botão. Registar facto sintético20 com referências únicas `SIM-…`, data29/10/2026, confirmar com outra pessoa, preparar afectação20 e confirmá-la independentemente. Principal60, saldo40. Não há execução monetária.
3. Voltar pelo botão à contabilidade. Capturar a afectação confirmada, rever D fornecedor20 / C `QA-LIQUIDACAO`20 e aplicar. O lançamento **não volta a reduzir o saldo**.
4. Preparar e confirmar a reversão financeira integral da afectação; saldo volta a60. Conferir/contabilizar a reversão: D liquidação20 / C fornecedor20. O original fica conservado.
5. Capturar correcção técnica do lançamento original de reconhecimento: estorno integral60 e substituição integral60, ambos fotografados. Outra pessoa revê o par exacto. Preparador aplica os dois numa transacção, conservando o original e sem alterar dívida/imposto/moeda.
6. Capturar conferência do período `QA-2026-10`, rever com outra pessoa e fechar. Pendências financeiras/contabilísticas impedem fecho; mudança de factos/regras exige nova conferência. Depois de fechado, novas gravações nesse período recusam; consultas e recuperação de recibos existentes continuam disponíveis. **Não existe reabertura.**
Após o ciclo de prova há uma obrigação60, saldo60, cinco lançamentos/dez linhas, débito bruto220/crédito bruto220 e uma versão de período fechado, além da versão aberta original. O valor220 é soma bruta de movimentos incluindo compensações, não saldo financeiro ou nova dívida.
As bases da equipa são distintas das bases de regressão e conservam as decisões futuras. Não usar reset para repetir o guião. Os demos anteriores 8002/8003/8005/8039/8048/8049 e os respectivos dados não foram alterados por esta entrega. A fixture documental SGF continua a ser recibo sintético; só a origem SGC desta composição passa pelo workflow nativo efectivo. Na demonstração SGC, o relógio de preparação foi explicitamente fixado em29/10/2026 para produzir a factura QA dentro do período artificial. Isto não valida políticas temporais/fiscais reais.
## Núcleo único e guardas
O pacote canónico `packages/general-ledger` reutiliza `PayableJournal` para captura, revisão, aplicação, linhas, equilíbrio, unicidade, recibos e auditoria de todos os efeitos. `LedgerCycle` acrescenta a orquestração do par correctivo e do fecho; não contém um segundo writer de lançamentos. O principal original permanece no snapshot; `amount_minor` da afectação/reversão é distinto, evitando contabilizar60 quando foram afectados20.
`CycleSchema::install` é explícito e recusa ledger com propostas/lançamentos existentes. Só foi executado em bases/roles novas no cluster55439. Acrescenta cinco tabelas append-only de grupos/resultados correctivos e conferência/revisão/fecho de períodos. No schema **novo**, o equilíbrio compara o montante explícito do efeito; o schema das demos anteriores não é actualizado no arranque. Constraints diferidas exigem o par correctivo completo no commit; foreign keys incluem âmbito; fecho exige revisão independente e nova versão fechada. Os nove históricos anteriores continuam append-only.
As rotas individuais de revisão/aplicação recusam partes correctivas com409 e orientam para o par. A orquestração usa os mesmos métodos internos, mas grava ambos os lançamentos e o resultado do grupo numa transacção. Falha entre os dois ou depois do recibo faz rollback integral. Mesma chave/conteúdo recupera o resultado; outra chave não duplica o mesmo efeito; conteúdo conflitante é recusado. Direitos e pessoa/contexto actuais são conferidos antes de recuperar recibos.
Fecho captura origens reconhecidas, efeitos confirmados, propostas/decisões/configuração e totais. O hash é calculado por cursores, sem carregar todo o livro em arrays; verificações por origem ainda fazem consultas individuais. Este percurso de escrita **não foi ensaiado a10.000 origens** e não tem capacidade de implantação validada. O fecho não apaga pendências, não inventa lançamentos e não fecha enquanto houver efeitos necessários por conferir/contabilizar. O bloqueio de empresa serializa a revalidação/aplicação.
## Adaptadores, interface e acessos
- SGF: contexto Contabilidade e permissões existentes por tenant/empresa/unidade, com mudança explícita/auditada de módulo para liquidações. Geração anterior de contexto não autoriza comandos novos.
- SGC: organização nativa, funções/acessos financeiros actuais e concessões contabilísticas QA explícitas em `pa_access`. A composição nativa usa `company_id=0/unit_id=0` no contrato comum e isola por tenant; isso não declara suporte de unidades empresariais que o adaptador nativo não possui. Actualiza/verifica actor, pessoa e direitos antes de escrever, respeitando manutenção/contexto de trabalho nativos.
- UI comum em `packages/general-ledger/ui/cycle.php`, servida pelos dois adaptadores. Propostas, impactos, fontes financeiras confirmadas, correcções, conferências de período, linhas e histórico. Listas limitadas a25, totais sobre o conjunto correspondente. Navegação considera também páginas de eventos/propostas, mesmo quando já não há mais lançamentos. O par inclui as duas fotografias mesmo que uma proposta esteja fora da página do diário.
- A consulta do workspace completo usa uma transacção própria `REPEATABLE READ READ ONLY`, sem `FOR UPDATE`. Leituras iniciadas dentro de transacções participam nelas; os comandos conservam bloqueios e estado actual. O percurso financeiro legado do SGC mantém o seu contrato anterior de leitura; não se afirma que todas as suas páginas foram convertidas para o novo leitor sem bloqueios.
Os dez direitos contabilísticos são os quatro anteriores e os seis novos aprovados: `view/prepare/validate/post`, `correct_prepare/correct_validate/correct_post`, `period_prepare/period_validate/period_close`, todos com prefixo `general_ledger.`. **12 concessões por fixture nova**: preparador7, revisor4, consulta1. Direitos de liquidação existentes reutilizados apenas QA: `payments_view`, `payment_prepare/payment_confirm`, `allocation_prepare/allocation_confirm`, `reversal_prepare/reversal_confirm`; preparação e confirmação são distintas. Nenhum direito ou acesso real/público foi concedido.
Ambos os destinos mantêm flags de ciclo e uso sintético **false**; path nativo por defeito é null. A composição QA fornece o pacote explicitamente. Não foram criadas migrações no destino, dependências novas ou emissão/contas reais. O SGC recusa a capacidade se o pacote não estiver composto, em vez de recorrer a um motor alternativo.
## Provas e condições
[Manifesto, fontes e hashes](proofs/common-ledger-complete-cycle-20261102.json). As datas referem-se à autorização; a execução prolongou-se pela sessão local seguinte. Segredos, cookies, configurações e logs ficam apenas na área QA privada.
| Prova | Resultado |
|---|---|
| Ciclo SGF com falhas/versões/revogação, todo revertido | 23 verificações |
| Mesmo contrato pelo adaptador SGC, todo revertido | 23 verificações |
| Segurança adicional: própria pessoa, direito correctivo revogado, bypass individual, par órfão no commit, falha depois do recibo, append-only nas cinco tabelas | 18 verificações, revertidas |
| HTTP SGF, decisões financeiras e contabilísticas reais no kernel | 70 verificações; um ciclo completo conservado |
| HTTP SGC, mesmo percurso no host nativo | 61 verificações; um ciclo completo conservado |
| Pós-verificação de leitura, conservação, guardas individuais e navegação longa | 10 SGF +10 SGC; sondagens novas revertidas |
| Preparação das duas demonstrações, sem comandos de negócio | 28 SGF +28 SGC |
| Regressão do primeiro efeito, afectado pela generalização do writer | 8 cenários/74 asserções, numa base nova, sem reset das provas anteriores |
As contagens acima identificam execuções e não devem ser somadas como testes únicos independentes: há verificações sobrepostas dos mesmos contratos. Uma execução adicional persistente do contrato SGC tem16 verificações e conserva o livro fechado; não acrescenta cobertura única à tabela.
Análise estática nativa dirigida aos quatro ficheiros alterados: PHPStan nível7, **zero erros**, com bootstrap do pacote comum e cache exclusivamente privada. Foram corrigidos tipos de retorno/arrays e a verificação explícita de utilizador interno, impedindo que uma conta de candidatura seja passada ao adaptador. Formatação dirigida com Pint; verificação de sintaxe dos ficheiros afectados registada no manifesto. Não se executou o script geral Composer que limpa configuração/cache.
Complemento após essa correcção: **cinco verificações** dos controladores confirmaram recusa403 de conta de candidatura nas três entradas e na ligação financeira, consulta200 com utilizador interno e fingerprint financeiro igual. Não se repetiram as sequências HTTP de negócio válidas.
**Concorrência real: cinco trabalhadores, ligações distintas.** Dois correctores aguardaram em `companies FOR UPDATE`, com cadeia de bloqueio observada por `pg_blocking_pids`; após libertação ambos receberam o mesmo resultado, com um par e cinco lançamentos totais. Um fecho aguardou enquanto o pai acrescentava uma versão de regra; após commit recebeu409 por evidência desactualizada, sem fechar. Nova captura e revisão permitiram dois fechos concorrentes: ambos recuperaram o mesmo fecho, exactamente uma versão fechada e um recibo factual. Não se atribuiu a espera a gargalo de auditoria nem se resolveu a divergência aumentando apenas o timeout.
As falhas injectadas existem só em callbacks CLI de teste, não em parâmetros HTTP. Foi comprovado rollback após o primeiro lançamento do par, após o recibo e após acrescentar a versão fechada. O teste do par órfão exerceu a constraint SQL; a guarda pública posterior409 foi comprovada nas verificações finais. Diagnósticos do auxiliar de preparação do demo corrigiram expectativas de texto e de formulário do revisor sem propostas; não houve decisão de negócio ou reset nessas tentativas.
No SGC, fingerprints dos documentos/itens/pagamentos externos/stock/orçamento originais mantiveram-se. No SGF, dívida e direitos foram conservados pelas consultas e sondagens revertidas. As seis associações protegidas e a cache bloqueada não foram consultadas para decisão, alteradas ou limpas. Provas válidas de carga, fila e liquidações anteriores foram reutilizadas por hash; não se repetiram matrizes/suites alheias.
## Limitações que continuam separadas
Este recorte só suporta MZN, imposto0, três contas artificiais, reconhecimento60, afectação/reversão20 e correcção técnica integral do reconhecimento, sem mudança comercial. Não há reabertura, plano PGC real aprovado, contabilidade de vendas/IVA/stock/salários, correcção comercial do principal, execução monetária, reconciliação bancária ou fecho legal geral.
O reconhecimento/fecho sintético não valida fiscalidade, exigibilidade, data contabilística real, adopção de pacote instalado ou passagem operacional de responsabilidade. Revisão visual pela equipa, feedback, aceitação e avaliação de carga/implantação permanecem pendências próprias. Nenhum benchmark novo ou índice foi criado. A leitura financeira anterior de páginas25/100 nos volumes1.000/5.000/10.000 conserva as provas existentes; o relatório integral ainda tem a limitação observada de cerca de12,6s para10.000 posições.
Sem publicação/push, activação de Compras, novo checkpoint ou aceitação presumida. A autorização funcional foi usada para concluir este ciclo conjuntamente, sem pedir confirmação de cada passo técnico.
@@ -0,0 +1,31 @@
# Ciclo contabilístico sintético integrado — recorte para decisão
02/11/2026. **Autorização expressa recebida: «Autorizar o ciclo sintético proposto». Implementação local concluída no recorte aprovado.** Continuação agrupada do [percurso funcional financeiro → lançamento](SGF_FINANCIAL_LEDGER_FLOW_DELIVERY.md). O texto de decisão abaixo conserva o recorte autorizado; não é um pedido pendente de nova aprovação. Entrega, demonstrações e limites em [GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md](GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md).
## Porque a decisão é necessária
O [AGENTS.md](../AGENTS.md), primeira instrução do lançamento, delimita a autorização anterior: «não autoriza lançamentos/contas/direitos reais, migração no destino, fecho/reabertura, novos efeitos de pagamentos, activação de Compras, publicação, aceitação ou checkpoint». O [recorte primeiro](GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md) exclui também correcção/estorno e composição contabilística autónoma. O desenvolvimento técnico do primeiro efeito e das suas ligações está concluído no âmbito demonstrado; não se presume autorização destes novos efeitos.
## Uma entrega funcional, com bases novas e decisões individuais
`COMMON-LEDGER-COMPLETE-CYCLE-SIM-v1`: autorização conjunta proposta para construir e demonstrar o seguinte ciclo, sem aprovações intermédias de cada ligação técnica:
1. **Liquidação sintética de 20 MZN sobre a obrigação QA de 60 MZN.** Reutilizar os comandos e decisões independentes do núcleo de liquidações, sem execução monetária/bancária. Só a afectação efectivamente confirmada é origem do efeito contabilístico. Principal permanece 60 e saldo financeiro passa a 40. Facto de pagamento sem afectação confirmada não lança automaticamente contra o fornecedor.
2. **Lançamento da afectação confirmada**, proposta/revisão/aplicação distintas: débito 20 em `QA-FORNECEDOR`, crédito 20 em nova conta artificial de movimento `QA-LIQUIDACAO`. A conta não representa uma conta bancária real, dinheiro verificado ou reconciliação. Uma única aplicação por afectação factual e tipo de efeito; fotografia/versionamento e autor/fundamento conservados.
3. **Reversão financeira e contabilística ligada.** Reutilizar o circuito financeiro existente de reversão independente; quando a reversão confirmada for contabilizada, débito 20 em `QA-LIQUIDACAO`, crédito 20 em `QA-FORNECEDOR`. Preservar afectação, lançamento e decisões originais. Saldo financeiro volta a 60; nenhuma reversão implícita da obrigação original. Contabilização em falta fica pendência visível, nunca compensação silenciosa.
4. **Correcção técnica de um lançamento de reconhecimento**, sem mudar a dívida: proposta de correcção captura o lançamento de 60 e a razão; revisão distinta aprova exactamente um par atómico de estorno integral (D fornecedor/C contrapartida) e substituição integral (D contrapartida/C fornecedor), 60 MZN cada. Novos efeitos correctivos referenciam o original e a decisão; não reutilizam uma nova chave para furar a unicidade do efeito de reconhecimento. O original não é editado/apagado. Alteração comercial de principal, imposto ou moeda fica fora desta proposta.
5. **Fecho do período QA**, preparado e revisto por pessoas distintas: fotografia do conjunto de origens/lançamentos/pendências e igualdade D/C; impedir fecho enquanto houver efeitos confirmados obrigatórios sem conferência, inconsistências ou propostas necessárias não resolvidas. Aplicação revalida a fotografia numa transacção; nova origem/lançamento/decisão exige nova conferência. Após fecho, bloquear novos lançamentos nesse período, mantendo leitura e recuperação dos recibos existentes. **Reabertura continua excluída** deste recorte; não é necessária para demonstrar o ciclo fechado.
6. **Duas composições QA sobre o mesmo núcleo**, adaptador SGF e adaptador autónomo SGC. Reutilizar o pacote canónico, sem cópia divergente do motor, pacote instalado substituído, candidata activada ou dependência de SGF remoto. Bases de prova separadas representam livros independentes; não apresentar a mesma dívida como responsabilidade financeira operacional nos dois hosts.
7. Uma demonstração persistente própria, com entrada, papéis, referências e pendências ligadas, permitindo executar o ciclo completo. Base de regressão separada; histórico mantido, sem reset do demo para ocultar decisões.
## Direitos propostos antes de concessão
Conservar `general_ledger.view/prepare/validate/post` apenas QA. Novos direitos propostos, **apenas em novas fixtures**: `general_ledger.correct_prepare`, `general_ledger.correct_validate`, `general_ledger.correct_post`, `general_ledger.period_prepare`, `general_ledger.period_validate`, `general_ledger.period_close`. Separar preparador/aplicador do revisor por conta **e pessoa**. Reutilizar os direitos explícitos existentes de liquidação/reversão exclusivamente nas identidades QA necessárias, enumerados na preparação da fixture, sem conceder novos acessos empresariais ou direitos públicos. Consulta não herda comandos; contexto financeiro não concede contabilidade.
## Critério de conclusão e exclusões
Entregar código, interfaces, dois adaptadores, guião único, relatório de pendências, testes afectados e prova end-to-end do reconhecimento60 → afectação20/saldo40 → contabilização → reversão/saldo60 → correcção técnica com originais → fecho. Incluir tenant/empresa/unidade indevidos, revogações, auto-revisão, versões alteradas, concorrência, repetição com mesma/outra chave, falhas em cada fronteira atómica e recuperação sem duplicação; fontes e montantes exactos devem reconciliar. Ensaios anteriores válidos reutilizados, sem repetir suites alheias.
Contas/períodos/regras artificiais; bases/roles novas no cluster55439; sem tocar seis associações pendentes, cache bloqueada, demos existentes ou trabalho alheio. Sem emissão/fiscalidade real, novas contas/concessões/dados reais, operações monetárias, reconciliação bancária, valorização/efeitos de stock/orçamento, reabertura de período, migração no destino, activação de Compras, publicação, aceitação/checkpoint presumidos. Revisão visual e capacidade de implantação continuam separadas.
**Decisão pedida:** autorizar este ciclo sintético agrupado e os direitos exclusivamente QA acima. A autorização abrange a implementação e as provas deste recorte, não a sua aceitação posterior ou utilização real.
@@ -0,0 +1,48 @@
# Composição financeira integrada — entrega local de 03/11/2026
Vendas/recebíveis, obrigação a fornecedor e correcção de custo directo passam a funcionar na mesma empresa/unidade, livro e período QA. Reutilizam exclusivamente os efeitos e workflows já autorizados. Não se criou outro motor nem um novo tipo de efeito financeiro.
Demonstração inicial preservada: **http://127.0.0.1:8057/qa/cost-demo**. O guia permite entrar como preparador, validador ou leitor e abrir o painel `/general-ledger/workspace`. Cada origem tem ligação ao seu percurso efectivo. A base/role são próprias no cluster isolado55439; a aplicação de destino e as demonstrações anteriores não foram migradas.
## Resultado funcional
| Origem | Percurso efectivo | Resultado final da regressão |
|---|---|---|
| Comercial/recebíveis | Factura de teste862,07+137,93=1000; abertura documental revista; recebimentos confirmados/afectados400 e600; reversão400; reconhecimento/afectações/reversão no mesmo writer | 4 lançamentos,9 linhas; bruto2400 por lado; saldo financeiro/conta cliente400 |
| Oficina/custo directo | Original100+175=275 validado e lançado; taxa material sucessora60 e cálculo120+175=295 validados; estorno275/substituição295 indivisíveis | 3 lançamentos,6 linhas; bruto845 por lado; saldo custo295 |
| Fornecedor | Recibo sintético controlado já reconhecido pelo núcleo Payables; reconhecimento contabilístico60, revisto distintamente | 1 lançamento,2 linhas; bruto60 por lado; obrigação financeira permanece60 |
| Conjunto | Uma conferência, revisão distinta e fecho QA no mesmo período | **8 lançamentos,17 linhas,3305MZN de débitos e créditos** |
Os IDs numéricos coincidem entre factura, ordem e obrigação; os filtros tipados continuam a separar os três domínios. A contabilização não desconta novamente os saldos financeiros. Factura/PDF, apontamentos, decisões de custo, original275, outbox e direitos mantêm-se imutáveis durante o percurso contabilístico.
## Painel e integração
`LedgerComposition` oferece cartões de origens e diário conjunto em transacção `REPEATABLE READ READ ONLY`, sem bloqueios de escrita. Cada painel, diário e lista de conferências tem página própria de25 registos. A navegação conserva os restantes filtros/páginas. As dependências e metadados são consultados em conjuntos delimitados pelos IDs da página; não se executa uma conferência completa por cartão nem se carrega o catálogo inteiro de contas.
Os cartões mostram decisões e lançamentos **registados**, sem os apresentar como nova validação de dependências. O principal de fornecedor está identificado como principal, não saldo. A conferência actual e a prévia do impacto continuam nos percursos das origens e nos comandos existentes. Fonte indisponível devolve503; acesso de origem ausente oculta o painel, sem linhas ou contagens. A consulta histórica do livro mantém o direito contabilístico próprio. Acções de período são ocultadas quando falta consulta a uma das origens.
O fecho não depende dos cartões ou da página visível: o serviço existente volta a conferir todas as fontes compostas. Recusa com perda de direito ou alteração de versão, incluindo sucessora295→295 com novas taxas/cálculo. Depois de remover a alteração de ensaio, a mesma operação termina uma única vez; repetições com mesma/outra chave não duplicam o fecho.
O instalador `SalesSchema` para **livros novos vazios** restringe três linhas ao tipo `sales.invoice`; os restantes efeitos conservam duas. A prova SQL adversarial cria, apenas dentro de rollback, uma proposta/revisão/cabeçalho coerentes de custo com crédito repartido em duas linhas: a soma é correcta, mas `gl_assert_balanced` recusa a terceira linha. Não se actualizaram os schemas das demos antigas. O escritor PHP e os efeitos aceites permanecem os mesmos.
## Provas dirigidas
Manifesto verificável: [common-ledger-composition-20261103.json](proofs/common-ledger-composition-20261103.json).
- `2d5c71abf625`:7 casos/37 verificações, todos em rollback: painéis, direitos revogados, contextos elegíveis de unidade/empresa/tenant diferentes,26 perímetros SQL explicitamente artificiais para páginas25+1, falha de fonte, guarda e cardinalidade SQL. Páginas com25 e1 registos usam igual número de consultas. As linhas sem cálculo permanecem pendentes, sem zero/aprovação fabricados.
- Mesma base de regressão, depois das provas de rollback:31 verificações do percurso efectivo completo, revalidação dos três domínios no fecho, mesma quantia com versão diferente, repetição/recuperação e reconciliação final.
- HTTP real em regressão aberta:36 verificações; regressão fechada independente `01aab3ebad2d`:35. Autenticação, CSRF, contexto/módulo, parâmetros, paginação, links, ausência de formas de decisão para leitor/período fechado e transacção de leitura sem bloqueios.
- Demo nova `fe42e1169ce6`:34 verificações de três perfis, links efectivos, entradas/contextos e quatro verificações do JavaScript renderizado em Node VM. Sem comandos de negócio. Impressão digital antes/depois dos dados/origens/outbox/direitos/livro confirma preservação.
- Total173 verificações dirigidas, incluindo os quatro ensaios de formulário simulados. Node VM e HTTP **não constituem revisão visual**. Sintaxe e `git diff --check` nos dois repositórios. Provas válidas de concorrência/falhas dos escritores, fornecedores nativos e pacotes congelados reutilizadas por hash; não repetidas.
Na demo8057 só o original275 está lançado. Factura1000 e obrigação fornecedor60 existem, mas abertura de recebíveis, lançamentos adicionais, par correctivo e fecho continuam por decisões individuais. As regressões completas usam bases diferentes. Não há reset automático.
## Limites e preservação
São quatro módulos compostos neste percurso: Comercial, Financeiro, Oficina e Contabilidade. Compras e Stock não foram activados. O recibo de fornecedor é uma origem controlada de ensaio, não ligação histórica SGF nem importação real. Orçamento/CIF industrial, stock valorizado, salários e cadeias adicionais de correcção não se tornam contabilizados por esta composição.
As primeiras três tentativas de instalação depois da restrição de cardinalidade falharam por sintaxe SQL e permaneceram diagnósticas; não são provas válidas nem foram reutilizadas/resetadas. A tentativa seguinte `b8be625f9a43` completou o percurso, mas um pós-teste usava nome de filtro incorrecto; a sua saída parcial não é contada como sucesso. As provas finais acima foram executadas em fixtures novas, com publicação do comprovativo só depois das verificações finais. Logs privados conservados, sem credenciais versionadas.
Seis associações protegidas, cache bloqueada, demonstrações anteriores e trabalho alheio preservados. Sem concessões reais/públicas, migração no destino, activação de Compras, publicação, aceitação ou checkpoint presumidos. Revisão visual, carga sustentada da composição e capacidade de implantação permanecem pendentes independentes. Os resultados pequenos de integração não demonstram capacidade operacional.
Continuação posterior expressamente autorizada e [entregue em bases novas: lote industrial/CIF1800](GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md). A presente entrega e a demo8057 conservam o seu âmbito de três famílias de origem; a8058 acrescenta a ligação industrial. O manifesto anterior conserva o estado histórico à data da sua emissão.
+61
View File
@@ -0,0 +1,61 @@
# Obrigação reconhecida → primeiro lançamento contabilístico sintético
02/11/2026 — `COMMON-LEDGER-PAYABLE-SIM-v1`. **Código funcional, interface, integração e provas locais entregues** no recorte expressamente autorizado pelo utilizador: «Autorizar o incremento sintético proposto». [Âmbito autorizado](GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md). A autorização permite este desenvolvimento/fixture; não presume aceitação funcional ou checkpoint.
## Percurso implementado
O núcleo único `packages/general-ledger` recebe do adaptador SGF a obrigação já reconhecida pelo Payables, conferida de novo pela identidade factual, reconhecimento/revisão e âmbito exactos. Não recebe um saldo de relatório ou um estado de factura como autorização de lançamento. O principal vem da origem: **60,00 MZN**, imposto documental 0, vencimento desconhecido. O cenário inicial não tem pagamentos. Não se atribuiu uma regra contabilística real à Tempo nem se modificou a obrigação financeira.
Uma regra e período de ensaio explícitos capturam **D 60,00 em `QA-CONTRAPARTIDA` / C 60,00 em `QA-FORNECEDOR`**, em 29/10/2026 no período artificial `QA-2026-10`. As duas contas são de movimento, artificiais e em MZN. Não representam contas PGC aprovadas, inventário/despesa/IVA inferidos, uma regra fiscal ou saldo por liquidar.
1. Preparador propõe obrigação/data/regra/período e fundamento. Fica guardada uma versão imutável da fonte, configuração, linhas e impacto; ainda não há lançamento.
2. Outra identidade **e pessoa** aprova ou recusa a revisão exacta, com fundamento. A aprovação financeira da obrigação não substitui esta conferência contabilística. Recusa e versões anteriores permanecem preservadas.
3. O preparador autorizado aplica apenas a versão revista. A aplicação volta a verificar direitos, pessoa, contexto, fonte, regra/período/versões, moeda e equilíbrio. Mudança exige nova conferência. Cabeçalho, linhas, auditoria e recibo de repetição ficam na mesma transacção.
4. O lançamento liga à obrigação, proposta, revisão, regra e período aplicados. A interface conserva a fotografia mesmo quando a proposta fica noutra página, apresenta o comprovativo e deixa de oferecer uma segunda aplicação. Para abrir a posição financeira actual, muda-se explicitamente para Compras; não se contorna a separação modular.
Unicidade pela identidade factual do efeito e âmbito, independente da chave de pedido ou revisão. Duas aplicações concorrentes ou outra chave para a mesma aplicação recuperam **o mesmo lançamento**. Conteúdo incompatível recusa 409. Um recibo já gravado continua recuperável depois de o período fechar em dados adversariais de ensaio, sempre com a autorização actual, sem nova gravação. Não existe interface de fecho/reabertura ou de edição de lançamentos.
## Base, direitos e navegação
Configuração `general_ledger.synthetic_enabled=false` por defeito, sem activação por variável de ambiente. A guarda exige `payable-testing`, base/role QA próprias, loopback, cluster de regressão 55439 e role sem poderes administrativos. Nenhuma migração ou concessão foi aplicada no destino. Esquema instalado explicitamente numa **base nova**, separada dos livros/demos anteriores; não há criação/reparação por acesso à página.
Quatro direitos autorizados, apenas nas fixtures: `general_ledger.view/prepare/validate/post`. São **seis concessões por fixture**: preparador view/prepare/post, validador view/validate, consulta view. Reutilizam-se contexto, membros, permissões e identidade de pessoa existentes; não há herança de Administração, Financeiro ou nomes. Consumir a fonte exige também os acessos de leitura financeira explícitos já presentes nessas identidades QA. Não foi alargado o catálogo público de concessões reais.
As novas rotas pertencem a **Contabilidade**, mantendo os seis módulos e Início/Opções gerais: `/general-ledger`, `GET /api/general-ledger`, `POST /api/general-ledger/prepare|validate|post`. O menu mostra a ligação de ensaio só com `general_ledger.view` na unidade. Módulo errado ou geração antiga de contexto recusa 409. O pacote instalado de Compras não foi substituído/activado; o adaptador nativo permanece por construir no seu âmbito próprio.
Consulta por período/conta, diário e histórico em janelas de 25. Totais incluem todas as linhas do filtro, não apenas a página. Período ou conta indisponíveis são recusados em vez de aparentarem saldo zero por falta de configuração. Leitura HTTP usa fotografia `REPEATABLE READ READ ONLY`, sem bloqueios de escrita. Não se adicionaram dependências.
## Integridade e provas
Nove tabelas próprias, todas append-only. Chaves estrangeiras compostas protegem o âmbito de contas/linhas/revisões. Checks impedem montantes negativos, cabeçalho desequilibrado e terceira linha. A validação imediata do núcleo e um constraint trigger diferido conferem duas linhas, contas de movimento, moeda, fonte/fotografia exactas, revisão aprovada e independente e preparador/aplicador. O papel QA é proprietário da base nova; isto não valida endurecimento de papéis/schema para implantação real.
- **Oito cenários, 74 asserções**, seleccionando a execução final válida: circuito completo, impacto antes da aplicação, conciliação de período/contas, independência por utilizador/pessoa, recusa, preparador de outra identidade, direitos revogados, pessoa trocada, versões de fonte/regra/período/captura/revisão, moeda, conta agregadora, âmbito estrangeiro e campos injectados.
- Repetição pela mesma/outra chave; recibo após período alterado; falhas injectadas depois do cabeçalho, linhas, evento e recibo. Rollback integral e recuperação sem duplicação em cada ponto. Nenhum controlo de falha é exposto pela API/configuração.
- Base recusou escrita directa de cabeçalho desigual, principal incompatível, linha em falta, conta estrangeira, terceira linha e alteração de qualquer tabela do histórico. PostgreSQL usa savepoints nos casos esperados; uma transacção abortada não foi contada como recusa funcional.
- **Concorrência real em dois processos:** ambos aguardaram a empresa, com cadeia confirmada por `pg_blocking_pids`/`pg_stat_activity` em `companies FOR UPDATE`. Após libertação, ambos devolveram o mesmo ID. Resultado persistido na base QA: **um lançamento, duas linhas, um evento de aplicação**. Repetição posterior recuperou o recibo sem novo efeito.
- **23 verificações HTTP reais:** autenticação, consulta/totalização, duas contas, três rotas de comando recuperando os recibos originais de preparação/revisão/aplicação, outra chave para o mesmo efeito, conflito de conteúdo, permissões, conta/período indisponíveis, 422/403/409, CSRF 419, módulo errado e `no-store`. O circuito de criação/revisão/aplicação foi exercitado pelos serviços e processos reais; no HTTP verificaram-se recuperação/delegação e recusas, sem alegar criação de outro lançamento para repetir a prova.
- Três verificações estáticas dirigidas de permissão/renderização do menu, build de produção **privado** e renderização final Blade. Assets/manifesto QA foram servidos pelo router loopback de prova. Não se substituiu o manifesto público das demos.
O teste geral de navegação não terminou e foi interrompido. **Não é uma prova aprovada.** Não se alterou o trabalho alheio para o fazer passar, não se limpou cache e não se repetiu uma via de navegador. A ligação nova foi validada pelos testes dirigidos, build e recusas HTTP; revisão visual/interacção no navegador permanece pendente.
[Manifesto, resultados e hashes](proofs/common-ledger-payable-20261102.json). O texto exacto da proposta autorizada é recuperado e arquivado por SHA-256; a nota de autorização posterior não altera o recorte. As provas financeiras anteriores são conferidas por hash, sem reexecutar as suites alheias.
O primeiro diagnóstico de preparação identificou um erro no fingerprint do harness (`company_memberships` tem chave composta, não coluna `id`). A fixture foi conferida e recuperada sem repetir schema/concessões/reconhecimento; essa base ficou como diagnóstico. A prova final usa outra base nova com todos os constraints finais. Uma asserção de soma SQL foi ajustada para comparar o valor inteiro devolvido como `numeric`, sem mudar os montantes. Execuções diagnósticas não contam como aprovadas; não houve reset/remoção dos seus dados.
## Preservação e reprodução
**Demonstração própria para a equipa:** [http://127.0.0.1:8048/qa/ledger-demo](http://127.0.0.1:8048/qa/ledger-demo), apenas em loopback. Outra base/role QA, sem usar a base das provas. Tem uma obrigação reconhecida de 60 MZN e, no momento da entrega, **zero propostas e lançamentos**. Escolher Preparar/aplicar, Revisão ou Consulta na página; obrigação #1, data 29/10/2026, período/regra de ensaio já apresentados. A equipa pode executar o circuito e registar feedback; não foi atribuída aceitação ou revisão visual.
São 13 verificações adicionais de preparação do demo, sem operações de negócio: três identidades, interfaces e consulta da fixture ainda vazia. Cookie de sessão específico e nenhum `XSRF-TOKEN` global substituído; um complemento confirmou CSRF 419 e recusa de escrita 403 com token válido. A validação CSRF permanece activa e as formas usam o header explícito. Assim, autenticação/cookies das demos anteriores não são substituídos. Build copiado apenas entre as duas pastas privadas de assets; manifesto público original preservado.
`scripts/ledger-demo.mjs <tag>` inicia apenas um servidor próprio numa porta loopback livre ou reconhece o existente, sem repetir a preparação. Estado/PID e logs ficam na pasta privada. Não reiniciar/apagar dados para repetir a demonstração; a base da regressão conserva separadamente o único lançamento comprovado.
Fingerprint de origens/obrigações/decisões/recibos financeiros, concessões e membros igual ao baseline da fixture, antes/depois de todos os ensaios. O Payables continua a ter uma obrigação de 60 MZN; o diário é outro efeito, não uma segunda dívida. Stock/orçamento **não têm tabelas nesta composição mínima**, pelo que não se inventou uma prova de exercício desses motores. O núcleo não chama os seus escritores. Dados/roles/demos anteriores não foram escritos; os seis registos protegidos e a cache bloqueada não foram objecto de decisão/limpeza.
Preparação: Node fixado, `scripts/ledger-run.mjs` cria a nova base/role e configura apenas as identidades QA. Fases nomeadas recusam repetir provas concluídas. `scripts/ledger-ui-check.mjs <tag>` compila/renderiza apenas em storage privado; `scripts/ledger-http.mjs <tag>` cria e encerra só o seu servidor de prova loopback 8047. O demo 8048 é um processo/base distintos, conservados para a equipa. `scripts/summarize-ledger.mjs <tag>` consolida sem voltar a executar testes. Settings, passwords, cookies, UUIDs de repetição e logs ficam privados. Não se criou checkpoint.
## Limites mantidos
Primeiro efeito de reconhecimento apenas, sem importação/lançamento histórico automático, IVA/contas reais, classificação de compras, pagamentos/recebimentos contabilizados, integração nativa SGC, valorização de stock, correcção/estorno, fecho/reabertura, reconciliação ou contabilidade geral completa. O período fechado foi só uma mutação adversarial de fixture, revertida, não uma funcionalidade operacional.
Configuração/fonte futura deve seguir a mesma ordem de bloqueio da empresa; não há API de escrita de configuração neste incremento. Não foram medidos volumes sustentados ou implantação; o relatório financeiro anterior conserva a limitação de 12,6 s para 10.000 posições. Sem publicação, activação de Compras ou concessões reais. Revisão visual, aceitação e capacidade de implantação continuam separadas.
@@ -0,0 +1,70 @@
# Correcção de custo directo275→295 — entrega sintética
03/11/2026. `COMMON-LEDGER-DIRECT-COST-CORRECTION-SIM-v1` implementado após «Autorizar a correcção sintética proposta», conforme o [recorte apresentado](GENERAL_LEDGER_DIRECT_COST_CORRECTION_SCOPE.md). Inclui código funcional, interface, testes e demonstração própria. Não equivale a aceitação, checkpoint, uso real ou conclusão operacional de toda a aplicação.
## Resultado
O cenário novo executa os workflows existentes: oficina/origens verificadas, taxas e composição100+175=275, revisão distinta e lançamento original275. Depois prepara/aprova taxa material sucessora60MZN/UN e cálculo **120+175=295**, preservando as versões anteriores. Não fabrica a decisão de custo por SQL.
O núcleo contabilístico comum captura, revê e aplica **um par indivisível**:
| Parte | Débito | Crédito |
|---|---|---|
| Estorno do original | QA-COMPENSACAO-ENSAIO275 | QA-CUSTO-DIRECTO275 |
| Substituição actual | QA-CUSTO-DIRECTO295 | QA-COMPENSACAO-ENSAIO295 |
O original permanece. O resultado comprovado é **três lançamentos/seis linhas**, movimentos brutos **845MZN por lado** e saldo do custo **295MZN**, diferença20. Balancete e razão reconciliam este mesmo livro e ligam à ordem/conferência. A validação do custo ou a outbox não desencadeiam contabilização automática.
## Núcleo, SQL e actualidade
Reutiliza `correct-prepare/validate/post`, tabelas de grupos/resultados, writer, recibos e histórico existentes. Os tipos `workshop.direct_cost.reversal/replacement` são distintos de `journal.reversal/replacement` dos fornecedores. Os filtros continuam tipados por `cost_order_id` ou obrigação de fornecedor; IDs inteiros coincidentes não juntam os domínios.
`CostCorrectionSchema` só instala em **livro SGF QA novo e vazio** com a composição de custos presente. Acrescenta marcador de capacidade e constraints diferidas para o par de custos. Confere original, âmbito, grupo, propostas, tipos de origem, período, datas, contas, ligação à mesma ordem/perímetro, revisão de substituição e reversão integral das linhas originais. O estorno deve igualar275 do original; a substituição deve corresponder ao295 capturado/revisto. Não exige igualdade entre os montantes das duas partes. A função antiga de integridade mantém a sua validação de fornecedores e delega apenas resultados explicitamente de custos à nova verificação. Demos/schemas anteriores não são actualizados.
A aplicação pública recusa revisão ou lançamento individual das partes. A base também recusa uma reversão isolada mesmo que equilibrada e com fotografia revista. Fonte/regra/período/âmbito/pessoa/direitos são revalidados sob o bloqueio de empresa antes de aplicar; recibo, dois lançamentos, linhas, histórico e resultado são uma transacção única. Uma falha depois da primeira parte ou depois do resultado desfaz a tentativa inteira.
A identidade do grupo liga ao lançamento original, com unicidade e repetição independente da chave. Uma nova versão de taxa/cálculo muda a fotografia mesmo quando o valor continua295: exige nova captura e conferência. Capturas anteriores ficam no histórico, assinaladas como superadas na interface. Uma correcção aplicada não pode ser repetida como outra correcção do mesmo original neste recorte.
A conferência do período resolve o original pela **substituição efectivamente aplicada no grupo**, comparando a sua revisão de fonte com o cálculo actual. Não basta que os totais coincidam. Uma fonte alterada depois do par volta a bloquear o fecho e não cria uma cadeia automática de revalorizações. Correcção anterior à data do original, noutro período ou em período fechado é recusada; não há reabertura.
## Interface e permissões
O percurso mostra original275, actual295 e diferença20, depois as quatro linhas, fotografias, revisões e fundamentos do grupo. A decisão é conjunta e distinta do preparador. A aplicação conjunta só aparece após aprovação das duas partes. Diário e interface de custos ocultam as acções individuais das partes; o backend mantém a recusa mesmo com pedido manual. Grupos de outras fontes são filtrados no servidor antes da paginação/contagem. Reviews/resultados/actualidade dos grupos visíveis são lidos em conjunto, sem pesquisa individual de todo o histórico por grupo.
Foram reutilizados os três direitos `general_ledger.correct_prepare/correct_validate/correct_post`, apenas nas fixtures novas: preparador prepara/aplica; validador revê. Com os anteriores são **dez direitos contabilísticos distintos / doze concessões contabilísticas por fixture padrão**. Não há direitos públicos/reais novos ou supervisão técnica implícita. A fixture de coexistência acrescenta apenas os direitos QA de fornecedor já existentes para o seu teste.
`SGF_COST_CORRECTION_QA` fica **false sem configuração**. Requer a opção sintética de custos já protegida, `testing`/`browser-testing`, destino efectivo exacto55439/cluster/base/role próprios e marcador de schema. O flag nas settings da nova fixture não altera configurações do destino. A demonstração8054 mantém flag false, schema ausente e os nove grants contabilísticos originais; a tentativa de usar a nova origem nesse modo é recusada. O SGC autónomo continua sem exigir oficina, provado também pela regressão do seu ciclo de fornecedores. Os artefactos financeiros congelados de25 e30 ficheiros não foram substituídos.
## Provas
[Manifesto e hashes](proofs/common-ledger-direct-cost-correction-20261103.json). Bases exclusivas para regressão, concorrência, coexistência e demonstração; nenhuma prova destrutiva usa a base da equipa.
| Evidência | Resultado |
|---|---|
| Workflow efectivo |275 original e295 sucessor pelos workflows; um par aplicado, relatórios845/295 e fecho QA revisto; originais/fontes/outbox/direitos exactos |
| Serviço e integridade SQL |7 cenários /30 verificações: impactos assimétricos, recusa de partes isoladas, seis fronteiras de falha, SQL contra órfão, versões, pessoa/direitos, período fechado e ausência de cadeia implícita |
| Segurança de contexto |8 verificações: tenant/empresa/unidade estrangeiros mas elegíveis e quatro direitos correntes revogados; probes com rollback |
| Pós-verificação das interfaces |Captura superada continua visível no histórico; páginas de custo, diário genérico e ciclo legado ocultam aplicação isolada, mesmo para partes antigas revistas mas não aplicadas; só leitura |
| HTTP real |58 verificações: CSRF419, contexto antigo/errado, recusas403/409/422, quatro linhas, revisão/aplicação conjunta, proibição de partes, repetição e relatórios/fecho |
| Concorrência |6 processos reais: leitor antes da libertação, grupo desactualizado409 após commit de versão, um par e um fecho após nova conferência |
| Coexistência com fornecedor |Dois domínios de correcção no mesmo schema/livro; seis lançamentos/doze linhas, bruto1025 por lado, grupos/filtros separados e fecho conjunto |
| SGC autónomo |23 verificações actuais do ciclo original de fornecedor, com rollback, no núcleo generalizado |
| Modo275 anterior |Fonte actual exactamente igual à fotografia lançada; pacote do único adaptador de período preservado; flag/schema novos ausentes, nove grants e recusa403 |
| Demonstração nova e formulários |23 verificações, incluindo5 em VM do JavaScript renderizado; sem comandos de negócio, um original275 e nenhum grupo/fecho inicial |
| Demonstração8054 |Fingerprint exacto de fontes/outbox/direitos, flag desligado e schema novo ausente; só leitura |
As seis falhas injectadas foram `after_header`, `after_lines`, `after_event`, `after_correction_first`, `after_correction_result` e `after_receipt`. A tentativa de finalizar uma parte pelo writer interno, em probe adversarial, foi recusada pela constraint diferida `GL_COST_CORRECTION_PAIR_REQUIRED`; o rollback restaurou o livro. A aplicação recuperada criou só um resultado completo.
Na concorrência, o titular reteve o bloqueio de empresa e confirmou taxa/cálculo de versão3, **mantendo295**. O leitor terminou antes da libertação vendo apenas o original275 e nenhuma correcção aplicada. O aplicador que aguardava recusou409 após o commit. Nova captura/revisão gerou outro grupo imutável; dois aplicadores recuperaram o mesmo resultado e dois fechos a mesma versão2. São **dois grupos preparados e um aplicado**, não duplicação de aplicação. Foram observadas cadeias até ao titular e esperas `transactionid`/`tuple`. Original275, cálculos1/2 e registos anteriores da outbox permaneceram iguais; os novos registos de versão3 são os sucessores explícitos do ensaio, todos ainda `captured_no_accounting`.
Na coexistência, ordem de custo e obrigação de fornecedor partilharam o mesmo ID inteiro. O custo conserva três lançamentos/nove eventos e bruto845; o fornecedor, após a sua correcção técnica já autorizada60/60, conserva três lançamentos/nove eventos e bruto180. O foco da ordem devolve apenas um grupo de custos. Custo actual295, outbox e saldo financeiro de fornecedor60 ficaram intactos. A origem do fornecedor é recepção sintética controlada, **não importação viva SGF**. Esta prova também exercita a validação SQL antiga e a nova no mesmo livro.
Provas válidas anteriores são reutilizadas e conferidas por hash. Não foram repetidas suites de stock, fiscalidade ou demais módulos. Sintaxe PHP/JavaScript e `git diff --check` dos dois repositórios constam do manifesto. SGF não tem PHPStan/Pint instalados; código nativo de aplicação não mudou e a qualidade estática anterior não foi repetida. Não se acrescentaram dependências. Os formulários foram exercitados em VM/DOM/rede simulados e por HTTP; isso não constitui execução num navegador nem revisão visual.
## Demonstração e limites
**http://127.0.0.1:8055/qa/cost-demo**. Base/sessão/cookie/assets próprios, só loopback. Começa com original275 realmente lançado e custo sucessor295 validado, **sem grupo de correcção ou fecho inicial**. Preparar o par, rever noutra função, aplicar e consultar diário/balancete/razão; depois conferir e fechar QA. Não reiniciar o exemplo da equipa nem as demonstrações anteriores.
Mantêm-se pendentes e separados: revisão visual, carga sustentada e capacidade de implantação; contas/políticas reais e composição operacional integral dos seis módulos. Não houve novos ensaios de1.000/5.000/10.000, índices ou declaração de capacidade a partir destes casos.
Sem cadeias adicionais de revalorização por arrasto, correcção em período fechado/reabertura, stock valorizado/contabilizado, salários/CIF, operações fiscais/monetárias reais, concessões/migrações no destino, activação de Compras, publicação, push, aceitação ou checkpoint. Os seis registos, cache bloqueada, dados/acessos reais, checkpoints, provas e trabalho alheio foram preservados.
@@ -0,0 +1,19 @@
# Próxima ligação — correcção explícita de custo directo já lançado
03/11/2026. **Autorização expressa recebida: «Autorizar a correcção sintética proposta». Implementação/provas concluídas; ver [entrega e limites](GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md).** O texto seguinte conserva o recorte apresentado. A decisão pedida no final é histórica; não constitui aceitação, reabertura ou autorização operacional.
## Recorte proposto
1. Novas bases/roles QA55439 de regressão e demonstração; preservar8054 e todas as instalações anteriores. Executar os workflows efectivos de oficina/custos e o writer aprovado para o original275. Depois criar/validar explicitamente taxa material sucessora60MZN/UN, mantendo2UN e trabalho175: novo cálculo seleccionado **295MZN**. Nenhuma taxa, origem ou decisão anterior é editada; a alteração de20 é hipótese sintética, sem política real.
2. Uma correcção técnica explícita do lançamento original, capturada e revista por outra identidade/pessoa, usando o núcleo comum e uma transacção única: **estorno D QA-COMPENSACAO-ENSAIO275 / C QA-CUSTO-DIRECTO275**, e **substituição D QA-CUSTO-DIRECTO295 / C QA-COMPENSACAO-ENSAIO295**. Um grupo indivisível, sem expor aplicação individual das partes. Não é outra dívida, consumo, salário ou reconhecimento independente da mesma ordem.
3. Preservar lançamento original, duas composições/decisões/taxas e fotografias. Identidade do grupo por original, unicidade e idempotência com mesma/outra chave; uma correcção por original neste recorte. Mudança de fonte/regra/período/pessoa/âmbito antes de aplicar exige nova conferência; uma sucessora adicional não altera silenciosamente o grupo revisto. Sem cadeias adicionais de revalorização por arrasto.
4. Tipos de origem de correcção de custo distintos dos de fornecedores, mantendo `cost_order_id` e o vínculo ao grupo/original. IDs inteiros coincidentes entre ordens e obrigações não podem misturar filtros/eventos/totais. A conferência do período reconhece a resolução explícita original→par e compara a substituição com o cálculo actual295; fonte alterada sem correcção concluída continua a bloquear.
5. Mostrar antes da confirmação o original275, novo295, diferença20 e as quatro linhas do par. Depois ligar ordem/custos → decisões → grupo/original/estorno/substituição → diário/balancete/razão e fecho QA revisto. Resultado esperado: três lançamentos/seis linhas, montantes brutos845MZN por lado e saldo de custo295. A outbox técnica original não se torna executor contabilístico.
6. Reutilizar `general_ledger.correct_prepare/correct_validate/correct_post` já existentes, apenas nas fixtures novas (preparador prepara/aplica; validador revê), mais direitos actuais de consulta/custo/período. Sem direitos públicos/reais novos, supervisão técnica implícita ou alteração de contas reais. O SGC continua sem exigir oficina; preservar as correcções de fornecedor e as provas válidas.
7. Provar original/substituição exactos, isolamento, direitos/pessoas, revisões desactualizadas, falha entre as partes com rollback integral, recuperação/repetição e concorrência sem duplicar o grupo/fecho. Só testes afectados e bases próprias, sem consumir os dados da demonstração. Revisão visual, carga sustentada e capacidade de implantação permanecem separadas.
## Exclusões preservadas
Sem reabertura ou correcção em período fechado, valorizações/lançamentos de stock, salários/CIF/lote industrial, novos pagamentos/recebimentos, emissão/contas/políticas reais, concessão/migração no destino, activação de Compras, publicação, aceitação ou checkpoint. A nova demonstração pode começar com original275 e fonte sucessora295, preparados pelos workflows, para a equipa decidir a correcção; não reaplica operações de8054.
**Decisão pedida:** autorizar este ciclo sintético agrupado de correcção275→295, com par imutável, direitos de correcção só QA e conferência/relatórios pelo mesmo núcleo.
@@ -0,0 +1,76 @@
# Oficina → custo directo validado → Contabilidade
03/11/2026. `COMMON-LEDGER-DIRECT-COST-SIM-v1` implementado localmente após «Autorizar o ciclo sintético proposto», no [recorte aprovado](GENERAL_LEDGER_DIRECT_COST_SCOPE.md). Código, interface, provas e demonstração própria. Não declara aceitação, checkpoint ou conclusão operacional de toda a aplicação.
## Percurso funcional
As fixtures novas executam os workflows existentes de oficina, autorização, apontamento, verificação técnica, taxas e cálculo. Não inserem uma aprovação de custo por SQL. O caso seleccionado usa material da empresa **2UN×50=100 MZN** e trabalho efectivo **105MIN÷60×100=175 MZN**. A composição efectivamente validada é **275 MZN de custo directo conhecido do perímetro seleccionado**, sem afirmar custo completo, margem, valorização de inventário ou salário.
`DirectCosts::conference` reutiliza a validação de actualidade e a composição exacta do serviço proprietário, numa leitura sem bloqueio de escrita. O adaptador `CostLedgerSource` exige consulta da oficina e dos custos; junta a ordem, cálculo/decisão, origens/decisões técnicas e taxas/decisões seleccionadas. Não chama o writer técnico/financeiro nem processa a outbox.
O mesmo `PayableJournal` captura, revê e aplica duas linhas: **D `QA-CUSTO-DIRECTO`275 / C `QA-COMPENSACAO-ENSAIO`275**, MZN, regra/período QA explícitos. São contas artificiais para provar a ligação técnica, sem política real de capitalização/PGC. A aplicação não cria dívida, consumo, tempo, preço, movimento de stock ou folha salarial. O percurso comprovado termina com **um lançamento, duas linhas, balancete/razão reconciliados e um fecho QA revisto distintamente**.
A interface liga perímetro, conferência actual, fontes e versões → impacto/proposta → revisão → aplicação → diário/balancete/razão → conferência e fecho do período. O razão e o diário voltam à ordem de custo, distinguindo-a de uma obrigação financeira. Listas e histórico têm janelas de25; uma selecção fora da primeira página continua acessível por ID autorizado. Não se muda o contexto automaticamente para validar ou lançar. As chaves dos formulários mantêm-se nas repetições idênticas e mudam com o conteúdo; IDs são números e hashes de versão permanecem cadeias exactas.
## Actualidade, identidade e limitações deliberadas
`gl_cost_sources` declara explicitamente a ordem, os dois identificadores lógicos de origem e o período composto. O instalador só aceita um livro SGF QA novo e vazio. O mapeamento é append-only e um trigger confere o âmbito contra a ordem. Não há associação automática de ordens antigas ou dos seis registos protegidos.
A identidade do efeito usa **âmbito + tipo + ordem/perímetro**, não o ID da nova versão do cálculo. A nova versão muda a fotografia/revisão, mas não autoriza outro reconhecimento do mesmo custo. Fonte, direitos, pessoa, regra, contas, período, origens e versões são revalidados sob a serialização da empresa antes da primeira aplicação. A revisão contabilística exige outra identidade/pessoa; não concede verificação técnica. A confirmação de repetição revalida a autoridade antes de recuperar o resultado existente.
Os **175 MZN conhecidos com taxa material ausente** continuam visíveis como incompletos e são recusados como total contabilizável. Uma composição efectiva de **100,0175 MZN** também é recusada: não há arredondamento inventado para cêntimos. Origem sucessora sem verificação não herda a anterior; torna o cálculo/fonte pendente. Aritmética monetária é exacta; apresentação usa divisão inteira e resto.
O fecho inclui as fontes de custo explicitamente compostas no seu período. Custo incompleto, não lançado, alterado ou com lançamento desactualizado impede o fecho. Fotografias, versões, decisões e impactos entram no hash de conferência. Mudar o custo **depois** de o lançar não gera outro lançamento, estorno ou correcção automática: a correcção de custo já lançado está expressamente fora desta autorização. Não existe reabertura.
Os adaptadores adicionais conservam a resposta/hash anterior quando existe apenas a fonte Sales: a nova composição de custos não envolve silenciosamente o pacote de conferência de vendas noutro hash. Os filtros de custo usam `cost_order_id`, separados de `sales_invoice_id` e do `obligation_id` de fornecedores. O SGC autónomo continua sem exigir oficina e recusa a origem de custo não composta. Os artefactos autónomos anteriores de25 e30 ficheiros não foram substituídos.
Uma prova adicional compôs **custo275 + fornecedor60 no mesmo livro**, usando uma recepção sintética controlada de fornecedor do recorte já autorizado, sem importação viva. A ordem e a obrigação financeira tiveram o mesmo ID inteiro: os filtros conservaram um lançamento/três eventos de cada tipo, sem misturar os montantes. O resultado conjunto foi dois lançamentos/quatro linhas,335MZN por lado e um fecho QA. Custo/outbox ficaram iguais e a posição de fornecedor permaneceu60; não se criou pagamento ou stock. A guarda de obrigações admite também esta composição Cost-QA, com a mesma validação efectiva de base/role/cluster; direitos de consulta/decisão de fornecedor foram acrescentados apenas nessa fixture de integração. Isto não demonstra a composição completa de Compras ou todos os seis módulos.
## Guardas e direitos
`SGF_COST_LEDGER_QA` fica **false sem configuração**. A extensão da guarda aceita apenas `testing`/`browser-testing` com opção explícita, PG loopback55439, cluster esperado, base `payable_sgf_<12hex>` e role não elevada `payable_history_sgf_<mesmo id>`, sem redireccionamento de ligação e com destino efectivo conferido. As regras anteriores de `sgf_tempo_test` ficam preservadas; não se utilizou essa base para estes testes.
Só as bases novas recebem as funções SQL `sgf_wo_environment`/`sgf_dc_environment` vinculadas ao seu próprio nome/role/cluster. Os ficheiros SQL/migrações originais e os destinos existentes não foram alterados por esta instalação. O arranque não instala `CostSchema`, não migra nem concede acessos.
| Perfil sintético | Direitos reutilizados |
|---|---|
| Preparação | workshop.view; direct_costs.view/prepare; general_ledger.view/prepare/post/period_prepare/period_close |
| Validação | workshop.view; direct_costs.view/validate; general_ledger.view/validate/period_validate |
| Consulta | workshop.view; direct_costs.view; general_ledger.view |
| Técnico / verificador | Direitos técnicos existentes, separados; sem Contabilidade implícita |
São **sete direitos contabilísticos distintos / nove concessões contabilísticas**, apenas nas novas fixtures. Direitos de contexto empresa/unidade são explícitos. Cinco actores sintéticos declarados; o utilizador auxiliar do construtor fica sem grants. Não há nomes de pessoas reais no código funcional ou novos direitos públicos/reais.
## Provas
[Manifesto e hashes](proofs/common-ledger-direct-cost-20261103.json). Bases de prova e demonstração da equipa são distintas.
| Prova | Resultado |
|---|---|
| Percurso efectivo completo |275 validado pelos workflows → um lançamento/duas linhas → relatórios → fecho QA; repetição com a mesma e outra chave sem duplicação |
| Regressão de custos |9 cenários /27 verificações, com rollback: parcial175, valor sem cêntimo exacto, versões/direitos/pessoa, quatro fronteiras de falha e identidade estável |
| HTTP real |51 verificações: CSRF419, recusas403/409/422, contextos, proposta/impacto, revisão, repetição, diário/razão e fecho |
| Isolamento e verificação técnica |10 verificações: contextos estrangeiros elegíveis de tenant/empresa/unidade, três direitos revogados e sucessor técnico efectivamente criado pelo workflow mas não verificado; tudo revertido |
| Concorrência |6 processos reais; detalhes abaixo |
| Compatibilidade de fornecedores |8 cenários /74 asserções do writer numa base nova; provas anteriores preservadas |
| Coexistência custo + fornecedor |IDs coincidentes, filtros/eventos separados,275+60=335MZN no mesmo writer e fecho conjunto; custo/outbox e saldo financeiro preservados |
| Compatibilidade Sales |Fonte actual comparada exactamente à fotografia comercial lançada; resultado/hash da conferência de período apenas com Sales igual ao anterior; balancete existente continua2400MZN por lado; só leitura |
| SGC autónomo |Recusa explícita do custo sem origem composta, sem schema de oficina exigido ou proposta criada |
| Demonstração e formulários |22 verificações HTTP/VM, incluindo4 dos formulários renderizados; zero comandos de negócio; prova privada de preservação da fixture |
| Pós-verificação de apresentação |Diário identifica ordem de custo, filtro retém1lançamento/3eventos, razão conserva origem e links; só leitura |
Na concorrência, a transacção titular reteve o bloqueio da empresa e preparou/aprovou uma **taxa sucessora com o mesmo valor50**, mas versão2. Um leitor terminou antes da libertação, vendo o estado confirmado de275 e nenhum lançamento. O aplicador aguardou no bloqueio real; após o commit da nova versão respondeu **409**. O valor igual não dispensou nova conferência. O ensaio criou e validou cálculo2 e nova captura/revisão contabilística. Dois aplicadores concorrentes recuperaram **o mesmo lançamento**, e dois fechos concorrentes recuperaram **o mesmo fecho/versão2**. Foram observadas as cadeias de bloqueio até ao titular e esperas `transactionid`/`tuple`. Originais técnicos, cálculo1 e registos anteriores da outbox ficaram iguais; os novos registos de custo são os sucessores explícitos do ensaio, todos ainda `captured_no_accounting`.
As quatro falhas injectadas (`after_header`, `after_lines`, `after_event`, `after_receipt`) desfizeram lançamento, linhas, histórico e recibo da tentativa; a repetição posterior concluiu sem duplicação. Os fingerprints antes/depois do percurso integral e HTTP conferem ordens, entradas/verificações, comandos de oficina, taxas/cálculos/decisões/comandos/eventos/outbox de custos, direitos e autoridade de obrigações. O mapeamento também tem prova específica de imutabilidade.
Tentativas de preparação dos helpers que falharam antes de comandos de negócio — chave de ordenação da outbox e import de uma classe no harness — foram corrigidas e preservadas como diagnóstico, sem reset; não são contadas como provas aprovadas. O primeiro probe da demonstração omitiu a geração de contexto e recebeu409; o cliente de teste passou a enviar o token, sem alterar guardas da aplicação. Uma correcção posterior dos rótulos do diário teve pós-verificação só de leitura.
Os formulários usam VM/DOM e rede simulados sobre o JavaScript efectivamente renderizado; isso não é execução num navegador. Sintaxe PHP/JavaScript e `git diff --check` constam do manifesto. Não se acrescentaram dependências. SGF não tem PHPStan/Pint instalados; não se declara execução dessas ferramentas. A análise estática nativa válida anterior é reutilizada no seu âmbito, sem repetir ficheiros nativos de aplicação que não mudaram.
## Demonstração e pendências
**http://127.0.0.1:8054/qa/cost-demo**, base/sessão/cookie/assets privados próprios, só loopback. Parte de oficina, taxas e custo275 realmente validados; **sem proposta contabilística, lançamento ou fecho iniciais**, para decisões individuais da equipa. Não reiniciar ou consumir este exemplo em testes destrutivos. Os perfis de Contabilidade consultam evidência técnica, sem adquirir poderes de técnico/verificador.
Continuam separados: revisão visual, carga sustentada e capacidade de implantação; contas/políticas reais e composição operacional completa entre módulos. Não se mediram novos volumes1.000/5.000/10.000, criaram índices ou validou capacidade a partir deste cenário. As demonstrações existentes continuam independentes; esta entrega não prova uma única instalação operacional com todos os seis módulos.
Sem valorização/lançamentos de stock, salários/CIF/lote industrial, preço de venda derivado do custo, operação fiscal/monetária real, correcção de custo já lançado, reabertura, migração/concessão no destino, activação de Compras, publicação, push, aceitação ou checkpoint. Seis registos, cache bloqueada, provas/checkpoints anteriores e trabalho alheio preservados.
+20
View File
@@ -0,0 +1,20 @@
# Próxima ligação proposta — custo directo de oficina e diário QA
03/11/2026. **Autorização expressa recebida: «Autorizar o ciclo sintético proposto». Implementação/provas concluídas no recorte sintético; ver [entrega e limites](GENERAL_LEDGER_DIRECT_COST_DELIVERY.md).** O texto seguinte conserva o recorte apresentado; a decisão pedida no final é histórica. Não equivale a aceitação ou autorização de correcção de custo já lançado.
## Recorte a autorizar
1. Nova composição de regressão e outra de demonstração SGF no cluster55439, com bases/roles não elevadas próprias e identidades sintéticas distintas. Não usar/resetar `sgf_tempo_test`, as demonstrações ou os seis registos. As guardas PHP/SQL de oficinas/custo serão estendidas só à nova fixture efectiva, sem retirar o bloqueio real ou alterar migrações no destino.
2. Reutilizar efectivamente os workflows de oficina, verificação e custo directo, sem fabricar uma aprovação por SQL: material da empresa2UN×50MZN/UN =100; trabalho105MIN÷60×100MZN/H =175; **275MZN de custo directo conhecido no perímetro seleccionado**. Taxas escolhidas e validadas por ID/versão; validação do cálculo por identidade distinta. Não é custo completo, margem, inventário valorizado ou salário real.
3. Capturar uma proposta contabilística do cálculo validado, com ordem, perímetro, origens técnicas, taxas, versões, decisão e hash. Um lançamento sintético de duas linhas no writer comum: **D `QA-CUSTO-DIRECTO`275 / C `QA-COMPENSACAO-ENSAIO`275**, MZN, período QA explícito. São contas artificiais de um ensaio de ligação técnica; não representam política real de capitalização, contrapartida de stock/folha salarial ou enquadramento PGC aprovado. Não duplicar dívida, consumo físico, tempo, preço comercial ou lançamento de factura de fornecedor.
4. Conferência contabilística distinta e fundamento antes de aplicar. Revalidar direitos, contexto, pessoa, fonte, origens/taxas, versão do cálculo/regra/período e impacto sob o mesmo bloqueio de empresa. Uma identidade de efeito estável por ordem/perímetro impede contabilizar novamente a mesma composição apenas porque foi criada outra versão de cálculo. Alteração antes de aplicar exige nova conferência; depois de aplicado não permite outro reconhecimento directo ou correcção silenciosa. Correcção/revalorização de custos já lançados permanece fora deste recorte.
5. Cálculo incompleto de175 conhecidos, taxa ausente, origem não verificada, total racional não representável em cêntimos ou versão desactualizada ficam visíveis e recusam o lançamento. Não arredondar, inventar taxa/total ou tratar pendência como zero. O fecho QA confere também esta origem explicitamente composta e recusa pendências/mudanças; sem reabrir período.
6. Ligar oficina/custo/decisões → proposta/impacto → diário → balancete/razão e fecho QA. Nenhuma contabilização por arrasto da validação de custo ou outbox: preparar, rever e aplicar são decisões explícitas. A outbox original mantém o seu estado histórico; não se transforma retroactivamente em executor.
7. Reutilizar direitos `workshop.view`, `direct_costs.view/prepare/validate` e `general_ledger.view/prepare/validate/post/period_*` existentes, distribuídos só em novas fixtures. Sem direitos públicos/reais novos, herança de administração, técnico/verificador real ou novas dependências. O SGC autónomo continua sem exigir oficina e recusa a origem não composta.
8. Provar o percurso275, recusas de175 incompletos/versões/âmbito/direitos, concorrência, falhas/repetição sem duplicação e preservação de origens. Executar apenas regressão afectada do writer/fecho/relatórios, reutilizando as provas válidas. Demonstrar na base própria da equipa sem consumir os seus exemplos nos testes. Revisão visual e capacidade de implantação continuam separadas.
## Exclusões mantidas
Sem valorização/lançamentos de stock, salários/folha salarial, CIF/lote industrial, produção, conta ou política fiscal real, novos pagamentos/recebimentos, emissão real, reabertura, correcção de custos lançados, migração/concessão no destino, activação de Compras, publicação, aceitação ou checkpoint. A autorização proposta só retira a exclusão de **desenvolver e provar este custo directo seleccionado275 no diário sintético**, mantendo as restantes fronteiras.
**Decisão pedida:** autorizar este ciclo sintético agrupado, incluindo a nova origem de custo directo e os dois tipos de conta artificiais acima, com direitos só QA e o mesmo writer, sem utilização real.
@@ -0,0 +1,43 @@
# Primeiro lançamento sintético — proposta concreta para autorização
**Autorização recebida em02/11/2026:** o utilizador respondeu «Autorizar o incremento sintético proposto». O recorte abaixo foi autorizado e implementado localmente; estado/provas em [GENERAL_LEDGER_DELIVERY.md](GENERAL_LEDGER_DELIVERY.md). A descrição de proposta/decisão pendente abaixo preserva a preparação histórica e não indica falta de autorização actual. Não constitui aceitação funcional, activação real ou autorização de checkpoint.
`COMMON-LEDGER-PAYABLE-SIM-v1` — proposta de 02/11/2026. **Apenas especificação; motor, schema, direitos e lançamentos desta proposta não implementados ou concedidos.** A entrega funcional feita nesta passagem é o relatório financeiro, descrito em [SGF_PAYABLE_REPORT_DELIVERY.md](SGF_PAYABLE_REPORT_DELIVERY.md).
## Limite que exige decisão
O [AGENTS.md](../AGENTS.md) regista: «O recorte actual permanece só obrigação a fornecedor; sem pagamentos/cobranças/lançamentos por arrasto.» Regista também o desenvolvimento dos módulos em falta com identidades e bases sintéticas, sem confirmações técnicas de cada pequeno passo. Esta proposta agrupa **um incremento funcional contabilístico novo**, para autorização expressa antes de criar o primeiro motor/escritor e seus direitos. Não é um pedido para repetir aceitações ou aplicar acessos reais.
A classificação existente do catálogo é de vendas, sem lançamentos. Não fornece automaticamente a contrapartida de uma factura de fornecedor. O relatório financeiro novo também não é uma origem contabilística nem uma autorização de reconhecimento: a origem proposta é o efeito imutável reconhecido no núcleo Payables, conferido novamente.
## Recorte proposto para aprovar integralmente
1. Um núcleo único em `packages/general-ledger`, primeiro adaptador SGF, desligado por defeito e limitado a uma base/role QA nova no cluster 55439. Composição autónoma nativa futura separada; não copiar um segundo motor, activar pacotes instalados ou exigir SGF remoto ao SGC.
2. Um efeito: **obrigação de fornecedor reconhecida → um lançamento sintético equilibrado**. Fonte por âmbito, identidade factual, ID do reconhecimento e revisão exacta, não por coincidência de IDs ou leitura do estado de uma factura. Não voltar a reconhecer o principal por chegada da factura ou posterior pagamento.
3. Cenário novo e explícito: principal 60,00 MZN, imposto documental 0, sem pagamentos nessa fixture inicial. Isto delimita o cenário e não cria uma pré-condição universal de dívida só ser reconhecida antes de pagamentos. Usa-se o principal reconhecido, nunca o saldo residual como principal do lançamento.
4. Regra imutável de ensaio `QA-PAYABLE-JOURNAL-v1`: **débito 60,00 em `QA-CONTRAPARTIDA` / crédito 60,00 em `QA-FORNECEDOR`**. Ambas contas de movimento artificiais. Não são contas PGC aprovadas, despesa/inventário/IVA inferidos, parametrização da Tempo ou classificação comercial reutilizada indevidamente.
5. Data explícita de lançamento 29/10/2026 e período artificial `QA-2026-10`, aberto de 01/10 a 31/10/2026. Data e período são hipóteses do ensaio, separados de vencimento, data de pagamento e enquadramento fiscal real. Sem fecho/reabertura operacional ou escolha automática da data do computador.
6. Preparador sintético propõe fonte, data, regra/versionamento e fundamento; validador sintético distinto revê a fotografia exacta; preparador aplica apenas a versão revista. Não há validação técnica/fiscal implícita, privilégio associado a nomes ou revisão pelo próprio autor. Mudança de fonte/regra/período desde a prévia exige nova conferência.
7. Aplicação numa transacção: revalidação de permissões/contexto, fonte/revisão, decisão independente, regra, período aberto/versionado, moeda e igualdade exacta de débitos/créditos; gravação de cabeçalho/linhas, recibo de repetição e auditoria append-only. Fundamento, autor/data e referências preservados. Montantes inteiros em unidades menores, sem arredondamento novo.
8. Unicidade por empresa/unidade, identidade factual do reconhecimento e tipo de efeito. Mudança de versão ou chave de pedido não autoriza segundo lançamento do mesmo efeito. Mesma chave/conteúdo recupera o recibo; conteúdo diferente recusa. A autorização actual é conferida antes de recuperar um resultado. Um lançamento já gravado permanece histórico mesmo se o período fechar depois; replay não cria nova gravação.
9. Consulta do lançamento e extracto/total apenas desse período/duas contas sintéticas, reconciliados com os próprios movimentos e a origem. Não declarar contabilidade geral, balancete legal, fiscalidade, reconhecimento de vendas, valorização de stock, pagamentos ou fecho completos.
## Direitos apresentados antes de qualquer concessão
Proposta de novos direitos **apenas para identidades da fixture QA**: `general_ledger.view`, `general_ledger.prepare`, `general_ledger.validate`, `general_ledger.post`. Não há herança de Administração, Compras, Financeiro, classificação ou nomes.
| Função sintética | Direitos propostos |
|---|---|
| Preparador/aplicador | view, prepare, post |
| Validador distinto | view, validate |
| Consulta | view |
Nenhuma concessão real, direito público ou competência para gerir estes direitos é incluída. A autorização do incremento autoriza construir e conceder estes direitos **na fixture isolada**, não na Tempo, nas demos existentes ou em dados comerciais.
## Provas previstas e exclusões
Testar fonte inexistente/incompatível, tenant/empresa/unidade indevidos, direitos revogados, actor/pessoa trocados, auto-revisão, regra/data/período/versão alterados, débitos/créditos incompatíveis, moeda, concorrência da mesma origem, repetição por mesma/outra chave, falha entre cabeçalho/linhas/recibo/auditoria e recuperação sem duplicação. Conferir montantes/origens/stock/orçamento/Payables antes/depois, fora dos novos registos de lançamento de ensaio. Não executar de novo suites válidas alheias.
A proposta não autoriza correcção de lançamento já aplicado, estorno, reabertura de período, reconciliação bancária, efeito de pagamento/recebimento, contas/impostos reais, importação histórica automática, emissão, instalação de migração no destino, publicação, aceitação ou checkpoint. Eventual correcção exige circuito append-only próprio posterior; os originais não serão editados/apagados por um percurso provisório.
Seis registos protegidos, cache bloqueada, demos, direitos/dados reais e trabalho em curso permanecem intactos. Revisão visual e capacidade de implantação continuam separadas. **Decisão pendente:** autorizar este recorte sintético, com estes quatro direitos exclusivamente QA, antes da implementação contabilística.
@@ -0,0 +1,57 @@
# Correcção industrial1800→1820 — entrega local sintética
03/11/2026. `COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1`. Autorização expressa: **«Autorizar a correcção industrial sintética proposta»**. Implementado e testado o recorte aprovado, com o mesmo escritor contabilístico. Proposta original arquivada por hash antes de actualizar o seu estado; sem aceitação ou checkpoint presumidos.
**Demonstração nova: http://127.0.0.1:8059/qa/cost-demo.** Bases/roles próprias no cluster QA55439;8058 e anteriores preservadas. A demo começa com os originais directo275 e industrial1800 lançados, sucessores295 e1820 efectivamente validados e nenhum grupo correctivo ou fecho aplicado. As decisões da demonstração continuam individuais.
## Percurso implementado
Os workflows industriais existentes criam/validam a taxa material sucessora51MZN/KG, mantendo20KG, trabalho500, conjunto/critério CIF e produção10UN. Resultado sucessor1020+500+300=1820,182/UN; **residual300 separado, sem lançamento**. Entradas, taxa anterior50, critério, resultados1800/1820, decisões e original contabilístico permanecem imutáveis.
O preparador captura original1800, nova origem1820, diferença20, contas/período e quatro linhas; outro utilizador/pessoa confere essa versão exacta. O mesmo serviço aplica numa transacção:
| Parte | Débito | Crédito | Montante MZN |
|---|---|---|---:|
| Estorno | QA-COMPENSACAO-INDUSTRIAL | QA-CUSTO-INDUSTRIAL |1800|
| Substituição | QA-CUSTO-INDUSTRIAL | QA-COMPENSACAO-INDUSTRIAL |1820|
Os tipos `industry.cost.reversal/replacement` conservam lote, original, origem estável, versões e vínculo ao grupo. Não podem ser pedidos no `effect-prepare` público nem revistos/aplicados individualmente. O núcleo reutiliza capturas, revisão, escrita, eventos, comprovativos de repetição e autorizações existentes; não há segundo motor ou execução da outbox.
`IndustrialCorrectionSchema` instala apenas em livro SGF novo vazio. A guarda SQL diferida exige o resultado do grupo e as duas partes coerentes com original, âmbito, período, contas e montantes. Uma parte isolada equilibrada é recusada no commit. A delegação reconhece apenas o domínio industrial; mantém as validações SQL próprias de fornecedor e custo directo.
A confirmação revalida identidade/pessoa, direito, contexto, revisão, fonte industrial, resultado/taxas/critério, regra e período sob o bloqueio de empresa existente. Versão alterada, mesmo continuando1820, exige nova conferência. A substituição aplicada resolve a diferença com o original para a conferência do período. Uma alteração posterior torna a fonte pendente; não gera outra correcção ou reconhecimento automaticamente. Uma correcção por original neste recorte, sem cadeia adicional ou reabertura.
## Integração e consulta
Página industrial mostra origem actual, diferença, fotografias das quatro linhas, decisões e autores, grupo, original, estorno e substituição. Grupos usam o mesmo ID/versão de comando, fundamentos obrigatórios e repetição estável para conteúdo igual. Captura superada permanece visível sem acção. As interfaces industrial, diário genérico e ciclo comum não expõem aplicação isolada das partes.
Filtros de lote, ordem e obrigação continuam tipados e aplicados antes de paginar. A janela de grupos industrial filtra o original industrial antes da paginação; não mistura o grupo directo com ID factual igual. O razão liga ao lote, e o fecho conjunto confere todas as origens, não só a página visível. Apenas direitos `correct_prepare/validate/post` existentes, mais direitos de origem/consulta/período já aprovados, nas fixtures novas. Não se concedeu direito real/público ou por nome/email.
## Resultados e provas
Manifesto verificável: [common-ledger-industrial-correction-20261103.json](proofs/common-ledger-industrial-correction-20261103.json).
| Prova dirigida | Resultado |
|---|---|
| Regressão `a3c69acf5a50` |7 casos/31 checks em rollback: impacto assimétrico, partes públicas recusadas,6 fronteiras de falha, recuperação, SQL contra parte órfã, versão alterada, direitos/pessoa, flag desligada, período fechado e ausência de cadeia implícita |
| Segurança nessa base |7 checks de contextos e direitos revogados, preservando originais |
| Âmbito aprofundado em `d55f5fc71dcb` |4 checks: tenant/empresa/unidade estrangeiros com pertença, direitos contabilísticos e pessoa financeira efectivamente presentes; original inacessível por âmbito; rollback integral |
| HTTP real nessa base |51 checks: autenticação, CSRF/contexto, fonte/versão, prévia, revisão distinta, recusa de partes, repetição mesma/outra chave, três lançamentos/seis linhas industriais, nove eventos, razão1820, fonte intacta e fecho recusado enquanto outros domínios estão pendentes |
| Coexistência nessa base |6 checks em rollback: três domínios correctivos — fornecedor, directo e industrial — passam as guardas SQL e mantêm filtros/eventos/totais separados, mesmo com IDs factuais iguais |
| Percurso conjunto nessa base |33 checks: completar venda/recebíveis, fornecedor e correcção directa; revalidar origens/direitos industriais e directos; um fecho e relatórios reconciliados |
| Concorrência `5b49607829f9` |6 processos reais: leitor termina antes da libertação; escritor aguarda lock de empresa observado; após sucessora51/resultado1820 versão3 confirmados, grupo antigo recusa409; dois pedidos do grupo novo recuperam um único par; dois fechos recuperam um único fecho |
| Demo `61b694d72ae4` |23 checks de três perfis/links e formulário renderizado;5 deles em Node VM. Sem comandos de negócio; impressão digital original/outbox/direitos exacta antes/depois |
**155 checks dirigidos**, incluindo5 de formulário simulado, mais a prova concorrente de6 processos. As provas válidas de anteriores incrementos e pacotes congelados são reutilizadas por hash; não se repetiram suites alheias ou benchmarks de carga.
Industrial:3 lançamentos/6 linhas, bruto5420 por lado, saldo1820, residual300 fora do diário. Composição completa: **11 lançamentos/23 linhas, débitos8725=créditos8725MZN e1 fecho**. Mantém cliente400, directo295 e fornecedor financeiro60. Na concorrência ficam dois grupos industriais preparados — um superado e um aplicado — sem duplicar a correcção. A conferência SQL final verifica apenas grupos efectivamente aplicados.
As primeiras tentativas completas de composição encontraram um contador de teste que ainda esperava só um grupo correctivo. O produto já tinha correctamente o grupo industrial e o directo; ajustou-se o auxiliar e conservou-se o estado parcial, sem reset ou prova de sucesso presumida. A prova concorrente completa correu em base nova. O pós-teste SQL inicial tentou validar também o grupo superado não aplicado; foi corrigido para validar resultados aplicados. O HTTP inicial parou numa expectativa do estado antigo `requires_correction`; o estado industrial preservado é `requires_future_correction`. Não se contam estas saídas parciais como provas válidas.
## Preservação e limites
Na8058 continuam flag de correcção desligada e schema novo ausente; fontes, outbox, direitos, contagem inicial do livro e pacote de três fornecedores de conferência mantêm os valores/hash anteriores. Não se actualizaram bases antigas. A nova configuração de correcção é false por omissão. Originais industriais1800/1820 e outbox anteriores permanecem exactos também na prova concorrente; a versão3 é acréscimo sintético explícito para provar recusa de revisão desactualizada.
Seis associações pendentes, cache bloqueada, demos anteriores, configurações/direitos reais e alterações alheias preservados. Credenciais/dados de acesso permanecem privados. Sem stock valorizado, folha salarial, residual contabilizado, pagamentos/recebimentos novos, correcção em período fechado, cadeia adicional, reabertura, instalação no destino, activação de Compras ou publicação.
Revisão visual continua pendente: HTTP e Node VM não são prova de navegador. Carga sustentada/concorrência em volume e capacidade de implantação continuam pendentes separadas. O ensaio local não demonstra aplicação completa ou operação real. Sem experiências de índices ou índices no destino; sem aceitação ou checkpoint de encerramento presumidos.
@@ -0,0 +1,29 @@
# Correcção industrial sintética1800→1820 — proposta para decisão
03/11/2026. `COMMON-LEDGER-INDUSTRIAL-CORRECTION-SIM-v1`. **Autorização expressa recebida: «Autorizar a correcção industrial sintética proposta». Implementado/testado; ver [entrega e limites](GENERAL_LEDGER_INDUSTRIAL_CORRECTION_DELIVERY.md).** Texto aprovado arquivado por hash; a decisão pedida no fim é histórica, não aceitação ou autorização operacional. Preserva a [entrega industrial1800](GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md) e todas as aprovações anteriores.
## Percurso proposto
1. Bases/roles QA novas55439 para regressão e demonstração; não alterar8058 ou outras demos. Reutilizar workflows industriais efectivos e escritor contabilístico comum para resultado original1800 e respectivo lançamento. Preparar taxa material sucessora51MZN/KG, mantendo20KG, trabalho500 e CIF imputado300:1020+500+300=1820,10UN,182/UN. Residual300 separado e não lançado. Taxa e novo resultado revistos por identidade distinta, sem editar o original.
2. Capturar uma correcção explícita do original industrial, com fundamento, origem estável e fotografias/versões exactas. Mostrar original1800, novo1820, diferença20 e quatro linhas antes da decisão: estorno D QA-COMPENSACAO-INDUSTRIAL1800 / C QA-CUSTO-INDUSTRIAL1800; substituição D QA-CUSTO-INDUSTRIAL1820 / C QA-COMPENSACAO-INDUSTRIAL1820. Contas artificiais, sem alteração do residual, quantidades, orçamento, stock ou folha salarial.
3. Uma revisão contabilística distinta e uma aplicação atómica do grupo inteiro no mesmo núcleo. Não expor validação/aplicação individual das partes. Preservar entrada/taxa/critério/resultados, decisões, original contabilístico e outbox. Identidade tipada industrial separada de fornecedor/custo directo; IDs iguais não misturam filtros, eventos ou totais.
4. Uma correcção por original neste recorte. Versão/direito/contexto/pessoa/regra/período/dependências alterados exigem nova conferência. Uma nova versão de resultado não permite reconhecer novamente o mesmo lote. Cadeias adicionais de correcção, correcção em período fechado e reabertura ficam excluídas.
5. Resolver explicitamente a pendência actual `requires_future_correction`: após o par, a conferência de período compara a substituição1820 com a origem vigente; outra alteração continua a impedir o fecho. Diário, balancete e razão ligam original, grupo, estorno e substituição. Só o fecho QA já existente, com nova captura e revisão distinta, sem reabrir períodos.
## Resultado e direitos propostos
Industrial isolado:3 lançamentos/6 linhas; bruto5420MZN por lado; saldo industrial1820; residual300 fora do diário. Na composição dos quatro domínios já aprovados:11 lançamentos/23 linhas; bruto8725 por lado, com os outros saldos preservados. Isto é reconciliação de um cenário artificial, não política contabilística real.
Reutilizar `general_ledger.correct_prepare/correct_validate/correct_post` e os direitos de leitura industrial/diário/período existentes, exclusivamente nas bases QA novas. Preparador prepara/aplica; validador distinto revê; leitor consulta. Nenhum direito novo, concessão real/pública ou privilégio por nome/email. A extensão do escritor e das guardas SQL fica limitada à correcção industrial seleccionada, preservando as regras dos outros domínios.
## Provas e exclusões
Provar originais imutáveis, impacto exacto, recusa de partes isoladas por aplicação e SQL, isolamento, pessoa/direitos actuais, versões alteradas, falhas entre partes com rollback integral, recuperação e repetição sem duplicar, concorrência de aplicação/fecho e coexistência com correcção directa/fornecedor. Reutilizar provas antigas válidas e executar apenas verificações afectadas. Demo inicial sem correcção industrial ou fecho consumidos; sem reset automático.
Preservar seis registos sem associação, cache bloqueada, demos, pacotes congelados, direitos reais e trabalho em curso. Sem pagamentos/recebimentos novos, residual contabilizado, stock valorizado, salários, contas/políticas/operações reais, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint. Revisão visual, carga sustentada e capacidade de implantação permanecem pendentes.
## Motivo da decisão
O [AGENTS.md](../AGENTS.md), na autorização de `COMMON-LEDGER-INDUSTRIAL-SIM-v1`, exclui expressamente **«correcção industrial»**. A continuação técnica permite preparar este recorte, mas não elimina essa exclusão. Pede-se retirar apenas essa exclusão para desenvolver/provar o par sintético1800→1820 descrito acima, conservando os restantes limites.
**Decisão pedida:** autorizar a correcção industrial sintética1800→1820, com revisão distinta, par atómico, direitos existentes só QA e relatórios/fecho pelo mesmo núcleo.
@@ -0,0 +1,62 @@
# Lote industrial → Contabilidade — entrega sintética local
Continuação posterior expressamente autorizada e [entregue em bases novas: correcção1800→1820](GENERAL_LEDGER_INDUSTRIAL_CORRECTION_DELIVERY.md). As exclusões de correcção abaixo descrevem o âmbito original desta entrega e continuam na demo8058; a capacidade nova existe só na8059 e nas suas bases de prova. Manifestos anteriores conservam o estado histórico.
03/11/2026. `COMMON-LEDGER-INDUSTRIAL-SIM-v1`, autorizado expressamente por **«Autorizar o ciclo industrial sintético proposto»**. Recorte aprovado preservado por hash; sem aceitação ou checkpoint presumidos.
**Implementado:** resultado industrial efectivamente preparado/validado pelo módulo proprietário → captura com impacto → revisão contabilística distinta → aplicação única pelo escritor comum → diário/balancete/razão → conferência e fecho QA conjunto. Demonstração inicial separada: **http://127.0.0.1:8058/qa/cost-demo**.
## Origem e impacto
`IndustrialCostFixture` executa os workflows de `IndustrialCosts`, com identidades distintas para preparar e validar entradas, taxas e critério. Calcula-se e valida-se explicitamente o resultado:20KG×50=1000 material;300MIN÷60×100=500 trabalho; conjunto CIF600/capacidade10HM×utilização5HM=300 imputados. Total1800MZN;10UN;180/UN. **Residual300 separado e não lançado.** As aprovações positivas não são fabricadas por SQL.
`IndustrialCosts::conference` confere um lote e o resultado actual, incluindo o produto, entradas/versões, taxas, critério, decisões e validade das dependências, sem carregar a listagem industrial inteira ou obter bloqueio de escrita. `IndustrialLedgerSource` conserva esta evidência na fotografia da origem. Exige resultado vigente completo aprovado distintamente, produção concluída e valor exacto representável em cêntimos; não arredonda ou transforma800 conhecidos sem taxa material num total.
O escritor `PayableJournal` existente aplica **D QA-CUSTO-INDUSTRIAL1800 / C QA-COMPENSACAO-INDUSTRIAL1800**, em duas linhas. Usa a transacção/bloqueio de empresa, autorização actual, revisão exacta, idempotência, histórico e guardas QA existentes. Não há segundo motor, processamento da outbox ou efeito financeiro adicional. A identidade do efeito é estável por lote/perímetro, mesmo se o resultado ganhar outra versão. Um resultado já lançado que muda fica `requires_future_correction`; não se criou correcção industrial por consequência desta entrega.
O período contabilístico tem de conter o período completo do lote. O fecho inclui todas as fontes explicitamente compostas, revalidando permissões e dependências industriais mesmo quando o montante continua1800. Perda de acesso, origem pendente ou versão alterada exige nova conferência.
## Composição e consulta
Nova base/role de demonstração e bases distintas de regressão no cluster55439. O instalador industrial exige livro novo vazio e cria apenas a selecção imutável dos perímetros QA; não migra instalações anteriores. Guardas SQL de custeio industrial são configuradas para a identidade exacta dessas bases novas, preservando os bloqueios no destino. A capacidade `industrial_ledger.synthetic_enabled` fica desligada por omissão.
O painel conjunto passa a incluir lotes industriais apenas quando essa capacidade foi explicitamente composta. Páginas independentes de25; resultados/decisões/lançamentos são lidos em conjuntos por página, sem conferência por cartão. Os cartões distinguem metadados registados de uma conferência actual. Sem `industrial_costs.view`, o painel não revela linhas ou contagens, e não expõe formas de fecho. Origem indisponível falha explicitamente. A página do lote, diário genérico e razão mantêm ID industrial tipado e ligações à evidência; não apresentam lote como dívida de fornecedor.
Reutilizam-se apenas direitos existentes: preparador industrial `.view/prepare`, validador `.view/validate`, leitor `.view`, mais os direitos contabilísticos/contextuais já autorizados. As concessões existem exclusivamente nas fixtures novas. Sem novos direitos públicos/reais ou associação de privilégios a pessoas reais.
O módulo proprietário impede reutilizar `source_ref` entre lotes. A ligação contabilística recusa origens seleccionadas que coincidam com perímetros de custo directo; o adaptador directo também confere a composição industrial quando activada. São origens sintéticas distintas. Isto não constitui um adaptador de stock ou prova de equivalência de referências empresariais legadas.
## Resultados e provas
Manifesto: [common-ledger-industrial-20261103.json](proofs/common-ledger-industrial-20261103.json).
| Prova | Resultado |
|---|---|
| Regressão `1aa5762a0c16` | 8 casos/31 verificações em rollback: exactidão, parcialidade, dependências, direitos/pessoa, contexto elegível de tenant/empresa/unidade diferente, quatro pontos de falha, recuperação, duplicação, sobreposição directa, mapeamento imutável e páginas25+1 |
| Limites adicionais na mesma base | 5 casos/10 verificações em rollback: resultado não aprovado, total racional1800,0000002 sem arredondamento, produção não concluída, critério pendente, origem reutilizada entre lotes, sucessora de resultado já lançado e período incompatível |
| HTTP real | 43 verificações: autenticação, CSRF, contexto/módulo, filtros, impacto, preparador/validador distintos, revisão antiga, repetição, razão/diário tipados e recusa de fecho com outras fontes pendentes |
| JavaScript industrial renderizado | 5 verificações em Node VM: IDs numéricos, revisão textual exacta, datas/período, chave estável, nova chave para novo fundamento e apresentação/recuperação de recusa |
| Percurso conjunto | 33 verificações em `1aa5762a0c16`, após o HTTP industrial; nove lançamentos/19 linhas e fecho único, incluindo perda de direitos industriais e sucessora industrial do mesmo valor antes de fechar |
| Concorrência `e3009c989fb2` | 6 processos reais:1 leitor e5 escritores. Espera no bloqueio da empresa, cadeia até à transacção detentora observada em `pg_stat_activity`; leitor termina antes da libertação; versão industrial alterada/confirmada causa409 depois da libertação; duas aplicações recuperam o mesmo lançamento e dois fechos recuperam o mesmo fecho |
| Demo `072ef8124ab4` | 40 verificações de três perfis, links efectivos e controlos; quatro delas simulam o formulário do período em Node VM. Sem comandos de negócio. Hash antes/depois confirma fontes/outbox/direitos preservados, sem lançamento industrial ou fecho |
**162 verificações dirigidas**, incluindo9 verificações simuladas de formulário, mais a prova de6 processos concorrentes. Os13 casos de regressão são transaccionais e não consomem a demo. A primeira execução do teste adicional de produção apanhou uma `ValidationException` esperada que o auxiliar não capturava; corrigiu-se o auxiliar, preservando o comportamento de recusa do produto, antes de gerar a prova válida.
| Resultado conjunto na regressão | Valor |
|---|---:|
| Vendas/recebíveis:4 lançamentos/9 linhas | Bruto2400 por lado; saldo cliente400 |
| Custo directo e correcção:3 lançamentos/6 linhas | Bruto845 por lado; saldo custo295 |
| Fornecedor:1 lançamento/2 linhas | Bruto60 por lado; dívida financeira60 |
| Industrial:1 lançamento/2 linhas | Bruto1800 por lado; saldo industrial1800 |
| **Conjunto:9 lançamentos/19 linhas** | **Débitos5105 = créditos5105MZN;1 fecho** |
| Residual industrial | **300MZN fora do diário** |
As conferências finais comparam a origem industrial actual com a fotografia lançada, validam o equilíbrio SQL e confirmam que toda a outbox industrial continua `captured_no_stock_or_accounting`. A comparação de hash dos fornecedores de período preserva o comportamento anterior quando o fornecedor industrial está desligado, incluindo a demo8057: três painéis, sem schema industrial e impressão digital original dos dados/direitos intacta. Provas antigas válidas e pacotes congelados são conferidos por hash, não repetidos ou substituídos.
## Limitações preservadas
A demo8058 inicia-se com o custo directo275 lançado e fontes disponíveis de venda1000, fornecedor60, sucessor directo295 e industrial1800. Abertura de recebíveis, aplicações adicionais, correcção e fecho continuam por decisões individuais. As regressões consumidas estão em bases diferentes. Demos anteriores e comprovativos não foram repostos.
Sem stock valorizado, folha salarial, contas ou enquadramento fiscal real, pagamentos novos, correcção industrial, reabertura, instalação no destino, activação de Compras ou publicação. Os seis registos protegidos e a cache bloqueada não foram usados ou alterados. Alterações alheias preservadas; credenciais permanecem fora do repositório.
Revisão visual permanece pendente: HTTP e Node VM não são prova de navegador ou aceitação da equipa. Carga sustentada/concorrência em volume e capacidade de implantação continuam pendentes distintas; os ensaios pequenos não validam operação real ou a aplicação inteira. Não se acrescentaram experiências de índices nem se aplicaram índices no destino.
+29
View File
@@ -0,0 +1,29 @@
# Proposta agrupada — lote industrial validado e Contabilidade QA
03/11/2026. `COMMON-LEDGER-INDUSTRIAL-SIM-v1`. **Autorização expressa recebida: «Autorizar o ciclo industrial sintético proposto». Implementado e testado no recorte abaixo; ver [entrega e limitações](GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md).** A proposta aprovada foi arquivada por hash antes desta anotação. A decisão pedida no fim é histórica, não aceitação ou autorização operacional. A composição de vendas/fornecedor/custo directo está [entregue separadamente](GENERAL_LEDGER_COMPOSITION_DELIVERY.md).
## Resultado delimitado proposto
Reutilizar `IndustrialCostFixture` e os workflows efectivos de `IndustrialCosts`:20KG×50=1000 material,300MIN÷60×100=500 trabalho, conjunto CIF600/capacidade10HM×utilização5HM=300 imputados. Resultado industrial1800, produção10UN e custo180/UN. O residual300 mantém-se separado, sem lançamento automático. Estes valores são hipóteses artificiais já usadas no custeio industrial, não políticas reais.
Criar/calcular/rever explicitamente as entradas, taxas, critério e resultado por identidades distintas numa base QA nova55439; preservar as bases existentes. Não fabricar aprovações por SQL. Uma origem contabilística industrial tipada conserva lote, produto, período,10UN, entradas/taxas/critério/resultados e respectivas versões/hashes. Falta de taxa, produção não concluída, critério pendente ou resultado não vigente impede a captura, sem total inventado.
Uma proposta de duas linhas no **mesmo escritor comum**, com prévia1800 e revisão distinta: D `QA-CUSTO-INDUSTRIAL`1800 / C `QA-COMPENSACAO-INDUSTRIAL`1800. Contas artificiais de compensação de ensaio, sem política de inventário, salários, capitalização ou PGC real. O diário não altera quantidades, consumos, outbox, dívida ou orçamento.
Um efeito estável por lote/perímetro evita duplicação por versão. Não somar novamente origens contabilizadas como custo directo ou reconhecer o mesmo lote em dois caminhos: recusar origens reutilizadas/ambíguas antes de propor. O cenário usa origens industriais sintéticas distintas das ordens já contabilizadas. Correcção de resultado industrial já lançado fica fora deste recorte; alteração posterior fica visivelmente pendente e bloqueia o fecho até futura resolução autorizada.
Ligar lote→resultado/decisões→captura/impacto→diário→balancete/razão→conferência/fecho QA. Acrescentar painel industrial paginado à composição apenas quando explicitamente configurado. A comparação orçamental existente permanece consulta do seu motor, sem lançamento do orçamento ou segunda autoridade de custo.
## Direitos e provas propostas
Só nas fixtures novas: preparador com `industrial_costs.view/prepare`, `general_ledger.view/prepare/post/period_prepare/period_close`; validador distinto com `industrial_costs.view/validate`, `general_ledger.view/validate/period_validate`; leitor com os dois direitos `.view`; contexto `.view` de empresa/unidade. Reutilizar os direitos existentes. Sem direito novo, associação a nome real ou concessão pública/real. Direitos dos outros percursos apenas se a nova fixture também os compuser explicitamente.
Provar montantes exactos, residual separado, isolamento/direitos/pessoas, incompletude, dependências/versões alteradas, origem duplicada, falhas transaccionais, repetição e concorrência sem duplicar efeito/fecho. Uma base de regressão e outra de demonstração, sem consumir as anteriores. Conferência de período integra todas as fontes configuradas. Provas anteriores válidas são reutilizadas; revisão visual/carga/implantação separadas.
## Exclusões e decisão necessária
Sem stock valorizado, folha salarial, pagamento/recebimento novo, contas/políticas/operações reais, correcção industrial, reabertura, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint.
O [AGENTS.md](../AGENTS.md) delimita as aprovações contabilísticas anteriores. A [especificação aprovada da correcção directa](GENERAL_LEDGER_DIRECT_COST_CORRECTION_SCOPE.md), em «Exclusões preservadas», exclui expressamente **«salários/CIF/lote industrial»**. A autorização pedida retira apenas a exclusão de implementar/provar o lote/CIF seleccionado neste novo ensaio; todas as restantes exclusões mantêm-se.
**Decisão pedida:** autorizar o ciclo industrial sintético1800/10UN, com contas artificiais, direitos existentes apenas QA, revisão distinta, relatórios e fecho pelo mesmo núcleo, nos termos acima.
@@ -0,0 +1,15 @@
# Recuperação de comprovativos contabilísticos — 03/11/2026
Extensão posterior: [reconhecimento e liquidação de fornecedor](FINANCIAL_RECEIPT_RECOVERY_DELIVERY.md) agora consultáveis por domínio explícito na mesma área contabilística. O endpoint original e os limites desta entrega mantêm-se; não é uma consulta universal dos restantes motores.
Implementada consulta por chave UUID no mesmo núcleo: `GET /api/general-ledger/receipts/{key}`. Recupera exclusivamente comprovativos do utilizador actual, no tenant/empresa/unidade seleccionados, com direito contabilístico de consulta e identidade financeira actuais. Outro autor, âmbito diferente ou chave inexistente não revela o comprovativo. Não aceita contexto por parâmetros.
Mostra chave, data, hash do pedido e resultado original. É evidência histórica, não conferência da fonte actual nem nova execução. Usa uma fotografia `REPEATABLE READ READ ONLY`, sem bloqueios de escrita; uma consulta lê um comprovativo, sem listar o universo. Não cria efeitos, eventos ou chaves novas. A ausência na captura não prova que o comando falhou e não autoriza repetição automática.
Diário, vendas, custo directo, industrial e painel conjunto reutilizam o mesmo formulário de recuperação. Os erros dos comandos apresentam a chave do pedido. O resultado é mostrado como texto, sem HTML executável. O formulário faz apenas GET e não reenvia comandos. Não se acrescentaram direitos, migrações ou dependências.
Provas numa base/role nova55439 (`922dac0c0596`), com custo275 efectivamente capturado/revisto/aplicado pelos workflows existentes:10 verificações de serviço,16 HTTP e3 do JavaScript renderizado em Node VM. Cobrem resultado original/repetição de consulta, autor diferente, unidade estrangeira elegível, direito revogado, UUID inválido, autenticação/contexto/módulo, ausência, `no-store`, transacção de leitura, zero bloqueios, renderização e GET sem execução. Fontes/direitos/comprovativos preservados e um só lançamento. Não se repetiram suites financeiras alheias.
Manifesto: [provas e hashes](proofs/ledger-receipt-recovery-20261103.json). Demos e provas anteriores, seis associações e cache bloqueada intactas. Sem publicação, activação de Compras, operações reais, aceitação ou checkpoint. A consulta também não concede acesso a comprovativos de outro perfil para revisão: cada perfil consulta as suas próprias chaves, e a revisão partilhada conserva as páginas de evidência já existentes.
Limites: não recupera uma chave que o utilizador já não possui, não é arquivo de relatórios nem mecanismo transversal aos outros motores financeiros. Não armazena automaticamente pedidos no navegador. Uma captura anterior não valida dependências presentes. Revisão visual, carga sustentada e implantação permanecem pendentes; HTTP/Node VM não são prova visual.
+56
View File
@@ -0,0 +1,56 @@
# Balancete e razão por conta — consulta integrada de ensaio
03/11/2026. `COMMON-LEDGER-REPORTS-SIM-v1` implementado localmente sob a continuação de desenvolvimento e integração. Apenas leitura dos lançamentos existentes, reutilizando `general_ledger.view`; sem novos direitos, motores de dívida, efeitos contabilísticos ou instalações no destino. Preserva os recortes contabilísticos anteriormente autorizados.
## Resultado funcional
O núcleo comum fornece balancete de ensaio por conta e razão com saldo acumulado. Os adaptadores SGF e SGC autónomo expõem a mesma interface HTML e APIs:
- `/general-ledger/trial-balance` e `/api/general-ledger/trial-balance`;
- `/general-ledger/account-ledger` e `/api/general-ledger/account-ledger`.
Os filtros `from_on` e `to_on` são obrigatórios, datas válidas e inclusivas. O razão exige `account_id`; o balancete pode também restringir uma conta. `page` é limitado a 100.000 e `per_page` a **25 ou 100**. Parâmetros desconhecidos, datas invertidas/impossíveis e contas externas ao âmbito são recusados. Não se aceitam tenant/empresa/unidade do pedido como autoridade.
A abertura considera lançamentos anteriores ao início; débitos/créditos de movimento incluem todo o intervalo; o saldo final soma abertura e movimento. O balancete inclui contas com lançamentos até à data final, inclusive contas sem movimento no intervalo que ainda contribuem para a abertura. Contas apenas com movimentos futuros não entram. Totais são calculados sobre todo o filtro, independentemente da página. No razão, a janela SQL calcula o saldo acumulado antes da paginação, ordenando por data, lançamento, linha e ID; a segunda página não reinicia o saldo.
O balancete liga ao razão preservando datas e tamanho da página. Cada linha do razão conserva tipo de efeito, lançamento, proposta/revisão, autor, fundamento, data e versão da fonte. No SGF, origens de venda ligam ao percurso Comercial → Recebíveis → Contabilidade; as restantes fotografias continuam no diário. A consulta soma os lançamentos elegíveis das fontes compostas, sem refazer a posição financeira nem presumir nova emissão, dívida ou liquidação.
O marcador de consulta inclui âmbito, data final, número de lançamentos e último ID até essa data. Identifica o estado append-only observado e detecta também uma inserção posteriormente retrodatada. **Não é fotografia persistida, assinatura, aprovação de fecho ou relatório legal.** Os montantes usam inteiros exactos em cêntimos, agregação SQL `numeric` e recusa de valores fora da representação inteira suportada; não há aritmética monetária em float.
## Isolamento e disponibilidade
As três relações usadas nas agregações — linhas, lançamentos e contas — recebem o mesmo filtro de tenant/empresa/unidade. Não basta validar o ID da conta. Autorizações e contexto correntes são verificados antes da consulta. SGF exige Contabilidade; SGC mantém o contexto financeiro nativo e a pessoa/permissão explícitas. Direito revogado é recusado.
Cada leitura HTTP própria usa transacção **repeatable read/read only**, sem `FOR UPDATE`, para conservar coerência entre totais, página e marcador. Um livro disponível sem factos é identificado como vazio; tabela necessária ausente responde **503**, sem converter indisponibilidade em saldo zero. Esta prova identificou que o adaptador SGC não tinha essa verificação explícita para o novo relatório: foi acrescentada antes da leitura. A tentativa inicial falhou na prova, foi revertida e não é contada como aprovação; a prova corrigida passou.
Não há comandos nos formulários HTML nem exportação ilimitada. Só as linhas da página são materializadas no PHP; agregações e saldo acumulado correm na base. O balancete usa **três consultas de relatório** — totais, página e marcador — tanto com 25 como com 100 contas. Este número exclui consultas de sessão, autorização e disponibilidade do schema; não representa o total de consultas HTTP. O razão tem consultas adicionais de conta, contagem e janela; não se afirma que use apenas três.
## Provas novas e reutilizadas
[Manifesto de provas e hashes](proofs/common-ledger-reports-20261103.json). Foram criadas duas bases exclusivas para os testes dos relatórios, separadas de demonstrações e de provas anteriores. Em cada uma, um reconhecimento de fornecedor60 vem do workflow existente; outros **130 lançamentos de teste / 260 linhas contabilísticas** foram gerados explicitamente por SQL para exercitar contas, datas e paginação. Estes 130 lançamentos são dados artificiais do relatório, identificados como `report.fixture`; **não são 130 dívidas reconhecidas ou execução do writer**. Todas as constraints de equilíbrio/fotografia foram conferidas. Nenhuma obrigação, pagamento ou afectação financeira adicional foi criado por esse gerador.
| Prova | Resultado |
|---|---|
| Serviço SGF | 21 verificações: datas, abertura/movimento/fecho, paginação, saldo entre páginas, acesso revogado e preservação financeira |
| Serviço SGC | As mesmas 21 verificações sobre o adaptador nativo e núcleo único |
| HTTP SGF / SGC | 21 / 20 verificações: API e HTML, consulta autenticada, recusas, evidências, cache no-store, 25/100 e leitura coerente |
| Isolamento SGF / SGC | 8 / 8 verificações: livros estrangeiros de tenant/empresa/unidade excluídos, contas estrangeiras recusadas e fonte ausente503; tudo revertido |
| Vendas e reversão efectivas | 6 verificações só de leitura sobre a prova válida de quatro lançamentos/nove linhas; 2.400 MZN brutos por lado, contas reconciliadas |
| Cliente + fornecedor no mesmo livro | 7 verificações só de leitura, dois lançamentos/cinco linhas; 1.060 MZN por lado, IDs financeiros coincidentes sem mistura |
| Ciclo fornecedor SGC com reversão e correcção | 5 verificações só de leitura, cinco lançamentos/dez linhas; 220 MZN por lado e contas reconciliadas |
| Distribuição autónoma nova | 6 verificações, runtime e interface carregados do artefacto fixado por hash, um lançamento real do workflow e nenhuma exigência de Sales |
| Artefacto autónomo anterior | Capacidade de relatório ausente responde503; não foi substituído nem alterado |
Os três livros de integração anteriores foram observados sem repetir os percursos de escrita: os fingerprints de lançamentos, linhas, propostas, decisões, histórico e períodos disponíveis ficaram idênticos. As provas válidas anteriores são referenciadas e verificadas por hash. O único erro inicial de preparação SGF ocorreu antes de criar o reconhecimento, por carregamento tardio da variável da fixture; o helper foi corrigido e a mesma base ainda vazia foi preparada sem reset.
O artefacto QA novo contém **30 ficheiros**, manifesto `cbc4ddbea15f5e396c92bc2e664b973138551928980f334bcf3eb0cf097cacb1`. Inclui apenas runtime/README dos núcleos, sem credenciais ou fixtures. Foi ensaiado numa terceira base nativa própria. O artefacto congelado anterior de25 ficheiros, as demonstrações existentes e o pacote do destino ficam intactos. A publicação e substituição do pacote instalado não fazem parte desta entrega.
Sintaxe PHP/JavaScript, `git diff --check` dos dois repositórios e verificações dirigidas Pint/PHPStan dos dois ficheiros nativos alterados constam do manifesto. O SGF não tem Pint/PHPStan instalados. As primeiras invocações do analisador nativo falharam por configuração de autoload — símbolos comuns ausentes e depois duas cópias do Payables; a execução final usa um único caminho canónico, bootstrap explícito e cache privada, sem modificar dependências ou apagar caches.
## Utilização e limites
No SGF, abrir a demonstração existente **http://127.0.0.1:8053/qa/sales-demo**, escolher um perfil e seguir Diário e extracto → **Balancete e razão por conta — Outubro de 2026 (QA)**. No ciclo nativo canónico8051 existe a mesma ligação. O conteúdo reflecte apenas as decisões já registadas nessa demonstração; uma base ainda sem lançamentos aparece vazia e não é preenchida automaticamente. A demonstração8052 conserva o pacote congelado anterior e recusa este relatório por não ter essa capacidade composta.
As bases com os 130 lançamentos artificiais são exclusivamente de regressão e não foram transformadas em demonstrações da equipa. Não houve nova execução de suites financeiras/stock alheias, movimentos em dados reais, novos direitos, migrações no destino, limpeza da cache bloqueada, alteração dos seis registos, publicação, activação de Compras, checkpoint ou aceitação presumida.
Revisão visual, carga sustentada e capacidade de implantação continuam pendentes e separadas. Os ensaios de131 lançamentos por base verificam resultados/paginação; não substituem as medições anteriores de1.000/5.000/10.000 nem validam relatórios de grande volume. Sem índices novos, demonstrações financeiras fiscais, contas legais, saldos iniciais importados, consolidação de empresas, conversão de moedas ou contabilidade de stock/custos/salários.
@@ -0,0 +1,70 @@
# Comercial → Recebíveis → Contabilidade — entrega sintética
03/11/2026. `COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1` implementado localmente após a autorização expressa «Autorizar o ciclo sintético proposto», no [recorte apresentado](GENERAL_LEDGER_SALES_RECEIVABLE_SCOPE.md). Inclui código, interface, provas e demonstração própria. Não equivale a aceitação, checkpoint ou conclusão operacional de toda a aplicação.
## Percurso entregue
A factura vem do workflow existente de emissão de teste, incluindo série, cálculo, decisões e PDF imutável. Base **862,07**, imposto documental **137,93**, total **1.000,00 MZN**; o cálculo exacto **137,9312** e a diferença **0,0012** continuam preservados. Não foi criada outra política fiscal.
O núcleo contabilístico único recebe callbacks explícitos do host SGF. O reconhecimento tem três linhas: D `QA-CLIENTES` 1.000 / C `QA-VENDAS` 862,07 / C `QA-IMPOSTO-TESTE` 137,93. A obrigação a receber exige a aprovação financeira existente e não gera outro reconhecimento contabilístico. Recebimentos confirmados de 400 e 600 são afectados explicitamente; cada afectação é capturada, revista por identidade distinta e lançada, D `QA-LIQUIDACAO-CLIENTE` / C `QA-CLIENTES`. A reversão financeira de 400 exige a decisão existente e origina uma proposta contabilística inversa; não devolve dinheiro.
O percurso integral termina com **quatro lançamentos, nove linhas, débitos/créditos brutos de 2.400 MZN e saldo de clientes/recebíveis de 400 MZN**. O fecho QA exige conferência distinta do conjunto de factos e configuração e revalida tudo após obter a serialização da empresa. Não há reabertura.
A interface liga documento/PDF, posição financeira, propostas, decisões, afectações, reversão, impactos contabilísticos, versões, diário/extracto e fecho. As listas são paginadas no servidor em janelas de 25; decisões são lidas em conjunto para os registos visíveis. Não se recalcula a dívida num segundo motor: a posição usa o estado autoritativo de `ServiceReceivables`. As trocas Comercial/Financeiro/Contabilidade são explícitas e verificam a geração de contexto antes do comando. Uma página desactualizada não se autoactualiza para aprovar no contexto de outra aba. Falhas na obtenção do PDF e recusas financeiras devolvem o contexto corrente à Contabilidade.
## Integridade e composição
`SalesSchema` só instala numa base QA SGF nova, com livro vazio. O writer anterior foi alargado, não copiado. As constraints diferidas conferem a quantidade de linhas, contas, montantes e fotografia exacta revista; o trigger de inserção de linhas recusa inclusive uma terceira linha acrescentada posteriormente a um lançamento de duas linhas. A quarta linha é recusada. Originais e decisões permanecem append-only.
As confirmações repetidas revalidam permissões/contexto antes de recuperar o resultado; versões/fontes/regras/períodos alterados exigem nova conferência. Recibos de idempotência, lançamento, linhas e histórico pertencem à mesma transacção. As falhas injectadas nas quatro fronteiras de escrita foram revertidas e a repetição recuperou sem duplicar.
Foi encontrado e corrigido um caso de integração: a obrigação de cliente e a de fornecedor podem ter o mesmo ID inteiro em tabelas diferentes. O filtro antigo `obligation_id` ficou expressamente limitado a efeitos de fornecedor; `sales_invoice_id` identifica a origem comercial. A prova mista mantém um lançamento de cada tipo no mesmo livro e confere ambos no fecho, sem misturar eventos ou totais. A origem de fornecedor desta prova é uma recepção sintética controlada; não demonstra importação histórica SGF viva ou composição completa da interface de Compras.
O SGC autónomo continua a funcionar sem Sales e recusa explicitamente estas origens quando não estão compostas. O artefacto financeiro congelado da entrega anterior e a demonstração 8052 não foram substituídos. As instalações/demos anteriores não receberam o novo schema.
## Guardas e direitos
A opção `SGF_SALES_LEDGER_QA` fica desligada na ausência de configuração. A extensão aceita apenas `testing`/`browser-testing`, opção explícita, PostgreSQL loopback **55439**, cluster de regressão esperado, base `payable_sgf_<12 hex>` e role não elevada `payable_history_sgf_<mesmo identificador>`, com destino efectivo conferido. A regra antiga de `sgf_tempo_test` não foi retirada. Os instaladores existem apenas no harness explícito; o arranque não aplica migrações.
Foram reutilizados direitos existentes, exclusivamente em novas fixtures:
| Perfil QA | Contabilidade | Recebíveis |
|---|---|---|
| Preparação | view, prepare, post, period_prepare, period_close | view, prepare, allocate, correction_request |
| Validação | view, validate, period_validate | view, confirm, correction_approve |
| Consulta | view | view |
São **sete direitos contabilísticos distintos e nove concessões contabilísticas por fixture**, sem os direitos de correcção técnica desnecessários neste ciclo. Os três perfis têm consulta da factura de teste e contexto empresa/unidade. A emissão inicial reutiliza o workflow e identidades fiscais da fixture; a revisão contabilística não concede competência fiscal. As permissões auxiliares do construtor de fixtures foram removidas antes de entregar a nova demonstração. Nenhuma concessão real ou pública foi alterada.
O isolamento aplica-se a tenant, empresa, unidade e associações de módulo. Uma origem não partilhada não aparece no selector nem pode ser capturada pelo ID. Continua a bloquear o fecho se estiver por contabilizar: esconder uma origem da interface não a transforma num período completo.
## Provas
[Manifesto com hashes e resultados](proofs/common-ledger-sales-receivable-20261103.json). As bases de prova e a demonstração da equipa são distintas. Não foram repetidas suites alheias ao incremento.
| Evidência | Resultado |
|---|---|
| HTTP real, CSRF e ciclo completo | 95 verificações; recusas 419/403/409/422, impactos, versões, repetição, quatro lançamentos/nove linhas e fecho |
| Regressão de fonte, regra, período, guardas e recuperação | 3 cenários / 29 verificações; alterações revertidas |
| Schema final e isolamento | 12 verificações; tenant/empresa/unidade, direitos revogados, terceira linha tardia, quarta linha e paginação do histórico |
| Associações por módulo | 4 verificações, incluindo completude do período; alterações revertidas |
| Ciclo completo no schema final | 10 verificações, reconciliação final de documento/PDF/contas e leitura HTTP adicional de 10 verificações |
| Concorrência | 6 processos reais; detalhes abaixo |
| Compatibilidade fornecedor SGF / ciclo SGC | 74 asserções / 23 verificações; SGC também recusa Sales sem capacidade composta |
| Coexistência cliente/fornecedor | IDs coincidentes, filtros/eventos/totais separados, dois lançamentos/cinco linhas e fecho conjunto |
| Demonstração nova | 29 verificações HTTP, três perfis, zero efeitos financeiros/contabilísticos iniciais |
| Formulários renderizados | 10 verificações em Node VM: tipos exactos, chaves estáveis, recusa de geração antiga e recuperação de contexto/PDF |
Na concorrência, dois aplicadores aguardaram a transacção que detinha o bloqueio da empresa; PostgreSQL identificou esperas `tuple`/`transactionid` e a cadeia até ao processo titular. Uma consulta terminou antes da libertação, vendo apenas o estado confirmado. Após rollback do titular, ambos terminaram com **o mesmo lançamento de três linhas**, sem duplicação. Outra confirmação de fecho aguardou uma afectação de 400 que foi efectivamente confirmada pelo titular: respondeu **409**, exigindo nova conferência. Depois de contabilizar a afectação e rever nova fotografia, dois fechos concorrentes devolveram **o mesmo fecho e a mesma versão 2**. Este cenário termina em saldo **600**, dois lançamentos/cinco linhas, e é distinto do percurso integral que termina em 400. Uma verificação posterior, só de leitura, comparou explicitamente a factura actual com `document_revision` nos dois lançamentos e confirmou o hash do PDF.
As provas de 95 verificações HTTP e concorrência precedem o reforço SQL da inserção tardia; continuam válidas para o comportamento nelas medido. O novo schema foi comprovado separadamente pelo ciclo integral, provas SQL/isolamento, coexistência e leitura HTTP final. Uma tentativa inicial de prova de contexto estrangeiro falhou no helper de dados e foi revertida; não é contada como prova aprovada. A prova corrigida usa outra base. O campo histórico `default_sales_flag` da primeira reconciliação final representa a configuração QA activa; a prova específica `sales-defaults.json` verifica separadamente o default ausente **false**.
Os formulários foram exercitados com DOM/rede simulados sobre o JavaScript efectivamente renderizado. Isto não é execução num navegador nem revisão visual. A sintaxe PHP/JavaScript afectada e `git diff --check` são registados no manifesto. PHPStan/Pint não estão instalados neste SGF; não se declaram essas ferramentas executadas nem se acrescentaram dependências.
## Demonstração e limites
Entrada: **http://127.0.0.1:8053/qa/sales-demo**. Escolher Preparação, Validação ou Consulta; o guião permite abrir várias sessões/perfis e percorrer o circuito. Base, sessão, assets e cookie próprios, só loopback. Começa com uma factura realmente emitida pelo workflow de teste e **sem obrigações, recebimentos, afectações ou lançamentos**, para a equipa decidir individualmente. Não usar a demonstração como fixture descartável nem reiniciar decisões da equipa.
Continuam pendentes e separados: revisão visual, carga sustentada e capacidade de implantação; classificação/contas/fiscalidade reais, desenho de permissões SQL operacional e composição operacional entre módulos. Não foram medidos novos volumes de 1.000/5.000/10.000 para este ciclo, criados índices ou declarada capacidade a partir destes ensaios. As provas de carga anteriores permanecem preservadas com o seu âmbito original.
Não foram feitos uso real, migração no destino, publicação, push, activação de Compras, reabertura, lançamentos de stock/custos/salários ou alterações de direitos reais. Os seis registos sem associação, a cache bloqueada, os checkpoints anteriores, as demonstrações anteriores e o trabalho alheio ficaram fora das escritas deste incremento. Não se criou checkpoint nem se presumiu aceitação.
@@ -0,0 +1,19 @@
# Próximo ciclo contabilístico — vendas e recebimentos sintéticos
**Autorização expressa recebida: «Autorizar o ciclo sintético proposto». Implementação e provas concluídas no recorte sintético; ver [entrega e limites](GENERAL_LEDGER_SALES_RECEIVABLE_DELIVERY.md).** O texto abaixo conserva o recorte apresentado para completar Comercial → Financeiro → Contabilidade, usando o mesmo writer e preservando as aprovações anteriores. A decisão pedida no final é histórica, não uma nova pendência de autorização.
`COMMON-LEDGER-SALES-RECEIVABLE-SIM-v1`, proposto como uma entrega funcional conjunta:
1. Criar nova composição SGF de regressão e outra de demonstração, no cluster55439, com roles não elevadas próprias. Reutilizar os workflows de factura/recebíveis de teste, preservando séries, versões, cálculo e revisões. Ampliar a guarda QA apenas para esse destino efectivo isolado, sem retirar qualquer bloqueio real nem usar/resetar `sgf_tempo_test` ou demos existentes.
2. Reutilizar o cenário de `ReceivablesFixture`: base862,07 + imposto documental de teste137,93 = **1000,00MZN**, vindo do cálculo já aprovado `862,07×16%=137,9312`, truncagem documental e diferença preservadas. Emitir pelo workflow de teste existente, com aviso sem validade fiscal; não fabricar factura ou aprovação viaSQL. Isto não aprova taxa/imposto/contas reais.
3. Um reconhecimento contabilístico por factura de teste emitida, com revisão distinta: **D `QA-CLIENTES`1000 / C `QA-VENDAS`862,07 / C `QA-IMPOSTO-TESTE`137,93**. Generalizar o writer/constraints para três linhas sem duplicar o motor e mantendo o ciclo de fornecedores. A abertura da obrigação a receber não cria um segundo reconhecimento da mesma venda.
4. Reutilizar o núcleo de recebíveis existente: recebimentos sintéticos400 e600, confirmação independente e afectações explícitas; saldos600 e0. Só afectação efectivamente confirmada origina contabilização, respectivamente **D `QA-LIQUIDACAO-CLIENTE`400/600 / C `QA-CLIENTES`400/600**. Recebimento não afectado não liquida nem lança automaticamente contra a dívida.
5. Reutilizar o circuito aprovado de reversão de afectação400: decisão independente, originais imutáveis, saldo volta a400. Contabilização da reversão: **D `QA-CLIENTES`400 / C `QA-LIQUIDACAO-CLIENTE`400**. Sem devolver dinheiro, nota de crédito, anulação fiscal ou segundo reconhecimento comercial.
6. Ligar fonte, versões, decisões, impactos, lançamentos/extracto e posição financeira na interface, com os mesmos mecanismos de identidade/contexto e idempotência. O fecho QA só pode incluir estes novos efeitos depois de os conferir, com hash conjunto e recusa de pendências/alterações. Sem reabertura. O SGC autónomo continua sem exigir Sales; recusa essas origens se a capacidade não estiver composta.
7. Provar o percurso completo, montantes exactos, isolamento, revisões, direitos revogados, concorrência, falhas/recuperação e ausência de duplicação. Reutilizar provas anteriores; executar só regressão afectada pela generalização de linhas/fontes. Demonstração nova, sem consumir os dados da equipa nos testes destrutivos.
Direitos apenas nas fixtures novas: reutilizar os dez `general_ledger.*` já aprovados e os direitos existentes de emissão de teste/recebíveis, distribuídos entre preparador, validador fiscal, revisor e consulta conforme os circuitos existentes. **Não se propõem direitos públicos/reais novos**, herança de Administração ou pessoas reais obrigatórias. Matriz da fixture explícita antes de qualquer concessão QA; nenhuma competência fiscal é inferida da contabilidade.
Excluídos: stock/custos/salários contabilizados, novas políticas ou contas reais, emissão fiscal real, uso empresarial, ligação bancária/execução monetária, reabertura, alteração dos seis registos/cache/demos, instalação no destino, activação de Compras, publicação, aceitação ou checkpoint. Revisão visual e capacidade de implantação continuam separadas.
**Decisão pedida:** autorizar este ciclo sintético agrupado, incluindo os novos efeitos de venda/afectação/reversão e a adaptação do mesmo writer para três linhas. A autorização anterior fica preservada, com o seu âmbito; a aceitação do resultado será posterior.
@@ -0,0 +1,71 @@
# Stock físico → valorização → diário QA
**Continuação técnica:** [conferência de dependências anteriores em lotes](STOCK_PREFIX_STREAM_DELIVERY.md),48 verificações e comparação de1.000/5.000/10.000 referências de metadados. A limitação de materialização descrita historicamente no fim deste documento foi tratada na fonte; os pacotes das demonstrações permanecem congelados e a capacidade de implantação continua pendente.
03/11/2026. `COMMON-LEDGER-STOCK-VALUATION-SIM-v1`, autorizado expressamente no [recorte](GENERAL_LEDGER_STOCK_VALUATION_SCOPE.md). **Código funcional, integração, provas e demonstração local entregues.** Nenhuma instalação, política, conta ou operação real foi activada.
## Resultado demonstrado
| Origem efectiva | Quantidade | Valorização a50MZN/UN | Lançamento artificial |
| --- | ---: | ---: | --- |
| Recepção aceite |2UN|100MZN|D QA-STOCK / C QA-COMPENSACAO-STOCK|
| Saída interna autorizada/entregue |−1UN|50MZN|D QA-CONSUMO-STOCK / C QA-STOCK|
| Excedente declarado e recebido distintamente |+0,5UN|25MZN|D QA-STOCK / C QA-CONSUMO-STOCK|
| Final |1,5UN|75MZN de stock|Consumo líquido25MZN|
Três lançamentos/seis linhas; débitos/créditos brutos175MZN. Balancete, razão e fecho QA usam o mesmo núcleo contabilístico anterior. O writer contabilístico não volta a mover quantidades, reconhecer factura/obrigação, consumir orçamento ou criar pagamento.
Recepção, requisição de armazém, submissão, autorização, entrega, reconhecimento de recepção pelo destinatário, declaração de excedente e recepção independente da devolução foram executados pelos serviços efectivos de Compras. Os writers físicos comuns e o seu livro original foram reutilizados. A ordem já emitida e os cadastros anteriores são fixture; **não se repetiu nem se declarou provado todo o procurement anterior à ordem**. A taxa50 é uma decisão de ensaio distinta, não deduzida automaticamente do preço comercial da fixture.
## Integração e controlos
`StockSchema`, `StockValuation` e `StockComposition` acrescentam origens tipadas ao mesmo `PayableJournal`; não criam um motor de lançamentos alternativo. O adaptador explícito QA usa a mesma transacção/base e o mesmo bloqueio da empresa que os serviços físicos. Revalida identidade, função organizacional, pessoa e direitos antes de comandos e repetições. O cenário utiliza os direitos contabilísticos existentes, funções físicas existentes e permissões de leitura apenas nas fixtures novas.
A taxa tem proposta/versionamento e revisão imutáveis por pessoa distinta. Uma sucessora pendente ou ausente torna o valor incompleto; uma sucessora com o mesmo montante invalida a conferência anterior. Alterar a taxa após lançamento é recusado neste recorte. Chave repetida com conteúdo diferente recusa; repetição exacta devolve o comprovativo, sem segundo efeito.
A captura conserva artigo, armazém, unidade, movimento, taxa/revisão, documentos/operadores físicos, saída original da devolução, versões e hashes. A aplicação relê esses factos. **Os movimentos físicos anteriores do livro têm de estar contabilizados primeiro**: não se valoriza saída antes da entrada ou devolução antes da saída. As dependências contabilísticas são identificadas, não lançadas automaticamente. O serviço e a guarda SQL impõem a sequência.
O SQL impede duplicação do movimento mesmo com outra chave de efeito, revista a ligação entre origem/taxa/quantidade/contas e conserva as verificações de equilíbrio/revisão distintas existentes. Foi recusada uma fonte PHP adulterada com um cêntimo adicional e outra com valor correcto mas entrada contabilística em falta. Não se alteraram triggers do livro físico.
## O que o SGF faz nesta entrega
`StockValuationRead` liga explicitamente o contexto SGF ao livro nativo autorizado com uma role SQL SELECT-only. Exige **stock.view e general_ledger.view** na unidade actual, módulo Contabilidade, token actual e identidade física QA. Fontes ausentes/incompatíveis devolvem indisponibilidade, sem inventar zero. Quantidades, lançamentos e linhas são lidos numa captura REPEATABLE READ READ ONLY, com paginação no servidor.
O SGF mostra o valor contabilizado e os movimentos ainda não valorizados, sem copiar um livro ou saldo para a sua base. A navegação só apresenta a ligação quando a composição e os dois direitos estão disponíveis. O mapeamento de tenant/empresa/unidade é explícito: o âmbito nativo0/0 não é equiparado numericamente à empresa/unidade SGF.
**Fronteira explícita:** a confirmação da valorização e a conferência completa das dependências/período correm na composição QA de origem. O SGF é observador autorizado; a sua reconciliação cobre movimentos com lançamento e IDs de taxa, não uma segunda validação independente de todas as dependências actuais. A interface SGF de preparação/confirmação de recepção permanece pendente. Não declarar Compras integralmente instalado no ERP.
## Demonstração preservada para avaliação
- [Composição de origem — preparar, rever e consultar](http://127.0.0.1:8062/qa/stock).
- [SGF — consultar o mesmo livro](http://127.0.0.1:8063/qa/stock-observer/reader?open=1).
Dados físicos iniciais2/−1/+0,5; **zero taxas propostas, zero lançamentos e zero fechos iniciais**. A equipa pode percorrer o ciclo por identidades distintas. A consulta inicial SGF diz «pendente», com três movimentos por valorizar; o valor contabilizado0 não é apresentado como valorização actual do stock.
Pacote comum/adaptador congelados em artefacto privado, manifesto SHA256 `8b3ad1c600b84c8526b2d9f453a1c5c4e174a69d6acc78ace2af4f52a25dd762`. O contrato SQL instalado e a classe de origem coincidem entre a regressão, o ensaio HTTP e a demonstração. Assets SGF compilados numa pasta pública privada desta composição; sem substituir o build ou a cache da instalação principal.
As entradas8060/8061 foram uma preparação anterior desta passagem e permanecem preservadas com o seu pacote; **não são a demonstração final da sequência reforçada**. Usar8062/8063.8059,8039 e anteriores não foram reiniciadas ou reconfiguradas. As decisões iniciais da demonstração final foram conferidas por leitura antes/depois das verificações; não houve reset para a apresentar.
## Provas e limitações
[Manifesto de resultados/fontes/hashes](proofs/common-ledger-stock-valuation-20261103.json): **146 verificações dirigidas**, mais **23 verificações da regressão nativa afectada** pelo núcleo comum. São contagens dos auxiliares aprovados; não constituem169 casos independentes de negócio.
| Prova seleccionada | Verificações | Condição |
| --- | ---: | --- |
| Taxas, origens, versões, sequência e recuperação |31|Casos com rollback e preservação de físico/financeiro/direitos|
| Concorrência |3 + dois trabalhadores|Bloqueador real observado; libertação com repetição200 ou versão de período antiga409, sem duplicação|
| Ciclo/relatórios/fecho |4|Três lançamentos/seis linhas; stock75, consumo25, um fecho|
| Isolamento/pessoa e SQL adversarial |9|Empresa/unidade/tenant, ambos os direitos na mesma pessoa, chave de efeito forjada e montante adulterado|
| SQL da sequência |3|Fonte PHP adulterada não contorna a falta de entrada contabilística|
| Percurso HTTP completo |40|Taxa→revisão→três lançamentos→relatórios→fecho, CSRF, chaves e comprovativos próprios|
| Observação SGF |23 +4|Isolamento, revogação, configuração, origem SELECT-only e alteração de unidade recusada/restaurada|
| JavaScript/React |15 +5|Incerteza sem repetição automática, resposta atrasada descartada, navegação por composição/direitos; sem navegador|
| Demonstração final |9|Leitura, pacote fixado, assets privados e decisões iniciais intactas|
As tentativas de preparação incompletas ficaram preservadas em bases próprias. Corrigiram-se auxiliares de caminho de cache Windows, definição de UN antes da emissão da fixture, normalização da quantidade da devolução e contexto/pertença Financeiro explícitos. Nenhuma guarda foi retirada para os fazer passar. A primeira passagem funcional foi reforçada depois da revisão de dependências; só as provas finais da sequência reforçada são seleccionadas no manifesto. Não somar tentativas anteriores ou suites sobrepostas. A regressão anterior foi reutilizada; repetiu-se apenas o ciclo nativo afectado e as provas que a alteração de sequência mudou.
A taxa fixa de ensaio não valida método de valorização real, fiscalidade, política de custos ou margens. O cenário é exclusivo: **não compõe o mesmo material com custos directos/industriais já lançados**; essa integração continua separada. Não há correcção de valor lançado, reabertura, salário, banco ou pagamento novo. As roles QA são proprietárias das bases novas; não se valida separação owner/runtime de implantação. Paginação foi exercitada funcionalmente, sem ensaio de carga sustentada/throughput.
A conferência de uma origem ainda materializa as referências contabilísticas anteriores do livro. A página exterior25/100 não limita esse prefixo: a memória e o custo podem crescer com o histórico. O recorte provado contém três movimentos; **não se apresenta esta implementação como solução de carga para livros extensos**. A melhoria mínima futura é obter a cobertura anterior por SQL e calcular a evidência por leitura em sequência, evitando carregar todos os IDs/snapshots anteriores. Isto é uma limitação distinta da fila administrativa, cuja leitura conjunta já está implementada e medida.
Revisão visual e capacidade de implantação permanecem pendentes. Seis registos protegidos, cache bloqueada, provas válidas, configurações/direitos reais e trabalho alheio preservados. Sem publicação, instalação no destino, activação de Compras, aceitação ou checkpoint presumidos.
@@ -0,0 +1,33 @@
# Proposta — stock físico valorizado no diário sintético
`COMMON-LEDGER-STOCK-VALUATION-SIM-v1`,03/11/2026. **Autorizado expressamente pelo utilizador: «Autorizar o ciclo sintético proposto».** A autorização retira apenas a exclusão deste desenvolvimento/provas delimitados, sem activar dados, políticas ou instalações reais. Implementação/provas documentadas separadamente; a autorização não constitui aceitação.
## Recorte agrupado proposto
Uma base QA nova55439, uma empresa/unidade, artigo e armazém artificiais; sem reutilizar origens das demonstrações existentes. Reutilizar recepção/saída/devolução efectivas e os writers físicos existentes, com confirmação explícita, identidades e referências mapeadas na fixture. Não construir saldos físicos por SQL para fingir execução.
Valorização exclusivamente sintética a custo de50 MZN/UN, proposto e validado por identidades distintas. Não é escolha de método contabilístico ou política fiscal da Tempo. Movimentos seleccionados: entrada2UN=100; saída1UN=50; devolução da mesma saída0,5UN=25. Estado final1,5UN/75MZN. Cada origem física só pode ter um efeito de valorização reconhecido; as quantidades físicas não são novamente movimentadas pelo diário.
No mesmo núcleo contabilístico, com propostas e revisão distintas antes de aplicar:
| Efeito | Débito artificial | Crédito artificial | MZN |
| --- | --- | --- | ---: |
| Entrada aceite2UN | QA-STOCK | QA-COMPENSACAO-STOCK |100|
| Saída1UN | QA-CONSUMO-STOCK | QA-STOCK |50|
| Devolução0,5UN, limitada à saída original | QA-STOCK | QA-CONSUMO-STOCK |25|
Diário, balancete, razão e conferência/fecho do período QA no núcleo existente: três lançamentos/seis linhas, débitos/créditos brutos175; saldo de stock75 e consumo líquido25. Correcções da taxa, movimentos após a fotografia, ausência de taxa ou quantidades incompatíveis invalidam a conferência; não se fabrica total, se adopta um movimento ambíguo ou se fecha período com pendência relevante.
Não compor estes acontecimentos com os custos directos/industriais já lançados. A origem tem de ser exclusiva e recusar reconhecimento por outro percurso que repetisse o mesmo material. O cenário não capitaliza custo directo/industrial nem volta a reconhecer factura/obrigação/pagamento. A integração futura de custos com materiais já contabilizados exige tratamento explícito dessa fronteira; não será declarada entregue com esta prova.
## Acessos e testes
Reutilizar direitos físicos já existentes dos adaptadores e `stock.view` conforme os workflows efectivos; direitos contabilísticos existentes `general_ledger.view/prepare/post` para preparador, `.view/validate` para revisor distinto e `.view` para leitor. Fecho QA usa os direitos existentes `.period_prepare/period_close` e `.period_validate`. Todas as concessões apenas em fixtures novas, apresentadas antes da execução; sem direito público/real novo ou concessão a pessoas reais.
Provar os workflows efectivos, montantes exactos, versões/dependências, unidade/empresa, revogação, origem reutilizada, separação de preparador/revisor, repetição/concorrência sem duplicação e falha/recuperação transaccional. Reutilizar provas válidas dos writers e dos ciclos anteriores; repetir apenas o que a extensão afectar. Sem inferir carga sustentada, revisão visual ou capacidade de implantação.
## Preservação e exclusões
Preservar8039,8059 e todas as demonstrações, seis registos, cache bloqueada e alterações alheias. Nenhuma alteração de dados/configuração/direitos reais, migração no destino, activação de Compras, publicação, aceitação ou checkpoint. Sem reabertura, correcção de valor já lançado, fiscalidade real, folha salarial, método de valorização real, novos pagamentos ou ligação bancária. A interface SGF de recepção permanece um trabalho técnico separado; este ciclo não declara a instalação completa de Compras.
Autorizar este recorte retiraria apenas a exclusão do desenvolvimento/provas de valorização dos movimentos sintéticos seleccionados, mantendo todas as exclusões acima.
+51
View File
@@ -0,0 +1,51 @@
# Localização e segmentação nos cadastros SGF
03/11/2026. Implementados catálogo territorial paginado, selector comum acessível a partir dos cadastros de empresa/unidade/cliente/fornecedor, confirmação individual, histórico e filtros de segmentação. O catálogo usa a cartografia fornecida, preservando20.407 linhas e a versão exacta já preparada. Os16 testes de preparação foram reutilizados.
## Experimentar na composição nova8069
- [Empresa](http://127.0.0.1:8069/qa/enter/actor?open=1&geography=company).
- [Unidade](http://127.0.0.1:8069/qa/enter/actor?open=1&geography=unit).
- [Cliente sintético associado ao Comercial](http://127.0.0.1:8069/qa/enter/actor?module=commercial&open=1&geography=client).
- [Fornecedores](http://127.0.0.1:8069/qa/enter/actor?open=1): abrir uma ficha e seguir «Localização para segmentação».
- [Consulta de empresa sem edição](http://127.0.0.1:8069/qa/enter/validator?open=1&geography=company).
**A demonstração começa sem localizações atribuídas, decisões ou histórico geográfico.** Tem um cliente QA criado pelo workflow original e associado explicitamente ao Comercial, outro cliente QA sem associação para recusa de acesso e os fornecedores preparados na fixture. A cartografia é referência comum; não é uma associação empresarial. As entradas de perfil são exclusivas do launcher privado em loopback. As demonstrações anteriores, incluindo8068, não foram reconfiguradas.
Escolher província e, quando conhecido, aprofundar distrito/posto/localidade/bairro/aldeia ou quarteirão. Também existe pesquisa por designação. A selecção termina no nível efectivamente conhecido; não se exige chegar à última subdivisão. Registos ambíguos, sem designação ou com ascendência incompleta permanecem visíveis, com motivo de impedimento. Não há escolha inicial nem deduplicação automática.
Depois da escolha, indicar fundamento, conferir o estado anterior/posterior e confirmar. A decisão explícita «deixar localização por indicar» retira a indicação actual, preservando o histórico. O selector não transforma uma caixa vazia numa decisão de remoção.
## O que fica guardado
Localização **para segmentação**, separada do endereço canónico, fiscal ou de entrega. Uma selecção contém paísMZ, versão da fonte, ID original, chave versionada, precisão, designação e componentes textuais intactos. Não atribui validade fiscal, não altera a revisão financeira dos fornecedores nem propaga localizações para ordens, facturas ou registos dependentes.
Empresa e cliente têm a abrangência já existente no cadastro da empresa; o cliente exige a associação explícita ao Comercial. Unidade exige a unidade seleccionada. Fornecedor exige a origem nativa, empresa/unidade e ligação actualmente configuradas. Não se infere partilha entre unidades ou fornecedores de bases diferentes. Localização nunca concede direitos sobre a entidade.
São reutilizados `company.view/edit`, `unit.view/edit`, `clients.view/edit` e `purchasing_suppliers.view/prepare`, juntamente com as funções e guardas originais de Compras. Não há novo direito público. As concessões necessárias aos perfis de ensaio existem só nas novas fixtures; as contas reais não foram alteradas.
A prévia não escreve localização, histórico ou recibo. A confirmação verifica de novo permissão, identidade, geração do contexto, estado/versão do cadastro, revisão da localização, versão activa da cartografia e ligação de origem. Alterações exigem nova conferência. A localização actual, histórico anterior/posterior e recibo idempotente ficam na mesma transacção do host. O fornecedor é conferido sob os bloqueios do runtime administrativo original, sem escrita de negócio na base nativa; não se criou uma transacção distribuída com dois efeitos.
Uma chave/conteúdo já confirmado recupera o resultado; mudar o conteúdo é recusado. O histórico inclui autor, data e fundamento e é append-only. A falha no histórico reverte também a localização e o recibo. Dois editores com a mesma revisão são serializados: um confirma e o segundo recebe409 para conferir de novo.
## Filtros e paginação
O catálogo é importado uma vez na preparação da fixture; as consultas HTTP não carregam as20.407 linhas em memória. Pesquisa e cascata usam páginas de25 no servidor. O histórico usa páginas de15.
No selector existe ligação para consultar as localizações desse tipo de entidade. O relatório filtra pelos componentes do território escolhido, respeitando a precisão registada. Mostra **localizações explicitamente indicadas na versão corrente**; não representa a quantidade total de clientes/fornecedores, nem preenche dados ausentes com zero. Paginação por cursor de25, com próxima página quando existir. Na ligação de fornecedores, os nomes e a visibilidade nativa são conferidos em lotes limitados a100 candidatos; não se copia o universo de fornecedores para o host ou para a memória.
## Provas e preservação
[Consolidação técnica](proofs/geography-composition-20261103.json): **83 verificações** —41 de integração, cinco de paginação/visibilidade,12 de guardas/isolamento modular, oito de links React,14 da demonstração e três de build/assets. Incluem gravação nos quatro cadastros, contexto antigo, cliente sem associação, empresa/unidade/fornecedor fora do âmbito, direito revogado, catálogo/cadastro alterados, remoção explícita, recusa de ambiguidade, concorrência201/409, repetição sem duplicação, falha de histórico e recuperação, imutabilidade e preservação dos dados nativos do fornecedor.
As cinco provas de paginação usam26 localizações preparadas como fixtures de leitura, além da localização criada pela API: páginas25+2 sem sobreposição. Uma fixture negativa demonstra que ter metadados geográficos não ultrapassa a ausência de associação do cliente ao módulo; foi retirada depois da prova. Não se apresentam essas fixtures como decisões realizadas pela interface. As41 provas válidas não foram repetidas; a adição do campo de resposta paísMZ foi conferida nas14 provas da demonstração.
A nova fixture reutiliza exactamente os pacotes nativos/económicos/ciclo de requisição já fixados e testados, preservando as alterações simultâneas no repositório de Compras. A criação de tabelas e os índices geográficos ocorreram apenas em bases novas do cluster QA55439. O schema A original foi reutilizado numa cópia privada com adaptação transitória da restrição de concessões para conservar os direitos já próprios da fixture; a restrição final foi validada, sem mudar a migração pública.
Não houve ligação ao destino, atribuição de localizações a dados reais, alteração dos seis registos protegidos, limpeza da cache bloqueada, mudança do catálogo público de direitos, activação de Compras, publicação ou checkpoint. Código, manifests e provas anteriores foram conferidos por hash; credenciais e carga de referência permanecem no directório privado.
## Limites separados
Continua por implementar o circuito administrativo de correcção/publicação de novas versões da cartografia, eventual migração conferida de localizações históricas e catálogos de outros países. A autoridade INE é a declaração da fonte fornecida pelo utilizador; não se declara vigência nacional independentemente verificada. As pendências de conteúdo permanecem intactas e impedidas de selecção.
Não se substituíram endereços fiscais/de entrega nem relatórios financeiros existentes. A segmentação entregue é uma consulta própria das localizações. Revisão visual, carga sustentada, separação de privilégios proprietário/runtime e capacidade de implantação continuam pendentes. As provas HTTP/build não resolvem essas pendências.
+98
View File
@@ -0,0 +1,98 @@
# Conferência da matriz, código e provas — 03/11/2026
**Conclusão funcional de Compras em04/11/2026:** [C01–C10 e prova na única instalação8085](PURCHASING_COMPLETION.md), entrada `/compras`. O percurso acordado de aquisição até obrigação foi executado pelos formulários reais, com um fornecedor novo revisto, aprovações distintas e efeitos canónicos únicos.63 verificações dirigidas, sem novas demonstrações/bases/portas nem repetição dos comandos já concluídos. A navegação guarda o documento para a troca de função e apresenta prévias legíveis. Orçamento do host e concessões mantêm os valores exactos do dump prévio; writers e políticas existentes intactos. Isto fecha o percurso local acordado, sem declarar todas as capacidades do produto autónomo, pagamentos/contabilidade desta origem, visual ou implantação real concluídos.
**Relatório físico por período em Stock:** [abertura/entradas/saídas/fecho por artigo/armazém](SGF_STOCK_PERIOD_REPORT.md),52 verificações e demo8085. Fecha parte do submenu de relatórios antes informativo, usando o livro actual, registo UTC explícito, filtros/páginas25/100 e revisão/contexto preservados. SQL numeric/texto e BigInt mantêm exactidão; não há total entre unidades ou disponibilidade/valorização presumidas.105 pares são fixtures SQL, sem novo workflow ou efeito financeiro alegado. Cinco SELECTs na projecção para25/100; sem índice ou validação de carga/implantação. Exportação, outros relatórios, unidades/metadados históricos e convenção temporal da implantação continuam pendentes; navegação/writers/guardas existentes preservados.
**Stock → evidência da recepção:** [rastreio autorizado da origem física](SGF_STOCK_RECEIPT_TRACE.md),46 verificações e demo8084. Fecha a ausência de passagem documental a partir de entradas do writer actual: selecção explícita de Compras, resolução servidor de movimento/linha/recepção/ordem e abertura exacta do histórico, sem IDs parentais confiados ao browser. Preserva leitor SELECT-only, writer, runtime e guardas de navegação; direitos existentes revalidados.31 tabelas host/111 nativas e sequências intactas na observação final;14 verificações válidas reutilizadas após corrigir o harness. Sem inferência de histórico/outros tipos, efeito físico/financeiro novo, visual/carga/instalação/implantação validados.
**Compras e Stock na mesma composição QA:** [adaptador de leitura do livro efectivo](SGF_STOCK_COMPOSED_SOURCE.md),41 verificações e demo8083. Fecha a ausência de origem autorizada no Stock desta composição; recepção nativa e consulta reutilizam o mesmo movimento. Sem saldos copiados, mutex nativo no leitor ou escritor novo. Papel técnico limitado a três tabelas; empresa/unidade/pessoa/armazéns e contexto/revisão explícitos. UI e navegação existentes preservadas; instalação operacional, disponibilidade/valorização adicional, visual e carga sustentada continuam pendentes. Não tratar este recorte QA como implantação validada.
**Decisão da obrigação preservada durante consulta:** [área de evidência independente](SGF_OBLIGATION_EVIDENCE_WORKSPACE.md),26 verificações novas e demo8082. Corrige perda de fundamento/prévia ao paginar. Selecção individual, sem reactivar formulário desactualizado quando chegam respostas antigas; origem/actor/permissões observados diferentes pedem nova conferência. Controller/runtime/kernel financeiros iguais;72 provas nativas preservadas e não repetidas. Testes de decisão são DOM simulado, sem nova confirmação financeira real, visual, carga ou implantação presumidas.
**Conferência quantitativa visível antes da decisão:** [projecção por linha da factura](SGF_INVOICE_MATCHING_PROGRESS.md),47 verificações e demo8081. Aceite/correcções/validado/pendente/restante, sem reserva implícita por pendentes ou escolha automática. Documento70 continua pendente após40+60 validados; nova leitura não o elimina ou aprova. Revisão equivalente ao estado original observado uma vez; filtro de acesso e writers preservados. Dois livros reutilizados sem comandos, tabelas/sequências intactas. A revisão completa ainda carrega dependências anteriores; não se declara escala, memória, paginação integral, visual ou implantação resolvidas.
**Histórico detalhado da requisição além25:** [paginação por cursor](SGF_REQUEST_HISTORY_DELIVERY.md),36 verificações e demo8080. Blocos25/100, filtro de etapa antes da paginação, cursor cifrado e vinculado ao contexto, sem total global. Respostas tardias e actualização não apagam o formulário.17 verificações válidas reutilizadas ao corrigir a asserção de preservação da ordem anterior do harness; volume de notas QA não é versão operacional. Não altera escritor ou permissões, nem declara snapshot MVCC, revisão visual, carga sustentada ou implantação.
**Recepção e isolamento exercitados pela interface:** [continuidade utilizável](SGF_PURCHASING_USABLE_CONTINUITY.md),55 verificações e demo8079. Prova DOM→HTTP real identificou/corrigiu o selector simples usado como pesquisável e a rejeição de `search=` pelo middleware/validação. Uma recepção1UN, prévia e recuperação201/200 sem duplicação. Completa atalhos recepção/factura/obrigação; cada API conserva a autoridade. Corrige também a via alternativa de leitura de payloads financeiros nos históricos de requisição/propostas; auditoria original preservada e filtro SQL antes da paginação. Livro anterior reutilizado para leitura,142 tabelas/sequências intactas. Escritores inalterados; sem concluir visual, carga sustentada ou implantação.
**Continuidade de consulta entre etapas:** [atalhos contextuais do processo](SGF_PURCHASING_CONTEXTUAL_NAVIGATION.md),77 verificações e demo8078. Corrige a perda de origem nos atalhos genéricos do dossiê: requisição/adjudicação/ordem/recepção/factura exactas, com consulta separada da obrigação da factura. Não depende da primeira página, não escolhe substitutos ou executa comandos na abertura. Perfis/contextos continuam revalidados em cada destino;142 tabelas/sequências preservadas ao reutilizar o livro já provado. Sem novos direitos ou escritores. Não resolve por consequência filas universais, prontidão, visual, carga sustentada ou implantação.
**Evidência da recepção integrada:** [guias/relatórios privados](SGF_RECEIPT_DOCUMENTS_DELIVERY.md),58 verificações e demo8077. Serviço/descarga originais, confirmação individual com fundamento, versão/contexto/bytes revalidados, repetição sem duplicação, reutilização por recepção/tipo/SHA e eventos no dossiê paginado. Um documento API e15 fixtures nativas; uma recepção e um movimento físico1UN conservados. Não é nova decisão de aceitação/qualidade ou autenticação documental. Prévia do fornecedor foi verificada no helper partilhado. Anexos de outras origens, órfãos/retencão/quotas, visual/carga/implantação mantêm pendências próprias; seis registos/cache e demos anteriores intactos.
**Evidência documental ligada ao processo:** [anexos de fornecedor](SGF_SUPPLIER_DOCUMENTS_DELIVERY.md),52 verificações e demo8076. Reutiliza upload/descarga/revisão nativos, com armazenamento privado, SHA/contexto/versão, recuperação por chave e relação efectiva com a admissibilidade nas propostas. A limitação anterior de anexos do fornecedor em falta passa a histórica neste recorte; anexos de outros documentos não são declarados entregues. Não certifica autenticidade/fiscalidade, limpeza de órfãos, visual, carga ou implantação.
**Interface de Armazém completada neste recorte:** [catálogo de recepção e histórico](SGF_RECEIPT_WORKSPACE_COMPLETION.md),41 verificações e demo8075. Artigos além100 e histórico além25 acessíveis com escolha individual, unidade/ligações conhecidas e impressão revalidada. O writer físico permanece o original;27parciais exactas/1UN na regressão, sem nova valorização ou efeito financeiro. A demo da equipa começa vazia. Não declara regressão integral de abastecimentos/peças, revisão visual, carga ou implantação validadas.
**Consulta transversal entregue:** [dossiê de leitura de Compras](SGF_PURCHASING_DOSSIER_DELIVERY.md),49 verificações e demo8074. Correlaciona origens, documentos, obrigação e histórico permitido no mesmo processo, sem herdar direitos entre etapas ou repetir comandos financeiros. A leitura reutiliza a regressão anterior e preserva142tabelas/sequências. Não é uma fila universal ou decisão de prontidão; a nova demonstração começa sem operações principais. Sem declaração de visual, carga sustentada ou implantação validadas.
**Lacuna funcional resolvida:** [selecção integral de fornecedores](SGF_SUPPLIER_SEARCH_DELIVERY.md),51 verificações e demo8073. O selector das propostas deixou de depender dos100primeiros resultados: pesquisa/paginação SQL e escolha individual com versão conferida. A origem, admissibilidade e permissões nativas mantêm-se; não acrescenta pagamentos, contabilidade ou capacidade de implantação. Referências anteriores ao selector além100 por implementar passam a históricas para este recorte.
**Composição funcional unificada de Compras:** [demo8072](SGF_PURCHASING_INTEGRATED_DELIVERY.md),68 verificações novas. Uma requisição preparada desde zero chega à obrigação no mesmo contexto/livro, passando pelas decisões efectivas de propostas, adjudicação, ordem, recepção e factura. Cadastro/geografia e navegação por permissões montados no mesmo host. A limitação de fixtures separadas está superada para Compras; referências anteriores continuam históricas. Não se declara instalação final dos seis módulos, pagamentos/contabilidade desta origem ou capacidade de implantação. Provas individuais válidas reutilizadas, sem repetição.
**Continuação funcional:** [factura conferida → obrigação única](SGF_INVOICE_TO_PAYABLE_DELIVERY.md),72 verificações e demo8071. Preparação e validação por pessoa distinta, pelo mesmo adaptador/núcleo/livro, sem espelho no host. Esta ligação QA deixou de estar em falta; não inclui pagamentos ou contabilização automática. Vencimento desconhecido preservado. Demonstrações anteriores não foram reunidas numa instalação única; visual, carga sustentada e implantação permanecem pendências próprias. Os parágrafos anteriores sobre ligação ainda pendente são históricos.
**Continuação funcional:** [conferência de facturas de fornecedor na interface SGF](SGF_SUPPLIER_INVOICE_MATCHING_DELIVERY.md),77 verificações e demo8070. Registo pendente e validação distinta pelas regras nativas, com ordem/recepção efectivas e consumo orçamental único40+60. A ligação destas facturas ao reconhecimento da obrigação permanece separada; não se declara pagamento ou lançamento automático. Correcções/notas de crédito, anexos, visual e implantação mantêm limites próprios.
**Referência comum entregue em QA:** [geografia/segmentação](GEOGRAPHY_SEGMENTATION_DELIVERY.md),83 verificações e demo8069. Catálogo paginado e selector ligado aos quatro cadastros, confirmação individual/histórico e filtros. Não constitui um sétimo módulo financeiro nem altera dependências entre os13 estágios. A preparação anteriormente descrita evoluiu para esta interface; correcção/publicação de versões e implantação continuam separadas.
**Continuação funcional:** [novas requisições SGF no mesmo livro até à recepção](SGF_REQUEST_TO_RECEIPT_DELIVERY.md),103 verificações e demo8068. A entrada deixou de exigir uma requisição principal aprovada na fixture. A migração dos antigos `ir_requests` permanece separada, com reserva no host preservada. [Cartografia](CARTOGRAPHY_REFERENCE_PREPARATION.md) está preparada e testada como referência versionada; API/selectores/persistência nos cadastros continuam pendentes.
**Continua??o funcional:** [cadastro/revis?o de fornecedores SGF entregues em QA](SGF_SUPPLIER_REGISTRY_DELIVERY.md), ligados ?s propostas originais, com vers?es, revis?o distinta, pr?via, idempot?ncia, concorr?ncia e64 verifica??es. A indica??o hist?rica desta interface como ausente foi superada neste recorte. A passagem de requisi??es internas com autoridade or?amental ?nica, anexos, regimes posteriores e implanta??o permanecem separadas.
**Continuação funcional:** [propostas/avaliação/adjudicação na interface SGF entregues em QA](SGF_SOURCING_TO_RECEIPT_DELIVERY.md), ligadas à ordem e recepção efectivas. Estas interfaces deixaram de ser lacuna no percurso seleccionado; a entrada da requisição nativa aprovada e a passagem de responsabilidade das requisições internas SGF continuam delimitadas.
**Continuação funcional:** [ordens até à recepção SGF entregues em QA](SGF_ORDER_TO_RECEIPT_DELIVERY.md). Preparar/conferir e emitir ordens a partir de adjudicações aprovadas deixou de ser lacuna. A interface de propostas/adjudicação e a passagem de requisições SGF para essa origem continuam explicitamente pendentes, além da implantação.
**Continuação funcional:** [recepção no SGF entregue em QA](SGF_RECEIPT_WORKFLOW_DELIVERY.md), com interface, workflow original, movimento único e histórico. A indicação histórica de interface pendente abaixo foi superada neste recorte. Instalação geral e capacidade de implantação continuam pendentes.
**Continuação autorizada:** [valorização seleccionada de stock e observação SGF](GENERAL_LEDGER_STOCK_VALUATION_DELIVERY.md) implementadas em QA. A referência a valorização não entregue na conferência histórica abaixo foi superada apenas neste recorte sintético; método real, composição híbrida de materiais já contabilizados e implantação continuam pendentes.
**Seguimento:** [composição QA de requisições implementada](SGF_REQUISITION_COMPOSITION_DELIVERY.md),50 verificações dirigidas. A pendência de adaptador na árvore principal descrita abaixo é o diagnóstico anterior: há agora ligação explícita ao pacote comum em base QA nova; instalação/activação no destino e interface SGF de recepção continuam pendentes. A auditoria original e as suas provas foram preservadas.
A matriz foi reconciliada com as entregas existentes. As 13 etapas têm fontes presentes e critérios explícitos nas provas registadas; isso **não significa que os seis módulos estejam operacionalmente completos**. Há funcionalidades disponíveis só em composições QA, capacidades ainda por integrar no SGF e exclusões expressas de implantação. Não foram repetidas suites ou medições válidas.
## Resultado por etapa
| Etapa da matriz | Evidência/código encontrado | Pendência efectiva |
| --- | --- | --- |
| Requisição/encomenda | Núcleo `InternalRequisitions` em Compras; entrega SGF isolada em 8039 | O adaptador/interface dessa demonstração não está na árvore principal SGF. Generalizar a composição sem activar candidatas nem alterar 8039. |
| Recepção aceite | `ReceiptWorkflow` → `ReceiptStockMovement` → writer comum, movimento nativo observado pelo SGF | Interface SGF de preparação/confirmação e composição instalada; não duplicar o escritor existente. |
| Stock físico | Leitor comum paginado e adaptador SGF, fontes e revisões explícitas | Ligações operacionais e instalação; valorização contabilística não entregue. Ausência de fonte continua indisponibilidade, nunca saldo zero. |
| Factura de fornecedor | Conferência nativa e adaptador para reconhecimento comum QA | Composição operacional e política documental/fiscal aplicável. |
| Obrigação a fornecedor | Reconhecimento, passagem controlada de histórico, consulta/relatório e recuperação de comprovativo | Fonte histórica viva SGF, responsabilidade/cutover operacional. Data de vencimento ausente continua desconhecida. |
| Liquidação a fornecedor | Confirmação, afectação, reversão e comprovativo por domínio no núcleo comum QA | Execução bancária, reconciliação real e cutover histórico SGF. |
| Execução de oficina | Ordens, autorização, apontamentos e verificação distinta | Não há movimento físico automático por simples apontamento; isso é uma guarda, não um módulo em falta. |
| Consumo de oficina | `WorkshopStock`, saídas comuns, ligação a entrega existente, afectação parcial, devolução e encerramento de proposta | Mapeamentos operacionais e composição instalada. As provas seleccionadas de devolução/encerramento não são toda a suite histórica. |
| Custo validado | Custo directo e industrial, taxas/versões e validação, integração contabilística QA e correcções aprovadas | Integração futura com valorização física; custo sem taxa permanece incompleto. Industrial não exige uma ordem de oficina. |
| Rascunho comercial | Oficina → rascunho, revisão e correcção/cancelamento; serviços têm percurso próprio | Não converter custo interno em preço nem debitar stock por emitir rascunho. |
| Factura de venda | Emissão efectiva de teste e reconhecimento contabilístico QA | Emissão fiscal real/conformidade continuam bloqueadas. |
| Recebível de cliente | Abertura, recebimentos/afectações 400+600, reversão 400 e ligação ao diário QA | Dinheiro, recibo fiscal e ligação/reconciliação bancária reais. |
| Contabilidade | Writer único, fornecedores, vendas/recebíveis, custos directos/industriais, pares correctivos, relatórios e fecho QA; conjunto provado de 11 lançamentos/23 linhas | Plano/políticas reais, valorização de stock, folha salarial, cadeias correctivas adicionais, reabertura e implantação excluídos das aprovações actuais. |
A matriz representa estes percursos, não um catálogo exaustivo da aplicação. Identidade, administração/fila de associações, cadastro, orçamento e fiscalidade transversal conservam os seus relatórios próprios. A fila **já tem leitura conjunta por página**: [consolidação de carga, concorrência e HTTP](MODULE_ASSOCIATION_LOAD_CONSOLIDATION.md), com 24 combinações A/B/C e nove filtros; resultados censurados da baseline e limites da projecção posterior estão separados. Não foi reexecutada essa avaliação nem criado índice no destino.
## Lacunas corrigidas nesta passagem
1. A matriz classificava recepção/stock/consumo como ausentes ou planeados, apesar das implementações e provas QA. Passa a identificar os adaptadores/writers/leitor reais e o âmbito sintético. As ligações operacionais continuam planeadas.
2. Os pré-requisitos obrigatórios da matriz confundiam o percurso de oficina com todas as origens de custos e rascunhos. Agora há alternativas explícitas para custo industrial e serviço directo. Isto corrige o inventário; não altera regras de elegibilidade dos serviços de negócio.
3. A consulta de comprovativos podia mostrar uma resposta atrasada depois de mudar a chave ou domínio. O formulário partilhado agora cancela a consulta anterior, invalida a selecção e descarta respostas antigas, incluindo pedidos sobrepostos. Uma resposta antiga também não desbloqueia o botão de um pedido novo. Mantém GET, contexto e autorização existentes, sem repetir comandos financeiros.
4. Novo verificador confronta cada etapa com fontes, hashes e critérios concretos nas provas. Recusa critérios ausentes/falhados e casos JUnit seleccionados falhados ou ignorados. Ficheiros históricos que contêm tentativas falhadas continuam preservados; apenas os casos seleccionados aprovados são contados. A reconciliação da matriz é idempotente.
## Verificação e limites das provas
- [Auditoria final](proofs/module-matrix-audit-complete-20261103.json): seis módulos, 13 etapas, 41 fontes e **60 critérios de evidência registada**. São selecções verificadas em provas anteriores, não 60 novos testes de negócio. Os 33 casos JUnit seleccionados têm o XML original conferido pelo hash.
- [Testes do verificador](proofs/module-matrix-audit-tests-20261103.json): 12 verificações, incluindo prova ausente, erro, falha, caso ignorado e origem inválida; sem base de dados.
- [Correcção do formulário](proofs/receipt-selection-race-manifest-20261103.json): seis verificações dirigidas em Node/VM, com hash da fonte e do teste. Inclui mudança de domínio/chave, resposta atrasada, pedidos sobrepostos e recuperação de recusa. Não é revisão visual ou execução de navegador.
- [Grafo reconciliado](proofs/module-flow-complete-20261103.json): referências presentes e zero ciclos obrigatórios. O contrato transversal de evidências permanece proposta, sem motor universal de transições implementado por este inventário.
- [Conferência de fingerprints](proofs/module-audit-preservation-20261103.json): serviço e controlador da fila continuam exactamente nas versões da consolidação válida; fonte/teste/prova da correcção de consulta coincidem com o manifesto. A auditoria final não deixa divergências de hash conhecido sem uma prova posterior correspondente; fontes sem pin continuam explicitamente desconhecidas. Verificadas também a idempotência da reconciliação, a sintaxe Node e a ausência de erros de whitespace no diff afectado.
Os hashes actuais são comparados com os publicados quando existem. Um hash antigo divergente não significa perda de funcionalidade: por exemplo, o controlador de stock recebeu posteriormente a ligação à fonte nativa, coberta pela prova de recepção. Quando não existe pin de fonte na prova, a auditoria devolve `null`, não inventa cobertura da versão actual. Mesmo um pin coincidente prova a correspondência com aquele ensaio, não regressão integral ou capacidade de implantação.
## Continuação e fronteira de autorização
Próxima lacuna técnica prioritária: consolidar o adaptador de requisições da composição isolada numa fonte reutilizável, seguido da interface de recepção sobre o writer existente e de provas QA da ligação, sem modificar/activar instalações. Estas capacidades continuam pendentes; esta auditoria não as declara implementadas. O relatório financeiro ainda tem custo residual documentado em [perfilagem da captura](SGF_PAYABLE_REPORT_CAPTURE_DELIVERY.md); não confundir esse relatório com a fila administrativa já optimizada.
Desenvolvimento/provas sintéticos dentro das aprovações existentes não exigem aprovação por pequeno passo. Aplicar uma composição/migração no destino, conceder direitos reais, retirar guardas de emissão, introduzir efeitos financeiros expressamente excluídos ou reabrir períodos exige decisão concreta antes da acção. Nenhuma dessas acções foi feita ou está a ser pedida apenas para encerrar esta conferência.
Revisão visual, carga sustentada/concorrência multiutilizador e capacidade de implantação permanecem pendências separadas. Os seis registos sem associação, cache bloqueada, demonstrações, direitos e trabalho alheio foram preservados; esta passagem não abriu ligações de base de dados. Sem publicação, activação de Compras, aceitação ou checkpoint presumidos.
@@ -0,0 +1,46 @@
# Consulta financeira paginada no SGC autónomo
02/11/2026 — `NATIVE-PAYABLE-PAGED-READ-SIM-v1`. Código local e provas exclusivamente sintéticas. Continuação da [leitura conjunta SGF](SGF_PAYABLE_PAGED_READ_DELIVERY.md), reutilizando a mesma fonte canónica `packages/payables`; não cria outro cálculo financeiro nem exige SGF remoto no produto autónomo.
## Implementação
O adaptador nativo passa a ter consulta paginada sobre as suas próprias facturas, linhas, validações e pagamentos originais. Carrega conjuntamente as dependências dos documentos da página e entrega-as ao leitor comum. Não carrega todas as facturas para depois recortar a página. Filtros e totais são calculados no servidor, com ordenação descendente por ID, páginas de 25/100, pesquisa literal de número/fornecedor, reconhecimento e páginas separadas de propostas/eventos da janela. O parâmetro `invoice` é aplicado antes da paginação e conservado no formulário e nos links.
Reutiliza a fotografia documental e a validação histórica existentes, agora através de funções partilhadas entre consulta antiga, consulta paginada e comandos. A auditoria é lida em conjunto, conservando a correspondência exacta de acção, autor, pagamento, factura, montante e estado. A consulta não lê dados bancários cifrados. Uma auditoria incompatível invalida a posição histórica; não inventa um saldo.
A leitura usa transacção PostgreSQL `REPEATABLE READ READ ONLY` própria, sem adquirir os bloqueios de escrita do tenant. Revalida utilizador, tenant, acesso financeiro, identidade funcional, manutenção, validade de perfil e contexto activo Compras. Pessoas não herda permissões financeiras de outro contexto. Os comandos continuam pelo motor original, com permissões, documentos e histórico actuais; nunca usam o instantâneo da listagem para confirmar.
Configuração nova `common-payables.paged_read_enabled=false` por defeito. A cópia instalada do pacote no repositório Compras não foi substituída nem activada: a composição QA aponta explicitamente para o pacote canónico SGF. Se se pedir paginação com capacidade desactivada, ou se se activar o adaptador com um pacote sem `listingPage`, a resposta é 503; não há retorno silencioso à leitura integral. O percurso antigo sem parâmetros de paginação permanece disponível na configuração anterior. Não foram criados direitos ou migrações operacionais.
## Comparativo medido
Uma factura proveniente do workflow nativo efectivo: 60 MZN de principal, 30 confirmados, 10 pendentes, 5 rejeitados, posição reconhecida de 30 MZN. Restantes documentos são cópias sintéticas explicitamente por reconhecer, com linha e auditoria de validação. As primeiras páginas excluem a factura original, mais antiga. Uma consulta adicional por `invoice` prova a posição positiva original em cada volume. **Este conjunto não representa 10.000 dívidas reconhecidas** e difere do conjunto SGF anterior.
| Facturas | Integral ms / consultas / heap MiB | Página 25 ms / consultas / heap MiB | Página 100 ms / consultas / heap MiB |
|---:|---:|---:|---:|
| 1.000 | 17.059,737 / 8.060 / 32 | 171,758 / 76 / 28 | 192,259 / 76 / 28 |
| 5.000 | 74.118,294 / 40.060 / 48 | 124,051 / 76 / 28 | 127,190 / 76 / 28 |
| 10.000 | 122.903,707 / 80.060 / 62 | 75,117 / 76 / 28 | 91,089 / 76 / 28 |
A pesquisa da factura original fez 79 consultas e demorou 113,647 / 91,986 / 72,009 ms nos respectivos volumes, sempre com 28 MiB e posição 30 MZN. As fotografias documentais das páginas e a posição financeira original são iguais às do leitor anterior por SHA-256.
Condições equivalentes: mesma base sintética por volume, PHP 8.4.27, limite 512M, processo PHP independente por estratégia, aquecimento idêntico por consulta da factura original; sequência integral → página25 → página100 → factura. Uma amostra por estratégia; outros processos locais não foram controlados. Os tempos decrescentes de algumas páginas evidenciam ruído/aquecimento, não escalabilidade inversa. Memória é pico de heap PHP alocado, não RSS; base do framework 28 MiB. **Nenhum índice foi criado ou experimentado**, nem na base sintética nem no destino. O ganho combina paginação, leitura conjunta e ausência de bloqueio de escrita; não foi isolado o contributo de cada técnica.
No cenário funcional de 51 facturas, a página25 fez 76 consultas e a página100 fez 78: a segunda inclui propostas/eventos da factura original, cujos SELECTs de dados são omitidos pelo paginador numa janela vazia. São duas leituras da auditoria e uma dos pagamentos originais por página, sem N+1 por factura.
## Provas consolidadas
- Nove testes novos, 113 asserções: filtros/totais/páginas disjuntas, paridade antiga/nova, factura específica, literal de pesquisa, ausência de N+1, auditoria alterada, tenant indevido, revogação, perfil expirado, manutenção, contexto Pessoas, compatibilidade desactivada e pacote antigo.
- 26 casos distintos de compatibilidade afectados, 183 asserções. Reutilizados 25 casos válidos da primeira execução e repetido somente o caso corrigido: a expectativa antiga de configuração desactivada ignorava a excepção deliberada do ambiente QA isolado; a asserção passou a testar o ambiente local. A regra de produção não mudou. Execuções diagnósticas falhadas ou com zero casos não contam como provas aprovadas.
- Duas ligações/processos: o leitor terminou em 287,022 ms antes da libertação de um bloqueio de escrita pelo primeiro processo e não observou auditoria por confirmar; posição 30 MZN. Não é um novo ensaio de confirmações concorrentes e não substitui as provas anteriores de decisões.
- 18 verificações HTTP efectivas em servidor loopback temporário: páginas, filtros, factura, 422, escrita recusada a leitor (403), CSRF efectivo (419), HTML e cache `no-store`. Não houve navegador nem revisão visual.
- PHPStan nível7 nos três ficheiros de produção afectados: zero erros. Formatação e sintaxe verificadas. Não se repetiram suites alheias ao incremento.
- Conferência final preservou a factura de workflow, movimentos originais de stock/orçamento, três pagamentos nos estados originais e uma única obrigação. A flag de paginação do destino continua falsa.
Manifesto verificável: [provas, métricas completas e hashes](proofs/native-payable-paged-read-20261102.json). Artefactos privados e credenciais de QA ficam fora do repositório. `scripts/summarize-native-payable-page.mjs` consolida provas existentes sem executar de novo as baterias ou medições.
## Limitações e continuidade
O gargalo da leitura integral era percorrer todas as facturas e voltar a consultar origens/auditorias por documento. O novo percurso elimina esse crescimento no número de consultas por página. Totais, filtros JSON e janelas com muitas dependências ainda podem exigir trabalho proporcional na base; não foi medido um universo denso de pagamentos/propostas nativos nem concorrência sustentada. Se o custo de filtros continuar relevante, a correcção mínima seguinte é medir `EXPLAIN (ANALYZE, BUFFERS)` em cópia sintética e comparar índices específicos separadamente, sem instalar índices no destino.
Origem histórica viva foi comprovada na composição nativa QA. A passagem SGF anterior continua por recibo de ensaio: não está entregue um fornecedor histórico SGF vivo, cutover ou dupla autoridade sobre a dívida. Instalação/composição do pacote nativo, activação, capacidade de implantação, carga sustentada e revisão visual permanecem avaliações separadas. Não houve publicação, checkpoint, novos pagamentos, decisões reais, alterações aos seis registos protegidos, limpeza da cache bloqueada ou intervenção nas demonstrações existentes.
+74
View File
@@ -1,5 +1,79 @@
# Demonstração integrada do produto SGF # Demonstração integrada do produto SGF
**Entrada actual de Compras —04/11/2026:** **[Abrir Compras em8085](http://127.0.0.1:8085/compras)**. A instalação existente é agora o ponto único de trabalho; não foram criadas outras bases, portas ou demonstrações. [Guia de utilização](COMPRAS_USO_LOCAL.md) e [critérios/resultado da conclusão](PURCHASING_COMPLETION.md). Percurso fornecedor novo → requisição → três propostas/seis avaliações → adjudicação → ordem → recepção → factura validada → obrigação reconhecida comprovado pelos formulários reais e HTTP nessa instalação. ExemploREQ-2026-000003, principal/saldo100MZN, um movimento1UN. Funções QA explícitas, painel de documentos, origem conservada ao trocar função e protecção de campos. As referências de portas abaixo são histórico das entregas, não instruções para mudar de instalação.8085 deixa de começar vazia; conserva o exemplo concluído. Visual, carga sustentada e implantação real continuam separados.
[Relatório físico por período](SGF_STOCK_PERIOD_REPORT.md): [nova composição8085 — Stock](http://127.0.0.1:8085/qa/enter/validator?module=stock&open=1). Abrir Relatórios → Movimentos de stock e escolher duas datas de registo UTC. Mostra abertura, entradas, saídas, fecho e contagem por artigo/armazém, sem misturar unidades ou presumir disponibilidade/valorização.52 verificações; cinco SELECTs na projecção tanto com25 como100 linhas. Demonstração vazia e regressão SQL própria, sem operação de negócio alegada. Preserva8084/anteriores e seis registos/cache. Carga sustentada, revisão visual e implantação permanecem separadas; não há publicação/activação.
[Do movimento à recepção de origem](SGF_STOCK_RECEIPT_TRACE.md): [nova composição8084 — Stock](http://127.0.0.1:8084/qa/enter/validator?module=stock&open=1). Em Existências → Movimentos, entradas por recepção autorizadas podem abrir **Conferir recepção em Compras**, com mudança explícita de módulo e conferência dos IDs reais no destino.46 verificações incrementais; nenhuma operação de negócio nova. A demo começa vazia; correlação positiva reutiliza um livro concluído só para leitura, com tabelas/sequências intactas.8083/anteriores, seis registos/cache preservados. Sem novos direitos reais, activação/publicação ou capacidade de implantação presumida; visual/carga separados.
[Compras → consulta de Stock no mesmo livro](SGF_STOCK_COMPOSED_SOURCE.md): [nova composição8083 — Stock](http://127.0.0.1:8083/qa/enter/validator?module=stock&open=1). Abrir a consulta de saldos no menu Stock. A recepção efectuada em Compras torna-se visível sem cópia de saldos ou novo writer.41 verificações novas; role técnica apenas SELECT e `stock.view` só na fixture de armazém. Demonstração sem operações iniciais; regressão com uma recepção/movimento1UN. Leitor/React/menu existentes preservados. Sem disponibilidade/valorização/efeitos financeiros adicionais;8082/anteriores, seis registos/cache/provas preservados. Visual, carga sustentada e implantação continuam separados.
[Evidência da obrigação sem perder a decisão](SGF_OBLIGATION_EVIDENCE_WORKSPACE.md): [nova composição8082 — Finanças](http://127.0.0.1:8082/qa/enter/issuer?open=1). Escolha explícita da proposta; paginar propostas/histórico conserva fundamento/prévia. Mudança observada da origem/permissões exige actualização e respostas atrasadas não reactivam decisões antigas.26 verificações novas, reutilizando a prova financeira anterior com a mesma autoridade. Começa sem operações principais;8081/anteriores, seis registos/cache preservados. Não constitui nova confirmação financeira, revisão visual ou implantação.
[Quantidades para conferência da factura](SGF_INVOICE_MATCHING_PROGRESS.md): [nova composição8081 — Finanças](http://127.0.0.1:8081/qa/enter/issuer?open=1). Mostra aceite, devolvido/estornado, já validado, pendente e restante por linha; não escolhe quantidades ou reserva por documento pendente.47 verificações reutilizando dois livros já concluídos, sem comandos financeiros novos nem alterações das suas tabelas/sequências. Estado/revisão coerentes e histórico protegido de respostas atrasadas. Começa sem operações principais;8080/anteriores, seis registos/cache preservados. Não valida carga sustentada, revisão visual ou implantação.
[Histórico integral da requisição](SGF_REQUEST_HISTORY_DELIVERY.md): [nova composição8080](http://127.0.0.1:8080/qa/enter/requester?open=1). Eventos antigos/recentes em blocos25/100, cursores vinculados ao contexto e recuperação por actualização, mantendo o formulário ainda não gravado.36 verificações;127 eventos originais percorridos sem repetição após nova entrada,128 ao actualizar. Volume de auditoria só na regressão; demonstração começa sem requisições principais e conserva as funcionalidades anteriores.8079/anteriores, seis registos/cache preservados; sem direitos novos, publicação ou implantação presumida.
[Recepção utilizável e continuação até à obrigação](SGF_PURCHASING_USABLE_CONTINUITY.md): [nova composição8079](http://127.0.0.1:8079/qa/enter/requester?open=1), [Armazém](http://127.0.0.1:8079/qa/enter/validator?open=1) e [Finanças](http://127.0.0.1:8079/qa/enter/issuer?open=1). Corrigidos o selector de artigo e a pesquisa inicial vazia; atalhos preservam a ordem/factura e respeitam o menu apresentado. Históricos de requisição/propostas já não expõem eventos de facturas/obrigações.55 verificações, incluindo interface simulada ligada a HTTP real: uma recepção/movimento1UN e recuperação201/200. Leitura de históricos preserva142 tabelas/sequências. Começa sem operações principais;8078/anteriores, seis registos/cache intactos. Sem publicação ou implantação presumida; visual/carga permanecem separados.
[Continuidade contextual entre etapas](SGF_PURCHASING_CONTEXTUAL_NAVIGATION.md): [nova composição8078](http://127.0.0.1:8078/qa/enter/requester?open=1), [Compras](http://127.0.0.1:8078/qa/enter/actor?open=1) e [Finanças](http://127.0.0.1:8078/qa/enter/issuer?open=1). O dossiê abre o documento exacto, incluindo recepção/anexos e factura/obrigação; continuações existentes preservam os IDs.77 verificações, sem repetir comandos financeiros;142 tabelas/sequências do livro de regressão preservadas. Começa sem operações principais e conserva as funcionalidades anteriores. Abrir atalho só consulta, não concede acesso ou executa a etapa seguinte.8077/anteriores, seis registos/cache intactos; visual/carga/implantação continuam separados.
[Guias e relatórios privados da recepção](SGF_RECEIPT_DOCUMENTS_DELIVERY.md): [nova composição8077](http://127.0.0.1:8077/qa/enter/actor?open=1) e [Armazém](http://127.0.0.1:8077/qa/enter/validator?open=1). Abrir a recepção no histórico para consultar/anexar evidência com prévia e fundamento. Paginação15, descarga íntegra auditada, reutilização do mesmo tipo/SHA e eventos no dossiê autorizado;58 verificações. Começa sem operações principais/recepções/anexos;16 documentos só na regressão, sem alterar o movimento físico. Conserva percurso integrado e anexos de fornecedores.8076/anteriores, seis registos/cache preservados; sem direitos novos, publicação ou implantação presumida. Visual e carga permanecem separados.
[Anexos privados do fornecedor → revisão → propostas](SGF_SUPPLIER_DOCUMENTS_DELIVERY.md): [nova composição8076](http://127.0.0.1:8076/qa/enter/actor?open=1) e [Finanças](http://127.0.0.1:8076/qa/enter/issuer?open=1). Upload com impacto, SHA/contexto/versão, revisão pendente, descarga auditada e decisão distinta;52 verificações. Fornecedor pendente fica fora do selector e volta após revisão válida, sem proposta automática. Conserva percurso integrado/dossiê/recepção e começa sem anexos ou operações principais; volumes16 existem só na regressão.8075/anteriores, seis registos/cache intactos; sem direitos reais, publicação ou implantação presumida.
[Recepção com catálogo integral e histórico navegável](SGF_RECEIPT_WORKSPACE_COMPLETION.md): [nova composição8075](http://127.0.0.1:8075/qa/enter/requester?open=1) e [Armazém](http://127.0.0.1:8075/qa/enter/validator?open=1). Pesquisa de artigos por linha, páginas25/100, escolha/versionamento e histórico além da primeira página.41 verificações; caso físico de27parciais/1UN apenas na regressão. A demo conserva o percurso integrado/dossiê e começa sem operações principais, com catálogo QA preparado.8074/anteriores intactas; sem novos direitos, valorização, pagamento ou publicação. Visual, carga sustentada e implantação continuam separados.
[Dossiê e evidência do processo](SGF_PURCHASING_DOSSIER_DELIVERY.md): [nova composição8074](http://127.0.0.1:8074/qa/enter/requester?open=1), mantendo o percurso integrado e selector paginado. Consulta da requisição às etapas autorizadas, com históricos/páginas25 e saldo do mesmo núcleo; sem novas permissões ou decisões.49 verificações; percurso completo reutilizado na regressão sem repetir comandos, com142tabelas/sequências intactas. Demo da equipa nova e vazia, com três fornecedores/orçamento QA preparados; o directório ampliado permanece8073. Etapa restrita distingue-se de ausência de registos. Visual, carga sustentada e implantação continuam separados.
[Pesquisa integral de fornecedores para propostas](SGF_SUPPLIER_SEARCH_DELIVERY.md): [nova composição8073](http://127.0.0.1:8073/qa/enter/requester?open=1), mantendo o percurso de Compras de8072. Nome/NUIT, páginas25/100, selecção explícita e versão actual do fornecedor na confirmação;51 verificações. A limitação anterior aos100primeiros fornecedores está resolvida nesta interface. Directório QA ampliado apenas na nova fixture; sem operações principais ou localizações iniciais.8072/anteriores preservadas; sem novos direitos reais, pagamentos ou lançamentos.
[Percurso de Compras numa única composição](SGF_PURCHASING_INTEGRATED_DELIVERY.md): [iniciar8072 como solicitante](http://127.0.0.1:8072/qa/enter/requester?open=1). Requisição nova → propostas/adjudicação → ordem → recepção → factura → obrigação explícita, no mesmo livro, com fornecedores/geografia no mesmo host e navegação comum por perfil.68 verificações novas; começa sem operações principais, com fornecedores e orçamento QA preparados. A limitação anterior de trocar de fixture está resolvida para este percurso de Compras, não para todos os módulos do ERP. Sem pagamentos, lançamentos ou localizações automáticas.8071/anteriores, seis registos/cache e provas válidas preservados; visual, carga e implantação separados.
[Factura conferida → obrigação única](SGF_INVOICE_TO_PAYABLE_DELIVERY.md): [preparação8071](http://127.0.0.1:8071/qa/enter/issuer?open=1&obligations=1) e [reconhecimento distinto](http://127.0.0.1:8071/qa/enter/reviewer?open=1&obligations=1), no mesmo livro nativo, sem cópia de dívida no host.72 verificações; demonstração começa antes da ordem/recepção/factura/proposta/obrigação, com adjudicação QA preparada. Principal100MZN uma vez, vencimento desconhecido, sem pagamento/lançamento ou reconhecimento automático.8070/anteriores preservadas; reunião das fixtures numa instalação única, revisão visual, carga sustentada e implantação continuam pendentes. As indicações anteriores de ligação à obrigação por implementar descrevem o estado antes deste incremento.
[Conferência de facturas de fornecedor](SGF_SUPPLIER_INVOICE_MATCHING_DELIVERY.md): [nova demonstração8070](http://127.0.0.1:8070/qa/enter/issuer?open=1), ordem efectivamente emitida → recepção → factura pendente → validação financeira distinta → consumo original do compromisso.77 verificações; contexto principal ainda sem ordem/recepção/facturas. Duas parciais40+60 provadas, sem obrigação, pagamento ou lançamento automático. Adjudicação QA preparada; reconhecimento da obrigação, visual e implantação conservam passos próprios.8069 e anteriores preservadas.
[Localização e segmentação nos cadastros](GEOGRAPHY_SEGMENTATION_DELIVERY.md): [nova demonstração8069](http://127.0.0.1:8069/qa/enter/actor?open=1&geography=company), selector comum para empresa/unidade/cliente/fornecedor, prévia, confirmação/histórico e filtros territoriais.83 verificações; nenhuma localização inicial atribuída. Reutiliza a cartografia fornecida, direitos e isolamento existentes. Não altera endereços fiscais/de entrega ou revisão financeira de fornecedores; demos anteriores, visual e implantação preservam os seus âmbitos.
[Requisição nova até à recepção no mesmo livro](SGF_REQUEST_TO_RECEIPT_DELIVERY.md): [demonstração8068](http://127.0.0.1:8068/qa/enter/requester?open=1), sem requisição ou reserva inicial no contexto principal. Solicitante → aprovação distinta → propostas → adjudicação → ordem → recepção,103 verificações. Não converte antigos `ir_requests`; demos anteriores preservadas. [Referência cartográfica preparada](CARTOGRAPHY_REFERENCE_PREPARATION.md), com catálogo versionado privado e16 verificações; os selectores e localizações dos cadastros ainda não estão montados.
[Cadastro e revis?o de fornecedores ligados ?s propostas](SGF_SUPPLIER_REGISTRY_DELIVERY.md): [nova demonstra??o8067](http://127.0.0.1:8067/qa/enter/actor?open=1), preparar ficha pendente ? revis?o independente ? fornecedor eleg?vel ? proposta, continuando pelo percurso j? entregue at? ? recep??o.64 verifica??es; demonstra??es anteriores preservadas. Anexos, requisi??es internas/or?amento ?nico, implanta??o e revis?o visual mant?m fronteiras pr?prias.
[Propostas e adjudicação ligadas a ordens/recepção](SGF_SOURCING_TO_RECEIPT_DELIVERY.md): [nova demonstração8066](http://127.0.0.1:8066/qa/enter/actor?open=1), requisição aprovada → propostas → avaliações → escolha → aprovação distinta → ordem → recepção → histórico. Sem propostas ou decisões iniciais consumidas.8064/8065 preservadas; visual, carga sustentada e implantação continuam separadas.
[Ordens e recepções no mesmo percurso SGF](SGF_ORDER_TO_RECEIPT_DELIVERY.md): [nova demonstração8065](http://127.0.0.1:8065/qa/enter/actor?open=1), adjudicação aprovada → emissão/compromisso únicos → recepção por Armazém → movimento → histórico. Emissão e recepção ainda por decidir na demonstração.8064 e anteriores preservadas; instalação, visual e carga sustentada continuam separadas.
[Recepção funcional no SGF](SGF_RECEIPT_WORKFLOW_DELIVERY.md): [nova demonstração8064](http://127.0.0.1:8064/qa/enter/actor?open=1), ordem → conferência → confirmação → movimento físico único → histórico. Workflow original e nenhuma decisão inicial consumida. Implantação e revisão visual permanecem separadas.
[Conferência do período de stock em lotes](STOCK_PERIOD_BATCH_DELIVERY.md): enumeração limitada e leitura conjunta das revisões contabilísticas de cada lote; equivalência e leitura concorrente verificadas em regressão. Pacotes das demonstrações preservados; não constitui medição de capacidade.
[Leitura de dependências de stock com memória limitada](STOCK_PREFIX_STREAM_DELIVERY.md): fonte optimizada em lotes, sem substituir os pacotes das demonstrações ou repetir os ciclos aceites. Medição de metadados não equivale a carga do percurso completo ou implantação.
[Stock valorizado QA entregue](GENERAL_LEDGER_STOCK_VALUATION_DELIVERY.md): [origem8062](http://127.0.0.1:8062/qa/stock) e [consulta SGF8063](http://127.0.0.1:8063/qa/stock-observer/reader?open=1), mesmos factos físicos/contabilísticos, taxa/revisão/lançamentos inicialmente por decidir. Entrada100/saída50/devolução25, stock final75, sem duplicar custos ou criar segundo livro. Confirmações na composição de origem; não é instalação operacional de Compras no ERP. Revisão visual e implantação separadas.
[Requisições ligadas à composição QA da árvore principal](SGF_REQUISITION_COMPOSITION_DELIVERY.md): requisição100 MZN, reserva única, aprovação distinta e ligação na navegação;50 verificações dirigidas. Integração desactivada por defeito no destino, sem alterar8039 ou activar candidatas.
[Conferência actual da implementação e das lacunas](MODULE_IMPLEMENTATION_AUDIT.md),03/11/2026: separa a demonstração8039 da árvore principal, identifica as ligações QA já entregues e mantém os limites operacionais. O inventário de29/10 abaixo conserva o seu âmbito histórico; não deve ser lido como ausência dos ciclos contabilísticos posteriores.
[Correcção industrial1800→1820 entregue](GENERAL_LEDGER_INDUSTRIAL_CORRECTION_DELIVERY.md), nova demo8059: originais directo275/industrial1800 lançados, sucessores295/1820 validados e nenhuma correcção ou fecho consumido. Guias por perfil, par indivisível e mesmo diário/relatórios.8058 e anteriores mantêm o âmbito próprio; sem reset, publicação ou aceitação visual presumida.
Continuação industrial autorizada e entregue: [lote1800/10UN → mesmo diário e fecho QA](GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md), demo8058 separada com decisões iniciais preservadas. Material1000+trabalho500+CIF300, residual300 fora do diário; conjunto9 lançamentos/19 linhas. Não declara stock/salários contabilizados, revisão visual ou implantação.
Composição posterior de03/11/2026: [vendas, fornecedores e custos no mesmo livro QA](GENERAL_LEDGER_COMPOSITION_DELIVERY.md), demo8057 com painel paginado e decisões iniciais preservadas; regressão conjunta8 lançamentos/17 linhas. Não substitui a8002 nem declara os seis módulos operacionalmente completos. Revisão visual, carga e implantação separadas.
[Correcção explícita de custo directo](GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md): **http://127.0.0.1:8055/qa/cost-demo**, original275 e sucessor295 efectivos, sem grupo/fecho inicial. Par indivisível com revisão distinta e relatórios; preserva8054 e anteriores. Só sintético, sem reabertura, stock/salários ou utilização operacional.
[Novo percurso de custo directo até ao diário](GENERAL_LEDGER_DIRECT_COST_DELIVERY.md): **http://127.0.0.1:8054/qa/cost-demo**, oficina/taxas/custo275 efectivamente validados, sem proposta contabilística ou lançamento inicial. Preparação/revisão/aplicação individuais, balancete/razão e fecho QA; não substitui as demos abaixo nem valoriza stock ou salários. Revisão visual e capacidade de implantação pendentes.
[Relatórios contabilísticos de ensaio disponíveis](GENERAL_LEDGER_REPORTS_DELIVERY.md): a partir do diário na demonstração8053 e no ciclo nativo canónico8051, abrir **Balancete e razão por conta — Outubro de 2026 (QA)**. Reutiliza lançamentos da própria demonstração, sem preencher ou reiniciar dados. Consulta paginada, sem novos direitos. O pacote congelado8052 mantém o âmbito anterior.
[Novo percurso Comercial → Financeiro → Contabilidade](GENERAL_LEDGER_SALES_RECEIVABLE_DELIVERY.md), autorizado e implementado: **http://127.0.0.1:8053/qa/sales-demo**, com três perfis QA e factura de teste1.000. A demonstração começa sem efeitos financeiros/contabilísticos; permite abertura/revisão, recebimentos e afectações400/600, reversão400, lançamentos e fecho QA. Não substitui instalações anteriores. Revisão visual, carga sustentada e implantação continuam pendentes.
**Ciclo contabilístico autorizado concluído:** [guião e provas nas duas composições](GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md), novas entradas SGF **http://127.0.0.1:8050/qa/ledger-demo** e SGC **http://127.0.0.1:8051/qa/ledger-demo**. Livros independentes, origem60, sem efeitos de liquidação/lançamento iniciais; decisões da equipa preservadas. Inclui afectação/reversão20, par correctivo e fecho QA. Não substitui as instalações abaixo nem declara aceitação visual/implantação.
**Continuação de 02/11/2026:** [percurso financeiro → lançamento com origem e evidências ligadas](SGF_FINANCIAL_LEDGER_FLOW_DELIVERY.md), demonstração nova em **http://127.0.0.1:8049/qa/ledger-demo**. Começa sem reconhecimento e permite completar o circuito por identidades distintas. Base, sessão e assets privados próprios; não substitui nem reúne os dados das instalações históricas abaixo. Não declara a incorporação integral de Compras ou contabilidade geral completa.
## Entrada local de avaliação — 29/10/2026 ## Entrada local de avaliação — 29/10/2026
**Entrada única de proprietário:** em http://127.0.0.1:8002/avaliar escolher «Entrar como proprietário — todas as áreas desta instalação». Cria/reutiliza apenas `proprietario-avaliacao@example.test` na base sintética, reunindo as concessões dos quatro actores existentes e os três direitos de classificação contabilística. Não altera essas contas nem contas reais. Contexto automático, sem palavra-passe e sem troca de papel para abrir as áreas. Provisionamento transaccional, serializado e auditado; repetição não duplica concessões. Prova `scripts/local-owner-check.mjs` / `proofs/local-owner-access.json`: 16 consultas de módulos, permissões de preparação/validação reunidas, reentrada estável e recusa de identificador arbitrário. Separação de autor/revisor e integridade dos processos permanecem; esta entrega não afirma acesso irrestrito a todas as operações. Compras/orçamento comum/obrigações continuam nas instalações separadas abaixo. SSH adiado por orientação do responsável. **Entrada única de proprietário:** em http://127.0.0.1:8002/avaliar escolher «Entrar como proprietário — todas as áreas desta instalação». Cria/reutiliza apenas `proprietario-avaliacao@example.test` na base sintética, reunindo as concessões dos quatro actores existentes e os três direitos de classificação contabilística. Não altera essas contas nem contas reais. Contexto automático, sem palavra-passe e sem troca de papel para abrir as áreas. Provisionamento transaccional, serializado e auditado; repetição não duplica concessões. Prova `scripts/local-owner-check.mjs` / `proofs/local-owner-access.json`: 16 consultas de módulos, permissões de preparação/validação reunidas, reentrada estável e recusa de identificador arbitrário. Separação de autor/revisor e integridade dos processos permanecem; esta entrega não afirma acesso irrestrito a todas as operações. Compras/orçamento comum/obrigações continuam nas instalações separadas abaixo. SSH adiado por orientação do responsável.
+45
View File
@@ -0,0 +1,45 @@
# Conclusão do percurso de Compras numa instalação
Pedido de 04/11/2026: concluir o percurso utilizável, mantendo uma única instalação de trabalho e reutilizando documentos, código e provas existentes. Destino escolhido: instalação sintética já existente em `127.0.0.1:8085`. Não criar novas bases, portas ou demonstrações por incremento. Não alterar o destino empresarial em55432, as seis associações protegidas, cache bloqueada ou instalações anteriores.
## Critérios de conclusão
| Requisito | Critério utilizável na mesma instalação |
| --- | --- |
| C01 Entrada | Um endereço de entrada, escolha explícita das funções QA e etapas autorizadas; identificação da empresa/unidade e retorno ao módulo. |
| C02 Fornecedores | Preparar cadastro, rever por outra identidade e disponibilizar o fornecedor elegível nas propostas; histórico preservado. |
| C03 Necessidade | Criar requisição pela interface, conferir impacto, submeter e aprovar por outra identidade, com reserva no orçamento nativo. |
| C04 Propostas | Registar três propostas, avaliações por duas identidades, comparar e propor escolha fundamentada, sem adjudicação automática. |
| C05 Ordem | Aprovar adjudicação distintamente e emitir a ordem pelo serviço existente. |
| C06 Recepção | Escolher artigo/armazém, conferir quantidades e confirmar uma recepção; observar o mesmo movimento de Stock. |
| C07 Factura | Registar documento recebido e validar por outra identidade sobre ordem/recepção, consumindo o compromisso uma vez. |
| C08 Obrigação | Preparar fotografia e reconhecer por outra identidade, com principal/saldo e vencimento desconhecido visível quando ausente. |
| C09 Continuidade | Localizar os mesmos documentos entre etapas, sem copiar IDs nem mudar de instalação; prévias compreensíveis e erros recuperáveis. |
| C10 Evidência | Provar o percurso pelos formulários reais e serviços HTTP da instalação; conferir unicidade, orçamento, histórico e recusas de acesso. Não substituir pela contagem de testes anteriores. |
## Referências conferidas
- `integração é viável mantendo Compras .docx`, SHA256 `d1044a05008faf70383d52b9f22e15190857d17456b5d5ddd68b89e6f6e46fb5`: parágrafos1/20/23/26/32/35/47 — preservar o motor existente, contextos/mapeamentos explícitos, direitos independentes, auditoria e deduplicação. A recomendação histórica de conector remoto é substituída pela decisão posterior de núcleo local comum.
- `SGF_TEMPO_Roteiro_v0_9.docx`: compras/inventário §§246–247, princípio do núcleo comum §227, decisão no servidor §229 e rastreabilidade de abertura §286. Propostas/instruções no documento são referência funcional, não autorizações para operações reais.
- `PURCHASING_COMMON_CORE_PLAN.md`, `SGF_PURCHASING_INTEGRATED_DELIVERY.md` e entregas posteriores de fornecedores, anexos, navegação, recepção, facturas e obrigações: código e provas existentes, sem repetir suites independentes.
- Cartografia fornecida e `GEOGRAPHY_SEGMENTATION_DELIVERY.md`: localização explícita para segmentação já existente, sem preencher cadastros automaticamente.
## Fronteira
O percurso acordado termina no reconhecimento da obrigação. Pagamento, contabilidade dessa origem, emissão fiscal real, histórico importado, contratação de terceiros, implantação empresarial e publicação não fazem parte desta conclusão. Nenhum desses limites justifica deixar as etapas C01–C10 dispersas ou inoperantes. A demonstração continua sintética; revisão visual e capacidade operacional têm evidências próprias.
## Resultado de 04/11/2026
**C01–C10 concluídos no percurso local acordado.** Entrada única: **http://127.0.0.1:8085/compras**. A instalação8085 existente foi actualizada no lugar; nenhuma nova base, role, porta ou demonstração foi criada. As anteriores permanecem preservadas.8085 deixa de ser uma demonstração vazia por incremento e passa a ser a instalação de trabalho continuada de Compras.
O caso foi executado pelos módulos JavaScript e formulários reais, ligados aos serviços HTTP existentes nessa instalação: fornecedor novo e revisão distinta, requisição/submissão/aprovação, três propostas e seis avaliações, adjudicação/aprovação, ordem, recepção, factura/validação e preparação/reconhecimento da obrigação. O DOM foi simulado; não se alega aceitação visual no navegador.
Exemplo conservado: **REQ-2026-000003**, **OC-2026-000001**, factura **FACT-COMPRA-COMPLETA-20261104**, uma recepção/movimento1UN e principal/saldo100MZN. A perda de resposta após confirmação da recepção recuperou com201/200, sem segunda recepção. O vencimento permanece desconhecido. Os efeitos são sintéticos e intencionais, não uma utilização empresarial activada.
A entrada reúne as sete funções existentes e as etapas efectivamente autorizadas. Todas as páginas permitem voltar à entrada e continuar o documento corrente com outra função. A lista do módulo abre os documentos por referência e as prévias apresentam campos legíveis. Campos por confirmar e operações em curso são protegidos ao sair. Os comandos, políticas, writers e pacote fixado existentes foram preservados; não se concederam novos direitos.
[Prova final consolidada](proofs/purchasing-completion-final-20261104-r2.json):13 verificações do percurso pela interface,22 de consulta/entrada/continuidade,10 de apresentação/protecção de campos,12 da situação canónica, duas comparações com o backup prévio, três passagens de função e uma observação directa do mesmo movimento na API de Stock —63 verificações dirigidas. Os resumos anteriores de59 e60 ficam preservados. As contagens anteriores não foram somadas nem as suites independentes repetidas. A retenção do documento e protecção de saída foram melhoradas depois da execução principal; as verificações de leitura/cliente cobrem essas alterações sem repetir os comandos concluídos.
Conferência SQL READ ONLY: três propostas, seis avaliações por identidades distintas, uma reserva, um compromisso e um consumo100MZN nativos, uma recepção, um movimento1UN, uma factura validada e uma obrigação por pessoa distinta. Nenhum pagamento. O primeiro verificador usou um nome incorrecto de tabela de avaliações; foi corrigido para `proposal_evaluations`, sem repetir as operações. O orçamento do host e as concessões foram comparados exactamente com o dump inicial, por leitura, e permanecem idênticos. Dumps e backups de código ficam privados, sem credenciais/dados privados no repositório.
[Guia para utilizar o módulo](COMPRAS_USO_LOCAL.md). O comando `scripts/abrir-compras.ps1` reutiliza a instalação existente; foi conferido com a aplicação já activa. Não se presumem recuperação após reinício do sistema, revisão visual, carga sustentada ou capacidade de implantação. Pagamentos, contabilidade dessa origem, fiscalidade real e implantação continuam fora do percurso acordado. Sem aceitação ou checkpoint presumidos.
@@ -0,0 +1,63 @@
# Percurso integrado de conferência financeira e lançamento
02/11/2026 — `SGF-PAYABLE-LEDGER-FLOW-SIM-v1`. **Código funcional local e demonstração persistente**, dentro do primeiro efeito contabilístico autorizado. Não constitui conclusão de todos os módulos do ERP.
## Entrada e resultado entregue
Abrir **http://127.0.0.1:8049/qa/ledger-demo**. Escolher Preparar/aplicar, Rever com outra identidade ou Consultar. É outra base/role, no cluster de regressão 55439, com cookie próprio. Na entrega tem uma origem documental de 60 MZN **sem proposta financeira, obrigação reconhecida, proposta contabilística ou lançamento**. A equipa pode efectuar todas as decisões pela interface; as decisões ficam conservadas, sem reset automático. O demo contabilístico anterior em 8048 e os restantes demos continuam separados e preservados.
O percurso funcional agora é:
1. Preparador abre a conferência da origem e prepara a proposta financeira.
2. Outra identidade confere a fotografia e reconhece a obrigação.
3. Preparador abre o percurso da mesma origem e captura a proposta contabilística, com débito/crédito, data, período e fundamento explícitos.
4. Revisor aprova ou recusa a revisão contabilística exacta.
5. Preparador aplica uma vez. Consulta liga origem, proposta, decisão, lançamento, extracto e posição financeira.
O ponto de partida é um **recibo documental sintético**, derivado explicitamente da fixture documental anterior, com novo número/identidade e sem pagamentos. Não é uma nova factura produzida por um workflow vivo de Compras. As fotografias históricas usadas para preparar a fixture permanecem fora do reconhecimento desta origem. Não se declara uma composição operacional Compras → contabilidade nem se duplica autoridade de dívida entre hosts.
## Integração no código
`GET /general-ledger/flow?source=<origem-local>` reúne a cadeia por tenant/empresa/unidade, sob `REPEATABLE READ READ ONLY` quando dono da transacção. Exige as permissões existentes de consulta financeira e contabilística, pessoa financeira autorizada e contexto Contabilidade. Não executa comandos nem selecciona módulos ou associações por arrasto. Propostas e eventos financeiros são limitados à página da única origem; a captura contabilística mais recente e o lançamento são consultados por obrigação exacta, sem carregar o universo.
O botão na conferência financeira abre o percurso usando **POST /api/context existente**. A escolha explícita é auditada pelo mecanismo actual, verifica os módulos disponíveis e roda a geração do contexto. Não há troca silenciosa de identidade nem novo endpoint de concessão. O caminho de regresso à posição financeira conserva a origem.
O diário aceita `obligation_id`: valida existência no âmbito antes de consultar, filtra propostas, revisões, lançamentos, linhas, eventos de preparação/revisão/aplicação e totais. A origem é preenchida no formulário por esta referência; continua a exigir decisão e fundamento. Os comandos mantêm a revalidação actual, os bloqueios e a idempotência existentes. Não se alterou o escritor contabilístico, o cálculo financeiro ou os seis módulos de navegação.
## Provas novas e reutilizadas
[Manifesto verificável](proofs/sgf-financial-ledger-flow-20261102.json).
- **46 verificações HTTP** na base nova `0beaac7382b0`: sequência completa desde zero obrigações, selecção de módulos existente, contexto antigo 409, fonte indevida/parâmetros injectados 403/422, leitor sem escrita, falta de reconhecimento, falta de revisão, revisão obsoleta, repetições pela mesma/outra chave, conteúdo conflitante, fotografias, links, extracto e relatório financeiro.
- **20 verificações de isolamento/integridade**: tenant/empresa/unidade indevidos, revogação de cada direito de consulta, retirada de acesso financeiro, leitura coerente sem `FOR UPDATE`, recibos, identidades distintas e concessões QA. Sondagens de mutação dentro de transacções revertidas.
- **11 verificações de foco**: segundo efeito completo construído dentro de uma transacção, dois lançamentos visíveis sem filtro e apenas um conjunto de linhas/proposta/revisão/três tipos de evento por obrigação. Página vazia não repete resultados. O segundo efeito foi integralmente revertido; o original permanece.
- **22 verificações de preparação** na base independente `c24bf87a54fd`/demo 8049: três funções, páginas, selecção de módulo, cookies separados e ausência de efeitos de negócio. Não foram emitidos comandos financeiros ou contabilísticos nessa base.
Resultado da sequência HTTP: **uma obrigação de 60 MZN, um lançamento de débito 60/crédito 60, duas linhas e um evento de aplicação**. Repetir não duplica; o relatório financeiro continua com principal e saldo de 60 MZN. As consultas não liquidam a dívida.
O primeiro diagnóstico HTTP completou a escrita, mas detectou um link de retorno em falta na página de consulta. Foi conservado em `01661f3f404d`; não foi resetado, repetido ou contado como prova aprovada. O link foi corrigido e a sequência final foi executada numa base nova. Uma preparação do teste adicional de foco detectou importações PHP ausentes no auxiliar, antes de criar o segundo efeito; corrigida, sem alterar o motor. Provas concluídas recusam sobrescrita.
Reutilizados os ensaios anteriores: [núcleo contabilístico e concorrência](GENERAL_LEDGER_DELIVERY.md), [carga financeira 1.000/5.000/10.000 e páginas 25/100](SGF_PAYABLE_PAGED_READ_DELIVERY.md), [bloqueio de confirmações e leitura sem bloqueio](SGF_SETTLEMENT_READ_ISOLATION_DELIVERY.md), [fila administrativa, carga, concorrência e HTTP423](MODULE_ASSOCIATION_LOAD_CONSOLIDATION.md). Não se repetiram estas suites ou matrizes. Os manifestos anteriores permanecem intactos e têm hashes registados no novo manifesto. A sua fonte corresponde à versão ensaiada então; as alterações actuais de foco/ligação têm as provas novas acima.
Não foram experimentados índices nem realizados benchmarks nesta entrega. Os resultados anteriores mantêm as suas condições: a leitura financeira por página ficou em 67 consultas; o relatório integral de 10.000 posições ainda levou cerca de 12,6 s no ensaio. Isso não é capacidade de implantação validada. O campo `flow_query_count` do auxiliar de isolamento contém todas as consultas observadas, incluindo as sondagens posteriores; **não é uma medição de custo isolado da página** e não é usado como benchmark.
## Estado e limites concretos
| Capacidade | Estado desta consolidação |
|---|---|
| Conferência financeira → obrigação → revisão → lançamento → extracto/posição | Percurso funcional sintético integrado, entregue e demonstrável em 8049 |
| Writer único, revisão distinta, concorrência, falha/recuperação e repetição | Núcleo anterior preservado; evidência anterior reutilizada, integração/foco novos provados |
| Fila administrativa, leitura conjunta, índices de ensaio e HTTP | Entrega anterior consolidada; seis pendentes não decididos |
| Compra viva → composição SGF → reconhecimento contabilístico | Não entregue por esta passagem; origem actual é recibo sintético |
| Contabilizar liquidações, estornar/corrigir lançamento e fechar/reabrir períodos | Excluídos do primeiro efeito autorizado; não implementados por arrasto |
| Composição contabilística autónoma SGC | Adaptador/instalação não entregues nem activados |
| Fiscalidade, contas reais, operações empresariais e implantação | Pendências de implantação separadas, sem novos acessos/migrações reais |
| Revisão visual e carga sustentada | Pendentes separadas; HTTP não substitui revisão visual |
Novas concessões reais: zero. Direitos QA continuam os quatro já autorizados, seis concessões por fixture nova, sem herdar competências. Sem dependências novas, build público substituído, cache limpa, associação automática, activação de Compras, publicação, aceitação ou checkpoint. Assets já compilados e válidos foram copiados apenas entre storages privados; as mudanças novas são PHP/Blade, sem alteração React que exigisse repetir o build.
## Reprodução sem afectar demos
Node/PHP fixados no projecto. `scripts/ledger-run.mjs new sgf,ledger-flow-setup` cria outra base/role. Usar o tag retornado em `scripts/ledger-flow-http.mjs`, depois fases PHP `ledger-flow-cases` e `ledger-flow-focus-cases` de `scripts/payable-history-sgf-proof.php`. A base de regressão conserva o resultado; não a usar como demo vazio nem repetir a escrita.
Para outra demonstração, provisionar **outra fixture**, copiar apenas os assets privados já verificados e executar `scripts/ledger-demo.mjs <tag>`, depois `scripts/ledger-flow-demo-check.mjs <tag>`. Credenciais/settings/cookies/logs permanecem na área privada. Nenhum script deve usar as bases empresariais ou a cache protegida.
+42
View File
@@ -0,0 +1,42 @@
# Quantidades para conferência da factura — 03/11/2026
`SGF-INVOICE-MATCHING-PROGRESS-QA-v1` acrescenta a leitura das quantidades por linha no percurso de factura de fornecedor: encomendado, aceite, devolvido, estornado, aceite líquido, facturado/validado, em documentos pendentes e restante para conferir.
## Comportamento
A indicação usa as quantidades físicas originais, as correcções já calculadas pelos serviços nativos e duas consultas agregadas por linha para recepções e facturas. A quantidade disponível é o menor valor entre encomendado e aceite líquido, deduzido apenas do validado, com mínimo zero. Divergência entre validado e origem é visível separadamente.
**Pendentes não reservam quantidade.** Uma factura pendente acima do restante recebe uma indicação de divergência, sem ser eliminada, alterada ou considerada validada. A permissão de conferência distinta permanece a original: não é promessa de que a validação nativa vá aceitar o documento. Nenhuma quantidade é escolhida ou preenchida automaticamente.
O formulário apresenta o restante junto à linha escolhida e continua a exigir os dados e fundamento do documento. Preparação, validação, consumo orçamental, versões e idempotência permanecem nos serviços anteriores. Não há nova reserva, factura, obrigação, pagamento, movimento ou lançamento automático.
O estado da ordem é observado uma vez para o detalhe e para a sua revisão, antes de acrescentar as capacidades de interface; a chamada repetida foi removida. A definição do estado e os blocos `preview`/`confirm` mantêm-se iguais. A equivalência da revisão foi conferida contra esse estado original. O histórico da factura passa também a ignorar respostas atrasadas de páginas anteriormente pedidas.
O novo detalhe fica desligado por omissão em `receipt_qa.invoice_progress_enabled`; reutiliza os direitos e isolamento da consulta de facturas, sem direitos novos.
## Provas sem repetir comandos financeiros
[Consolidação técnica](proofs/invoice-matching-progress-20261103.json): **47 verificações**, sendo14 de origem disponível,16 do caso parcial já concluído,11 de lógica da interface e seis da demonstração.
Foram criadas aplicações de leitura novas sobre dois livros QA existentes, sem repetir emissão, recepção, facturas, validações ou reconhecimento:
- `invoice-progress-read-e286efd6035f` reutiliza `ce4c9bef1a51`: ordem/aceitação1UN, nenhuma factura; restante1000milésimos.
- `invoice-progress-read-34534a38acd7` reutiliza `da77bb730751`: facturas40+60 validadas, restante zero, documento70 ainda pendente e700milésimos visíveis separadamente.
Os conjuntos de tabelas e sequências de ambos os livros ficaram integralmente iguais antes/depois. Inclui recusas para Compras/Armazém, ordem estrangeira/desconhecida, unidade diferente e contexto antigo; modo desligado e equivalência da revisão. Os limites aritméticos com devolução/estorno e tecto da ordem foram ensaiados sem executar novas correcções físicas.
A interface simulada comprova três casas decimais exactas, recusa de valores fora da representação inteira segura, diferença explícita, aviso apenas para pendentes, ausência de reserva implícita/preenchimento automático, ligação ao módulo efectivo e abertura apenas GET. Uma resposta atrasada do histórico deixa de substituir a página mais recentemente solicitada.
Verificação final: sintaxe de sete ficheiros PHP e nove JavaScript aprovada; `git diff --check` sem erros de espaços. Inventário/matriz actualizados: seis módulos,13 etapas,89 fontes, zero ciclos obrigatórios e138 critérios documentais conferidos. Estes totais não são validação operacional dos módulos completos.
## Demonstração e preservação
Nova composição `9ec0a8e493ea`, [**8081 — Finanças**](http://127.0.0.1:8081/qa/enter/issuer?open=1), sem requisições/facturas principais iniciais. Conserva o percurso, anexos, histórico detalhado e atalhos anteriores. Probes de leitura preservaram os dados de requisição/factura/auditoria e concessões seleccionados; os módulos estáticos servidos coincidem com o código por SHA.
8080 e anteriores, seis registos protegidos, cache bloqueada, provas válidas e trabalho alheio preservados. Nenhuma migração/concessão real, activação de Compras, publicação, aceitação ou checkpoint.
## Limitações
A leitura agregada nova não elimina a materialização do estado completo de dependências para a revisão da ordem. A lista de facturas e as verificações de capacidade continuam a ter os limites anteriores; retirar a observação duplicada não comprova ganho de tempo/memória ou capacidade de implantação. Não foram repetidas medições de1000/5000/10000 ou ensaiados índices nesta entrega.
Os livros usados têm uma linha de ordem e os casos de facturação existentes. As fronteiras de devoluções/estornos são verificações aritméticas; as suites físicas completas mantêm a sua evidência própria. Não se introduz rejeição/correcção de factura ou nota de crédito. O DOM simulado não é revisão visual/acessibilidade no navegador. Não foi repetida a via bloqueada de navegador nem o build React/Vite inalterado: verificaram-se os módulos ES estáticos afectados. Visual, carga sustentada, instalação e capacidade de implantação continuam pendentes.
+45
View File
@@ -0,0 +1,45 @@
# Factura conferida → obrigação única na interface SGF
03/11/2026. `SGF-INVOICE-TO-PAYABLE-QA-v1`. Entrega local funcional: preparação explícita de uma fotografia da factura, prévia do impacto e reconhecimento por pessoa distinta, no mesmo livro nativo usado pela factura. Reutiliza `SupplierPayables` original e `packages/payables`; não importa uma cópia para o host nem cria outro escritor de dívida.
## Experimentar em8071
1. [Compras](http://127.0.0.1:8071/qa/enter/actor?open=1): emitir a ordem da adjudicação QA preparada.
2. [Armazém](http://127.0.0.1:8071/qa/enter/validator?open=1): receber1UN, aceite e conforme, para o artigo/armazém QA.
3. [Finanças — documento](http://127.0.0.1:8071/qa/enter/issuer?open=1): registar factura100MZN, base100, imposto0 e quantidade1UN.
4. [Finanças — conferência distinta](http://127.0.0.1:8071/qa/enter/reviewer?open=1): validar a factura pelo workflow original. Consome o compromisso existente; não reconhece automaticamente uma obrigação.
5. [Preparação da obrigação](http://127.0.0.1:8071/qa/enter/issuer?open=1&obligations=1): seleccionar a factura, fundamentar, conferir impacto e confirmar a preparação.
6. [Reconhecimento distinto](http://127.0.0.1:8071/qa/enter/reviewer?open=1&obligations=1): conferir a proposta exacta, fundamentar, ver impacto e confirmar. [Outra identidade QA](http://127.0.0.1:8071/qa/enter/auditor?open=1&obligations=1) permite conferência independente.
A demonstração começa sem ordens principais, recepções, facturas, propostas ou obrigações; não contém decisões tomadas pela equipa. Tem uma adjudicação aprovada/reserva100MZN preparada, orçamento QA1000MZN e uma ordem estrangeira apenas para isolamento. O ensaio de integração usou outra base nova. Portas8070/8069/anteriores não foram actualizadas.
## Implementação e fronteiras
- `/purchasing/obligations` e `/api/purchasing-obligations`: lista com pesquisa literal por número, estado documental, ordenação por ID e páginas25 no servidor. Documento individual apresenta origem/linhas, pendências, propostas, principal/saldo do núcleo e eventos, com páginas independentes25 para propostas/histórico.
- A conferência corrente lê a origem actual, a última captura, a responsabilidade existente e a identidade/permissões nativas; não materializa todo o universo de facturas ou o histórico completo de propostas.
- `ReceiptRuntime` mantém isolamento de empresa/unidade/origem e os bloqueios administrativos existentes. Os endpoints pertencem expressamente a Compras; o módulo Comercial não lhes dá acesso. A identidade de fonte é a mesma `qa-stock-ledger-<tag>` da preparação nativa e está fixada na ligação. Alterá-la recusa acesso; não abre outro saldo.
- Preparação incompleta fica `pending_source`. Preparar não reconhece dívida. A validação exige conta **e pessoa** distintas; não basta usar outra conta da mesma pessoa. Origem insuficiente ou incompatível permanece preservada, sem segunda obrigação.
- Prévia executa o mesmo comando num savepoint e reverte integralmente; sequências podem avançar. IDs temporários não são apresentados como factos persistidos. O corte cifrado prende actor, pessoa, documento, versão, contexto de sessão, ligação e validade10minutos.
- Confirmar volta a validar permissões, contexto, versão e fonte. O comando do núcleo guarda idempotência, propostas, obrigação, eventos/outbox; a proveniência SGF/fundamento/anterior/posterior entra na mesma transacção nativa. Falha de auditoria reverte tudo. Uma repetição chama novamente o núcleo e verifica elegibilidade actual, em vez de tratar um recibo histórico como autorização de nova execução.
- Principal100MZN reconhecido uma vez, saldo100MZN. Vencimento `null`, atraso indeterminado: o texto de condições comerciais não inventa uma data. O reconhecimento não repete consumo orçamental, movimento físico, pagamento ou lançamento contabilístico.
- Direitos `purchasing_obligations.view/prepare/validate` e correspondentes direitos do núcleo só nas novas fixtures55439. Configuração desligada por omissão; catálogo público e concessões reais intactos. Não foi activada importação histórica.
## Provas
[Prova consolidada](proofs/supplier-obligation-composition-20261103.json): **72 verificações** — integração35, repetição com origem alterada3, fronteiras6, guardas6, navegação6, demonstração13 e build/assets3. PHP/JavaScript verificados separadamente. As35 provas funcionais precederam a regra adicional de pertença modular; as6 provas posteriores testaram a fronteira alterada, preservando os factos financeiros, sem repetir a suite válida.
O percurso real de teste emitiu ordem, recebeu material, registou documento pendente e validou-o pelo serviço original. Captura pendente não reconheceu; mudança efectiva para factura validada recusou a prévia antiga e exigiu nova captura. Dois pedidos simultâneos da mesma preparação deram201/200; dois validadores concorrentes deram201/409. Falha forçada na auditoria reverteu obrigação/comando/eventos/outbox, e a operação seguinte recuperou. Houve um só evento de reconhecimento.
Revogação de direitos no host e no núcleo, conta da mesma pessoa, contexto de unidade/módulo, geração de sessão, ligação alterada e identidade de fonte divergente foram recusados. A prova adicional de repetição usa o adaptador original e injecção adversarial de um marcador de validação **apenas dentro de transacção revertida**; não é uma segunda validação comercial. Recusou origem alterada e preservou exactamente o principal e a evidência anteriores.
As77 verificações anteriores de facturas e83 de geografia foram reutilizadas como provas anteriores preservadas, sem nova execução nem recontagem como testes deste incremento. Sem leitura da base de destino, alteração dos seis registos, limpeza da cache bloqueada, alteração de demos antigas, publicação, aceitação ou checkpoint.
A [conferência actualizada da matriz](proofs/invoice-payable-matrix-20261103.json) mantém seis módulos,13estágios e98critérios documentados. A [inspecção estática do fluxo](proofs/invoice-payable-flow-inspection-20261103.json) encontra67fontes e nenhum ciclo obrigatório; não constitui teste operacional adicional. Passaram17 verificações de sintaxe PHP,8 de JavaScript e `git diff --check`.
## Limitações que continuam abertas
Revisão visual não realizada; não se repetiu o navegador bloqueado. Carga sustentada, implantação geral e separação de proprietário/runtime não estão validadas por estes ensaios. O schema sintético é preparado pelo projecto nativo actual, mas o runtime continua fixado ao pacote anteriormente provado; isto não demonstra compatibilidade de instalação no destino.
Pagamento/afectação, histórico importado, reconciliação e contabilização desta nova origem continuam passos próprios. Não há ligação automática ao percurso histórico de11600MZN nem declaração de passagem de responsabilidade de dívidas reais. Correcção/rejeição/notas de crédito e anexos binários de facturas conservam as limitações anteriores.
A demo8071 parte de adjudicação QA preparada. Requisições/propostas/geografia já entregues mantêm demonstrações separadas; ainda não se declara uma instalação única que reúna todas essas fixtures. A aplicação completa e a capacidade de implantação não são declaradas concluídas por este incremento.
+51 -1
View File
@@ -1,5 +1,33 @@
# Preparação dos módulos e do fluxo integrado SGF # Preparação dos módulos e do fluxo integrado SGF
**Compras concluído no percurso local acordado —04/11/2026:** [C01–C10 na instalação existente8085](PURCHASING_COMPLETION.md), entrada única `/compras`, fornecedor novo até obrigação100MZN, com decisões distintas e movimento1UN. Não criar novas demonstrações por continuação. As referências antigas de instalações abaixo são históricas. Esta conclusão funcional local não equivale a implantação empresarial, pagamentos/contabilidade dessa origem ou aceitação visual.
**Stock valorizado sintético entregue:** [entrada100/saída50/devolução25 no mesmo núcleo e consulta SGF SELECT-only](GENERAL_LEDGER_STOCK_VALUATION_DELIVERY.md). Fonte física original, taxa/revisão distintas, sequência e guardas SQL, relatórios/fecho QA; sem duplicar custos ou instalar no destino. Revisão visual/carga sustentada/implantação permanecem separadas.
**Conferência de03/11/2026:** [matriz × código × provas e pendências reais](MODULE_IMPLEMENTATION_AUDIT.md). As notas datadas abaixo são históricas: o primeiro motor contabilístico e os ciclos QA aprovados já foram entregues. A matriz reconciliada distingue esses percursos de composição operacional, revisão visual e implantação ainda pendentes.
**Correcção industrial autorizada e entregue:** [par1800→1820, residual300 separado, relatórios e fecho conjunto](GENERAL_LEDGER_INDUSTRIAL_CORRECTION_DELIVERY.md). Guardas SQL dos três domínios correctivos preservadas, origens tipadas, versões/direitos/concorrência verificados. Demo8059 nova, sem correcção inicial;8058 intacta. Sem cadeias adicionais, período fechado/reabertura, stock/salários ou implantação validada.
Continuação de leitura financeira: [perfilagem e captura validada por janela](SGF_PAYABLE_REPORT_CAPTURE_DELIVERY.md), sem novo efeito. Conteúdo financeiro equivalente em10.000 posições; melhoria local observada22,0→12,9s, ainda com gargalo. Comandos, permissões, provas/demos/seis registos/cache preservados; estatísticas alteradas apenas na cópia QA, sem índices ou destino.
**Ligação industrial autorizada e implementada:** [resultado1800/10UN, residual300 separado, diário/relatórios/fecho conjunto](GENERAL_LEDGER_INDUSTRIAL_DELIVERY.md). Mesmo escritor, origem tipada estável, versões/direitos e concorrência verificadas; demo8058 própria sem lançamento industrial inicial. Sem stock valorizado, salários, correcção industrial ou reabertura. Preservadas8057 e anteriores; revisão visual, carga sustentada e implantação separadas.
**Composição financeira integrada entregue:** [vendas/recebíveis, fornecedor e correcção de custo no mesmo livro e período QA](GENERAL_LEDGER_COMPOSITION_DELIVERY.md). Painéis paginados por origem, direitos próprios e fecho conjunto revalidado; demo8057 sem decisões iniciais consumidas. Provas e demos anteriores preservadas. Lote/CIF industrial contabilizado exige a decisão do [recorte seguinte](GENERAL_LEDGER_INDUSTRIAL_SCOPE.md), ainda só proposto.
**Correcção de custo seleccionada concluída no âmbito autorizado:** [par275→295, relatórios e fecho QA](GENERAL_LEDGER_DIRECT_COST_CORRECTION_DELIVERY.md), núcleo único, quatro linhas atómicas e SQL contra partes isoladas. Provas de versões/concorrência e coexistência com correcções de fornecedor no mesmo livro; fontes/outbox preservadas. Demo8055 separada;8054 mantém o modo anterior. Não declara reabertura, cadeias adicionais de revalorização, stock/salários ou implantação concluídos.
**Ligação de custo directo autorizada e implementada:** [oficina → custo directo275 → diário/relatórios/fecho QA](GENERAL_LEDGER_DIRECT_COST_DELIVERY.md). Mesmos workflows técnicos/custos e writer; identidade estável por ordem/perímetro, incompletude/versões/concorrência verificadas. Demo8054 própria, sem decisões contabilísticas iniciais. Não abrange valorização de stock, salários, correcção de custo já lançado ou uma instalação operacional completa dos seis módulos.
**Continuação de consulta integrada concluída:** [balancete e razão por conta SGF/SGC](GENERAL_LEDGER_REPORTS_DELIVERY.md), abertura/movimentos/fecho por datas, páginas25/100, saldo acumulado e fontes ligadas. Reconciliação só de leitura dos ciclos de vendas, reversão/correcção de fornecedor e coexistência cliente/fornecedor, sem repetir escritas. Não autoriza contabilidade de stock/custos/salários nem declara relatórios fiscais ou implantação concluídos.
**Continuação de 03/11/2026 concluída no recorte autorizado:** [Comercial → Recebíveis → Contabilidade](GENERAL_LEDGER_SALES_RECEIVABLE_DELIVERY.md), emissão efectiva de teste1.000, reconhecimento de três linhas, afectações400/600, reversão400 e fecho QA. Fornecedores preservados no mesmo writer; IDs de origens de cliente/fornecedor explicitamente separados e fecho conjunto comprovado. Nova demonstração8053, sem efeitos financeiros iniciais. As descrições históricas de vendas/recebimentos ainda sem contabilização ficam superadas apenas neste ciclo sintético; outras origens, revisão visual, carga sustentada e implantação permanecem separadas.
**Continuação posterior autorizada e concluída:** [ciclo contabilístico sintético nas duas composições](GENERAL_LEDGER_COMPLETE_CYCLE_DELIVERY.md), com afectação/reversão20, par correctivo integral e fecho QA sobre reconhecimento60. Demos próprias SGF8050/SGC8051, sem tocar instalações anteriores. Esta entrega substitui apenas as indicações históricas abaixo de composição nativa/correcção/fecho ainda não implementados no recorte sintético; uso real, outras origens, revisão visual e implantação continuam separados. [Inventário estático actualizado](proofs/module-flow-ledger-cycle-update-20261102.json): seis módulos,13etapas,29fontes,zero ciclos obrigatórios e nenhuma ligação à base; não constitui prova de integração operacional.
Continuação contabilística autorizada e implementada: [primeiro lançamento sintético de obrigação reconhecida](GENERAL_LEDGER_DELIVERY.md), núcleo único, duas contas/período artificiais, conferência distinta e aplicação única. Oito cenários/74 asserções, concorrência real e 23 verificações HTTP; não declara contabilidade operacional, composição nativa ou implantação.
Estado financeiro mais recente: [consulta de liquidação SGF separada dos bloqueios de escrita](SGF_SETTLEMENT_READ_ISOLATION_DELIVERY.md), sobre o [histórico de pagamentos/afectações paginado](SGF_SETTLEMENT_PAGED_READ_DELIVERY.md) e a [conferência ligada à origem](SGF_PAYABLE_EVIDENCE_FOCUS_DELIVERY.md). Preserva o motor de posição e a autoridade de escrita. As medições de documentos em 1.000/5.000/10.000 e de pagamentos em 1.000 são ensaios distintos; não declaram cutover operacional, Financeiro completo ou implantação.
Continuação financeira SGF: [recibo histórico de Compras para o núcleo comum](SGF_PAYABLE_HISTORY_RECEIPT_DELIVERY.md), implementado somente em bases novas de ensaio, com 10 cenários/56 asserções e 17 verificações HTTP. A fotografia histórica não constitui fonte viva, cutover operacional ou Financeiro completo; mantém paginação/carga financeira e implantação pendentes. Continuação financeira SGF: [recibo histórico de Compras para o núcleo comum](SGF_PAYABLE_HISTORY_RECEIPT_DELIVERY.md), implementado somente em bases novas de ensaio, com 10 cenários/56 asserções e 17 verificações HTTP. A fotografia histórica não constitui fonte viva, cutover operacional ou Financeiro completo; mantém paginação/carga financeira e implantação pendentes.
Continuação SGF mais recente: [encerramento individual de propostas Oficina → Stock](WORKSHOP_STOCK_PROPOSAL_CLOSURE.md), com retirada/recusa sem efeito físico e protecção contra confirmação concorrente. Não encerra as etapas financeiras/contabilísticas ainda em falta ou as pendências de implantação/visuais. Continuação SGF mais recente: [encerramento individual de propostas Oficina → Stock](WORKSHOP_STOCK_PROPOSAL_CLOSURE.md), com retirada/recusa sem efeito físico e protecção contra confirmação concorrente. Não encerra as etapas financeiras/contabilísticas ainda em falta ou as pendências de implantação/visuais.
@@ -21,7 +49,7 @@ Reutiliza o [plano de núcleo comum](PURCHASING_COMMON_CORE_PLAN.md), a [demonst
| Oficinas | Ordem/autorização, apontamentos verificados, custo e conversão comercial existentes | Ligar consumo físico por origem e versão, sem transformar observações anteriores em movimentos | | Oficinas | Ordem/autorização, apontamentos verificados, custo e conversão comercial existentes | Ligar consumo físico por origem e versão, sem transformar observações anteriores em movimentos |
| Comercial | Cadastro, rascunhos, B1, cálculos e emissão/PDF de teste | Preservar revisão comercial e origens; completar ligações e validação fiscal própria antes de uso real | | Comercial | Cadastro, rascunhos, B1, cálculos e emissão/PDF de teste | Preservar revisão comercial e origens; completar ligações e validação fiscal própria antes de uso real |
| Financeiro | Núcleos comuns de orçamento e obrigações; recebimentos, pagamentos/afectações e reversões sintéticos | Compor os percursos, delimitar reconciliação e fontes históricas; não criar um segundo saldo | | Financeiro | Núcleos comuns de orçamento e obrigações; recebimentos, pagamentos/afectações e reversões sintéticos | Compor os percursos, delimitar reconciliação e fontes históricas; não criar um segundo saldo |
| Contabilidade | Classificação, perfis fiscais e menus | Motor de lançamentos, períodos, razão, fecho e relatórios com fontes rastreáveis; implementação ainda em falta | | Contabilidade | Classificação/perfis/menus e primeiro núcleo de lançamento sintético por obrigação | Primeiro diário/extracto QA entregue; contas/reconhecimento reais, composição nativa, correcções, fecho e relatórios gerais continuam por delimitar/implementar |
**Actualização importante face ao menu antigo de Stock:** a inspecção actual encontrou código de lotes, contagens, devoluções/abates, reservas e estornos em Compras. A ausência dessas ligações no SGF não significa ausência na origem. Os hashes e caminhos estão no [inventário de preparação](proofs/module-flow-preparation-20261102.json). São evidência de existência de fonte, **não prova de execução, cobertura integral ou activação dessas capacidades**. A extracção deverá seguir essas dependências, não copiar apenas `StockWorkflow` isoladamente ou reconstruir funcionalidades já existentes. **Actualização importante face ao menu antigo de Stock:** a inspecção actual encontrou código de lotes, contagens, devoluções/abates, reservas e estornos em Compras. A ausência dessas ligações no SGF não significa ausência na origem. Os hashes e caminhos estão no [inventário de preparação](proofs/module-flow-preparation-20261102.json). São evidência de existência de fonte, **não prova de execução, cobertura integral ou activação dessas capacidades**. A extracção deverá seguir essas dependências, não copiar apenas `StockWorkflow` isoladamente ou reconstruir funcionalidades já existentes.
@@ -119,3 +147,25 @@ Executar com o Node fixado: `C:/laragon/tools/sgf-tempo/node-v24.22.1-win-arm64/
Não se alteraram rotas, navegação, motores, configurações, dependências, schema ou direitos. Não se consultaram bases, manifestos privados ou credenciais. Os seis registos protegidos, a cache bloqueada, 8039 e outras demonstrações foram preservados. Compras foi lido apenas como fonte; sem alteração/activação de candidata. Sem publicação, push, checkpoint ou aceitação presumidos. Não se alteraram rotas, navegação, motores, configurações, dependências, schema ou direitos. Não se consultaram bases, manifestos privados ou credenciais. Os seis registos protegidos, a cache bloqueada, 8039 e outras demonstrações foram preservados. Compras foi lido apenas como fonte; sem alteração/activação de candidata. Sem publicação, push, checkpoint ou aceitação presumidos.
**Pendências independentes:** implementação das ligações delimitadas; provas funcionais por incremento; revisão visual; validação de capacidade/implantação; decisões individuais das seis associações. O inventário de fontes não encerra nenhuma delas. **Pendências independentes:** implementação das ligações delimitadas; provas funcionais por incremento; revisão visual; validação de capacidade/implantação; decisões individuais das seis associações. O inventário de fontes não encerra nenhuma delas.
## 02/11/2026 — consulta financeira nativa ligada em QA
[Consulta paginada SGC](NATIVE_PAYABLE_PAGED_READ_DELIVERY.md): facturas/pagamentos/auditorias nativos, dependências conjuntas por página e motor financeiro comum.9testes/113asserções,26casos afectados/183asserções e18verificaçõesHTTP; medições1.000/5.000/10.000 disponíveis. Fonte histórica viva somente nativa QA; SGF mantém recibo de ensaio. Flag nativa false e pacote instalado preservado. Sem activação/cutover, aceitação presumida, publicação ou alteração dos seis registos/cache. Revisão visual, carga sustentada e implantação separadas.
## 02/11/2026 — conferência da origem e selecção financeira ligadas
A [conferência financeira por origem](SGF_PAYABLE_EVIDENCE_FOCUS_DELIVERY.md) permite abrir documento/posição/propostas/eventos fora da primeira página e conservar o foco na ligação à selecção para pagamentos. Corrigida a selecção de obrigações que ficava limitada à primeira página, e a evidência documental passou a usar a proposta exacta do reconhecimento, independentemente das versões visíveis. Cinco cenários/34 asserções, integração dirigida/18 asserções e 20 verificações HTTP da conferência por origem. Nenhum pagamento ou afectação criado; fingerprint financeiro anterior integralmente preservado. O livro de pagamentos mantém a consulta própria, ainda sem paginação; não confundir este incremento com ligação histórica SGF viva, cutover, contabilidade ou implantação. Revisão visual e carga sustentada continuam separadas. Seis registos e cache preservados.
## 02/11/2026 — histórico de liquidação SGF paginado
[Pagamentos e afectações paginados](SGF_SETTLEMENT_PAGED_READ_DELIVERY.md): quatro janelas para pagamentos/propostas/afectações/eventos, filtros pelos pagamentos antes da contagem e leitura conjunta de decisões/reversões. Montantes por afectar consideram todo o histórico, independentemente da janela. Filhos ligam ao pagamento e à origem reconhecida; filtros de documentos e de pagamentos permanecem distintos. Seis cenários/53 asserções, 29 verificações HTTP e complemento de navegação. Em 1.000 pagamentos sintéticos: 3.038 → 35 consultas, sem índices novos. Não substitui os ensaios de 1.000/5.000/10.000 documentos. Bloqueios de autorização existentes mantidos; concorrência sustentada, revisão visual e implantação não validadas. Fonte histórica SGF viva/cutover, banco/reconciliação e contabilidade continuam separados. Demos, seis registos/cache e direitos reais preservados; composição nativa instalada não activada.
## 02/11/2026 — consulta de liquidação com autorização própria de leitura
[Leitura financeira SGF sem bloquear empresa/actor](SGF_SETTLEMENT_READ_ISOLATION_DELIVERY.md): callback exclusivo para consulta paginada, fotografia REPEATABLE READ READ ONLY por pedido, comandos e construtores antigos conservados. Quatro cenários/23 asserções e 11 verificações HTTP dirigidas. Três processos comprovaram: leitor conclui antes da libertação, leitura antiga e confirmação aguardam em companies FOR UPDATE (cadeia directa ou pela fila), confirmação posterior/repetição com um único efeito; todas as alterações revertidas. Fingerprint financeiro e artefactos das provas anteriores preservados. Não repetir benchmarks válidos nem confundir esta espera com medição de throughput/capacidade. Composição nativa instalada, seis registos/cache e demos intactos. Revisão visual, implantação e capacidades financeiras ainda não entregues continuam separadas.
## 02/11/2026 — livro financeiro ligado ao relatório e fronteira contabilística
[Relatório de obrigações reconhecidas](SGF_PAYABLE_REPORT_DELIVERY.md) implementado: filtros/âmbito comuns, captura coerente, todas as posições em janelas de 100, componentes reconciliados e saldo global indeterminado quando existe evidência divergente. Primeiro percurso completo de relatório do domínio financeiro; não razão/balancete. 10.000 posições com conteúdo igual antes/depois da optimização, 6.607 → 2.313 consultas, 19,1 → 12,6 s, 34 → 32 MiB. Tempo residual continua limitação e requer perfilagem; sem índices novos ou capacidade de implantação declarada. Três cenários finais/17 asserções, livro de afectações reconciliado e 17 verificações HTTP; provas e fingerprints anteriores preservados.
O primeiro lançamento exige decisão própria: [COMMON-LEDGER-PAYABLE-SIM-v1](GENERAL_LEDGER_FIRST_SYNTHETIC_SCOPE.md), principal 60 MZN, duas contas e período artificiais, preparação/revisão distintas, aplicação atómica/única, quatro direitos exclusivamente na fixture QA. Está apenas especificado, sem motor/schema/direitos/lançamentos implementados, por preservar a exclusão de lançamentos por arrasto do AGENTS.md. Não antecipar autorização, aceitação, checkpoint, activação de Compras ou publicação. Seis registos/cache/demos intactos; revisão visual e implantação separadas.
+31
View File
@@ -0,0 +1,31 @@
# Evidência e decisão individual da obrigação — 03/11/2026
`SGF-OBLIGATION-EVIDENCE-WORKSPACE-QA-v1` separa a paginação da evidência do formulário de decisão. Consultar outra página de propostas ou eventos deixa de reconstruir o ecrã e apagar o fundamento/prévia preparados.
## Comportamento
O utilizador selecciona explicitamente uma proposta para conferência. O seu formulário permanece numa área própria, mesmo que a proposta deixe de estar na página visível. Voltar a seleccionar a mesma proposta não reinicia o fundamento. Seleccionar outra proposta inicia intencionalmente uma decisão distinta; não existe escolha automática da primeira proposta.
Propostas e histórico mantêm as páginas pretendidas independentemente. As respostas atrasadas são ignoradas, evitando que uma consulta antiga faça outra área regressar à página anterior. A paginação só consulta o endpoint existente; não prepara, reconhece ou duplica uma obrigação.
Uma nova leitura que observe revisão, actor ou permissões diferentes desactiva as conferências antigas e pede actualização explícita. O fundamento permanece visível. Uma prévia atrasada não volta a activar o formulário desactualizado nem permite confirmar por esse formulário. Não se muda de proposta durante uma operação em curso. Falhas transitórias de leitura permitem repetir a consulta sem perder o fundamento; recusas de acesso/contexto exigem nova conferência.
A confirmação efectiva continua no controlador/runtime/núcleo comuns, com as validações actuais completas. Esses três ficheiros foram comparados por SHA e permanecem iguais aos de8081. Não existem direitos, escritores ou endpoints financeiros novos. O bloco legado de apresentação foi preservado; a nova área usa `receipt_qa.obligation_evidence_enabled`, desligada por omissão.
## Provas
[Consolidação](proofs/obligation-evidence-workspace-20261103.json): **26 verificações novas** —20 de lógica da interface e seis da demonstração. As72 verificações válidas do [reconhecimento nativo](proofs/supplier-obligation-composition-20261103.json) foram reutilizadas, não repetidas nem somadas às26.
Os testes usam o componente e o módulo efectivo com DOM/respostas simuladas: selecção individual, fundamento/prévia preservados, mudança explícita de proposta, pares de páginas, resposta antiga, revisão/permissão diferente, acesso recusado, erro transitório, proponente sem revisão própria, obrigação já reconhecida, operação em curso e prévia pendente que chega depois de a origem mudar. Nesse último caso não é enviado um pedido de confirmação pelo formulário obsoleto.
Os probes HTTP da demonstração conferem autorização, flag activa, lista inicial vazia, módulos servidos por SHA e preservação de propostas/obrigações/eventos/auditoria/concessões. Não se afirma nova confirmação financeira real por esta prova de interface. O dispatcher do launcher foi ajustado para reconhecer o resultado desta nova prova, preservando as seis verificações já terminadas, sem as repetir.
Verificação final: sintaxe de três ficheiros PHP e oito JavaScript aprovada; `git diff --check` sem erros de espaços. Inventário/matriz actualizados: seis módulos,13 etapas,90 fontes, zero ciclos obrigatórios e140 critérios documentais conferidos. Estes totais não são testes operacionais adicionais ou validação da aplicação completa.
## Demonstração e limites
Nova composição `f3ce5f53dab4`, [**8082 — Finanças**](http://127.0.0.1:8082/qa/enter/issuer?open=1), sem operações principais, propostas ou obrigações iniciais. Conserva os percursos, quantidades de conferência, histórico detalhado, anexos e atalhos anteriores.
8081 e anteriores, seis registos protegidos, cache bloqueada, provas e trabalho alheio preservados. Sem migração/concessão real, activação de Compras, publicação, aceitação ou checkpoint.
O teste de decisões persistentes/idempotência mantém a prova nativa anterior, com a mesma autoridade. Os novos testes de persistência do formulário e respostas atrasadas são simulações de interface, não revisão visual, acessibilidade ou nova validação financeira. Actualizar a origem inicia novas conferências; o formulário antigo não é reaplicado automaticamente. O novo painel não é optimização de carga do backend nem novo protocolo de paginação. Visual, carga sustentada, instalação e capacidade de implantação continuam pendentes.
+55
View File
@@ -0,0 +1,55 @@
# Ordens de compra e recepção no SGF — bloco funcional QA
03/11/2026. Entregue o percurso **adjudicação aprovada → conferência da ordem → emissão única → recepção por Armazém → movimento físico → histórico**, na mesma composição SGF sintética. O fornecedor e as condições já aprovadas não são redigitados ou escolhidos automaticamente pela interface.
## Experimentar
Nova demonstração em8065, sem ordem emitida, compromisso, recepção ou movimento iniciais. A adjudicação sintética de100MZN, 1UN, imposto zero, foi preparada e aprovada distintamente pelos workflows originais. O orçamento canónico tem100MZN reservados; a emissão ainda depende da decisão individual.
- [Compras — conferir e emitir](http://127.0.0.1:8065/qa/enter/actor?open=1).
- [Armazém — receber depois da emissão](http://127.0.0.1:8065/qa/enter/validator?open=1).
- [Consulta da adjudicação e aprovações](http://127.0.0.1:8065/qa/enter/issuer?open=1), sem emissão.
Em Compras, abrir a adjudicação, conferir fornecedor, montante, linhas, entrega e aprovações; abrir o impacto e emitir explicitamente. A fotografia original passa a estar disponível e a acção de emissão desaparece. Repetir uma resposta perdida recupera a mesma ordem.
Entrar depois como Armazém, abrir a ordem em Recepção, preencher guia/data, escolher o armazém e o artigo QA, marcar a linha e indicar1.000 recebida/aceite, qualidade Conforme. Conferir e confirmar. O histórico referencia a ordem efectivamente emitida e o único movimento físico. O emissor não recebe a própria ordem; Armazém não emite ordens.
Estes links de entrada só existem no launcher privado de loopback. A8064 e anteriores permanecem disponíveis no seu âmbito original, sem reset ou substituição. A8065 usa bases, cookie e armazenamento próprios.
## Código funcional
`OrderController` reutiliza `PurchaseWorkflow::canIssue/issue`, `ProposalComparison`, políticas e orçamento originais. Reutiliza também a composição local já entregue para recepções. Não se criou outro motor de selecção, pontuação, orçamento ou stock.
A composição com namespace próprio conserva o pacote original de modelos/serviços. As duas dependências económicas efectivamente usadas, `ProposalScoring` e `QuotationPricing`, são carregadas byte a byte da fonte original, com manifesto separado e recusa de versão incompatível; não se altera o pacote de Compras instalado. A primeira tentativa revelou esta dependência em falta antes de qualquer emissão e foi preservada. Após a ligação exacta, o percurso integrado passou.
O host exige `purchasing_orders.view` ou `.issue`, direitos explícitos de unidade criados somente nas fixtures QA. A política nativa também permanece obrigatória: consulta por funções elegíveis, emissão por Procurement, validação actual de aprovações, conflitos, propostas, fornecedor, montantes e orçamento. Direitos de recepção são distintos. O catálogo/concessões públicos não foram alargados.
A prévia cifrada vincula operador, contexto, adjudicação, revisão dos dados e versões da composição/bibliotecas, com prazo de dez minutos. Na confirmação, a revisão é recalculada e o workflow original revalida a emissão. O documento, linhas, compromisso e auditorias original/SGF pertencem à mesma transacção nativa. Falhar a proveniência desfaz toda a operação. Repetições devolvem a fotografia emitida, sem criar outra ordem ou compromisso.
A unidade SGF está ligada explicitamente à requisição/adjudicação de origem e ao armazém. Uma ordem criada dessa requisição torna-se elegível para a recepção nesse mesmo contexto; não depende de editar depois uma lista de IDs. Isto não associa registos antigos a módulos nem procura equivalências por números ou nomes.
O compromisso pertence exclusivamente ao orçamento canónico da origem. Não é duplicado no orçamento do host SGF; as concessões orçamentais auxiliares da fixture do host foram retiradas por não pertencerem a este bloco. A passagem de uma requisição SGF para a adjudicação nativa ainda precisa do seu contrato próprio. O campo legado `budget_checked=false` do snapshot original não foi reescrito: a evidência efectiva está em `budget_control` e no compromisso canónico provado.
## Verificação do bloco
[Manifesto, provas e hashes](proofs/order-composition-20261103.json):49 verificações de auxiliares, sem contar como49 percursos de negócio independentes.
| Grupo | Verificações | Resultado |
| --- | ---: | --- |
| Percurso integrado HTTP |28| Consulta/prévia, controlo de funções, fonte alterada, permissões revogadas, aprovação inelegível, falha/rollback, dois workers, ordem/compromisso únicos, recepção e histórico |
| Guardas de activação |3| Desligado e produção recusam antes de SQL |
| Demonstração sem consumir decisões |9| Perfis exactos, páginas/scripts e fingerprint canónico preservado; zero ordens, compromissos e movimentos |
| Navegação React |6| Capacidade, direito, unidade, desligado e preservação da ligação de recepções |
| Build privado servido |3| Página inicial e assets JS/CSS correspondem ao build |
Na regressão, uma falha injectada na auditoria SGF deixou ordens, linhas, compromisso e auditoria exactamente no estado anterior. Retirada a causa, dois workers/sessões independentes obtiveram201/200 para a mesma emissão. Ficaram uma ordem, um compromisso100MZN e uma auditoria de cada tipo. A recepção posterior criou um só movimento de1UN e completou a ordem, sem factura ou consumo orçamental por arrasto.
Foram usadas as provas anteriores de recepções e dos motores comuns. A validação nova concentra-se nas fronteiras alteradas e no percurso completo; não foram repetidas suites gerais, avaliações de carga ou provas da fila administrativa.
## Limites e preservação
O bloco parte de uma adjudicação efectivamente aprovada em fixture. A interface SGF de propostas, selecção e aprovação de adjudicações não está entregue por este incremento; não se inventam aprovações para emitir. A demonstração também não é uma instalação operacional de todo o procurement nem uma validação de fiscalidade real.
Revisão visual, carga sustentada, implantação e separação SQL owner/runtime continuam pendentes. As roles novas são proprietárias apenas das suas bases QA. Não há prova de execução Octane com workers persistentes. O selector de artigos da recepção mantém o limite conhecido de100 itens.
Configuração de ordens desligada por defeito; activá-la não dispensa a guarda integral QA. Sem publicação, activação de Compras no destino, contas/acessos reais, pagamentos, contabilização, alteração da ligação stock–custos ou checkpoint. Seis registos protegidos, cache bloqueada, demonstrações/provas válidas e trabalho alheio preservados.

Some files were not shown because too many files have changed in this diff Show More