Proposta #= $e($p->id) ?> · captura = $e($p->capture_version) ?>
Preparador #= $e($p->prepared_by) ?> · = $e($p->created_at) ?>
= $e($p->reason) ?>
Fotografia e pendências
= $e($p->snapshot) ?>
= $e($p->missing) ?>
diff --git a/AGENTS.md b/AGENTS.md index ffdd819..ff4acb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # SGF Tempo — instruções locais +- 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. + +- Continuação autorizada no projecto Compras em 29/10/2026: `PAYABLE-HISTORY-IMPORT-v1`, passagem explícita de histórico original para o núcleo comum, com principal integral, dedução única de confirmados e pendentes/rejeitados preservados sem abatimento. Preparação/validação distintas, unicidade, idempotência, auditoria e append-only. Ver `C:/laragon/www/compras/docs/passagem-historico-pagamentos.md`. Provas em bases separadas; 000022 e activação histórica não aplicadas por consequência do desenvolvimento. Preservar demos, contas/dados reais e menus. Não executar novos pagamentos, confirmar pendentes, reconciliar, alterar stock/orçamento ou contabilizar por arrasto. O adaptador SGF anterior permanece sem fornecedor de histórico; não declarar ligação histórica SGF entregue. Substitui apenas a exclusão absoluta anterior de desenvolver uma passagem de pagamentos, mantendo a recusa no percurso antigo e enquanto a nova capacidade estiver desactivada. + - Implementação autorizada em 29/10/2026: `COMMON-PAYABLE-SOURCE-SIM-v1`, cenário aprovado 10 000+1 600=11 600 MZN, fonte única `packages/payables`, factura efectivamente conferida por Compras, preparação/validação distintas e consulta nas duas composições QA. Principal reconhecido com vencimento/dias nulos e atraso indeterminado. Ver `docs/SUPPLIER_PAYABLE_DELIVERY.md`. Pagamentos anteriores impedem incorporação integral; preservar originais/histórico e passagem de responsabilidade sem segundo saldo. Bases/roles próprias, sem alterar demos em 8002/8003 ou dados/acessos reais. Sem pagamentos/cobranças/afectações/stock/orçamento adicional/contabilidade, dependências novas, push/publicação, aceitação ou checkpoint presumidos. - Orientação comum de produto da Tempo, 29/10/2026: oito regras de documento/dívida/liquidação, condições explícitas, afectações parciais, confirmação/reconciliação, idempotência, correcções, cobrança configurada e responsabilidade financeira única registadas em `docs/SUPPLIER_PAYABLE_SCOPE.md`, secção 14. São separadas da fiscalidade específica e não declaram capacidades futuras entregues. O recorte actual permanece só obrigação a fornecedor; sem pagamentos/cobranças/lançamentos por arrasto. Vencimento desconhecido é pendência documental visível, não data inventada, dívida apagada ou pré-condição universal de reconhecimento criada por escolha técnica. Preservar a avaliação orçamental em paralelo. diff --git a/docs/ACCOUNTING_MENU_DELIVERY.md b/docs/ACCOUNTING_MENU_DELIVERY.md new file mode 100644 index 0000000..98c94f9 --- /dev/null +++ b/docs/ACCOUNTING_MENU_DELIVERY.md @@ -0,0 +1,47 @@ +# Menus de Contabilidade + +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 + +Pedido expresso «acrescentar», na sequência dos menus de Facturação, Oficinas e Stock. Fonte lida: `C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/ERP TEMPO/Menus de contabilidade.docx`, 186 blocos de texto/tabelas, original preservado. + +O documento foi tratado como referência funcional. As recomendações comerciais sobre Odoo/Cegid, a proposta de contratar demonstrações e a matriz Gestor/Contabilista/Tesoureiro não foram executadas como instruções do responsável. Não foram adoptadas afirmações de conformidade, verificadas ofertas de terceiros, contactados fornecedores ou atribuídas permissões por perfil. As fontes/regras fiscais já aceites no projecto mantêm os seus âmbitos e versões. + +## Disponibilidade + +Abrir **http://127.0.0.1:8002/avaliar**, escolher **Preparar e gerir** e abrir **Contabilidade e fiscalidade**. + +| Área | Conteúdo acrescentado | Ligação ao existente / limite | +| --- | --- | --- | +| Painel contabilístico | Encaminhamento para as cinco áreas e percursos já disponíveis. | Sem indicadores ou contagens fictícias de pendências/fecho. | +| Trabalho diário | Lançamentos; modelos e recorrências; origem documental. | Não há motor de lançamentos. Liga aos rascunhos e obrigações apenas com autorização da unidade actual. | +| Conferência | Conferência/reconciliação; auditoria/arquivo. | Liga ao recebimento simulado e ao histórico de classificação quando autorizados; não os apresenta como razão ou auditoria contabilística agregada. | +| Análise | Balancetes/extractos, analítica e demonstrações; catálogo de 24 relatórios com pesquisa e filtro por área. | Todos os relatórios são definições, não resultados gerados. Orçamento industrial mantém âmbito distinto. | +| Fecho | Operações de fecho e períodos/bloqueios. | Sem execução de ajustamentos, fecho ou reabertura. Os bloqueios de emissão fiscal não são bloqueios de período contabilístico. | +| Configuração | Plano de contas, diários e ligação à fiscalidade existente. | Reutiliza classificação de planos/contas e respectivas decisões; não cria diários, plano integral ou regras fiscais por arrasto. | + +As onze funções detalhadas, mais o painel, cobrem as doze entradas funcionais do documento sem multiplicar grupos laterais. Classificação contabilística e Perfis fiscais do serviço permanecem acessíveis directamente e com as mesmas permissões. + +O catálogo contém balancete, razão/extractos, diário, balanço, resultados, fluxos de caixa, alterações no capital próprio, anexo, clientes, fornecedores, caixa/bancos, reconciliação bancária, previsão de tesouraria, pagamentos/aprovações, IVA, retenções/obrigações, salários, activos/depreciações, centros de custo/projectos, orçamento/realizado, inventário/valorização, moeda estrangeira, fecho e auditoria. Os resultados da pesquisa são **definições de relatórios**, nunca consultas de movimentos. Não há geração, PDF ou exportação fictícia. + +## Preservação e autorização + +- `app/Modules/Services/CatalogueAccounting.php`, `resources/js/CatalogueAccountingWorkspace.jsx` e os endpoints `/api/catalogue-accounting` continuam responsáveis pelas propostas, decisões e resoluções existentes. Não foram modificados. +- `resources/js/ServiceFiscalWorkspace.jsx` e o seu tratamento de perfis permanecem separados. +- Recebimentos, obrigações e orçamento industrial são apenas ligações condicionais às concessões existentes, sem equivalência a diário, razão, pagamento bancário ou analítica contabilística completa. +- Consulta de empresa/classificação/perfis permite ver o mapa informativo; não cria direitos operacionais contabilísticos. Os botões de acesso ao existente exigem a permissão exacta e, quando aplicável, a unidade activa. Administração técnica não dá acesso financeiro por inferência. +- A apresentação não lê saldos, dados bancários, salários ou auditoria agregada. Não há API contabilística nova. Os componentes são reiniciados quando mudam concessões/contexto, preservando isolamento. +- Lucro, liquidez e previsão são distintos. Vencimento desconhecido não é atraso zero. Documento, obrigação, liquidação e lançamento não se substituem. + +## Ficheiros e provas + +Novos: `resources/js/accounting-menu.js`, `resources/js/AccountingMenuWorkspace.jsx`, `scripts/accounting-menu-check.mjs`, `docs/proofs/accounting-menu.json`. Montagem em `resources/js/app.jsx`; grupo lateral em `resources/js/WorkspaceNavigation.jsx`; apresentação responsiva em `resources/css/app.css`. + +O check verifica seis entradas, onze funções, 24 definições de relatório, pesquisa por texto/área, ausência de permissão, conta sem administração implícita, ligações de unidade diferente recusadas, contexto de empresa e ausência de formulários/valores financeiros fictícios. Os checks anteriores de início/navegação e Stock foram executados para regressão. Compilação Vite aprovada, sem alterações de manifests de dependências ou lockfiles. + +São provas técnicas de renderização estática e compilação. **Não foi realizada nova revisão visual/interactiva**, não foram gerados relatórios financeiros nem executadas operações de negócio ou testes destrutivos na demonstração. O catálogo não recebe estado Demonstrado por estar visível. + +Pendentes: motor de lançamentos e diários, razão, balancetes, demonstrações, analítica por movimentos, activos/depreciações, reconciliação contabilística/bancária, períodos e fecho, fiscalidade operacional e exportações autorizadas. A sua execução exige incrementos próprios sobre origens e capacidades comuns, sem duplicar obrigações, stock ou orçamento. + +Sem novas contas/concessões, bases ou migrações, dependências, publicação, push ou checkpoint de encerramento. A recuperação da conta em `sgf.tempo.co.mz` não é resolvida nem presumida por esta navegação local. diff --git a/docs/BILLING_WORKSHOP_MENU_DELIVERY.md b/docs/BILLING_WORKSHOP_MENU_DELIVERY.md new file mode 100644 index 0000000..df911f6 --- /dev/null +++ b/docs/BILLING_WORKSHOP_MENU_DELIVERY.md @@ -0,0 +1,77 @@ +# Menus de facturação e oficinas + +29/10/2026. Implementação local da navegação solicitada, reutilizando os percursos existentes. Não declara implementadas todas as funções descritas nos documentos Word, não altera acessos e não resolve a recuperação da conta pública. + +## Fontes e âmbito autorizado + +- Pedido expresso «implementar o menu de facturacao», com `C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/ERP TEMPO/Menu Facturação.docx`. SHA-256 do original lido: `f2d59d8e31892602764598f310518a265d54ed796db96b015b858d43e669c784`. +- Documento `Menu para software de gestão de oficinas.docx`, no mesmo directório. O responsável confirmou «sim» à pergunta de implementar também o menu de oficinas depois de facturação. +- Os documentos foram lidos como referências funcionais. A sua proposta de operações, perfis e regras fiscais não foi tratada como autorização para emitir documentos reais, atribuir concessões, executar pagamentos, publicar ou implementar todos os motores de domínio. +- Preservados os percursos e alterações anteriores de serviços, oficinas, custos, recebimentos, orçamento e obrigações. Nenhum motor financeiro foi copiado ou reimplementado. + +## O que fica utilizável + +Abrir **http://127.0.0.1:8002/avaliar**, escolher **Preparar e gerir** e abrir **Facturação** ou **Oficinas**. A entrada já existente não exige palavra-passe. Os perfis técnicos mostram apenas os destinos permitidos pelas concessões actuais. + +| Menu | Comportamento entregue | Limite identificado | +| --- | --- | --- | +| Todos os documentos | Consulta conjunta das facturas de teste e rascunhos autorizados; pesquisa por referência, cliente e NUIT quando preservado; filtros de tipo, moeda, série, estado documental, autor quando registado e intervalo de emissão; limpeza de filtros. | Não é um novo repositório documental. Não agrega moedas nem infere saldo/atraso. Os tipos futuros não aparecem como documentos fictícios. | +| Facturas | Lista das facturas de teste; abrir fotografia imutável, ver pedidos de correcção existentes e descarregar o PDF de teste já preservado. | Sem emissão real, envio ou edição da fotografia. | +| Facturas-recibo | Entrada com explicação de capacidade ainda não disponível. | Não une factura e recebimento automaticamente. | +| Recibos | Entrada que distingue documento fiscal ainda ausente do percurso de recebimentos/afectações já existente; ligação apenas quando autorizada. | Recebimento confirmado não é apresentado como recibo fiscal emitido. | +| Notas de crédito | Entrada com limite explícito e ligação aos pedidos de correcção existentes, quando autorizada. | Um pedido de correcção não altera dívida, stock ou dinheiro nem constitui nota de crédito. | +| Orçamentos de facturação | Entrada comercial identificada como ainda não disponível. | Não equivale a controlo orçamental de Compras nem a orçamento industrial. | +| Rascunhos | Editor e histórico existentes, pesquisa por referência/designação/cliente e ligações aos restantes submenus. A acção Novo documento abre a preparação existente por prestação de origem, sem emitir. | Não foi substituído pelo formulário integral das secções 6.1–6.12 do Word. | +| Configuração documental | Preparação documental, apresentação do rascunho e séries/emissão de teste mantidas em grupo próprio. | Preserva os caminhos existentes em vez de os remover para acomodar os sete submenus. | + +### Oficinas + +| Entrada | Comportamento entregue | Capacidade por desenvolver | +| --- | --- | --- | +| Painel da oficina | Lista pesquisável de ordens autorizadas e estado efectivamente registado; abertura da ordem seleccionada. | Indicadores de entradas/entregas, agenda e alertas sem fonte não são apresentados como zero. | +| Clientes | Ligação ao cadastro existente, só com `clients.view`. | Conta corrente e histórico integral de manutenção não são acrescentados. | +| Equipamentos | Identificação, descrição, série e ligações às ordens visíveis, sem duplicar a identidade do equipamento. | Cadastro independente, tipos, características técnicas e documentos de recepção/entrega ainda não estão entregues. | +| Agendamentos | Informação explícita de indisponibilidade. | Calendário, disponibilidade e lembretes. | +| Orçamentos de oficina | Informação explícita de indisponibilidade e ligação às autorizações de trabalho existentes. | Documento comercial e aprovação do cliente por versão. | +| Ordens de serviço | Reutiliza abertura, atribuição, tarefas, autorização, tempos, materiais, verificação e histórico já existentes; conserva a ordem escolhida na ligação da lista. | Não substitui os estados de apontamentos pelas fases operacionais futuras propostas no Word. | +| Peças e stock | Distingue apontamentos materiais de stock e encaminha para a consulta existente. | Reservas, mínimos, inventário e movimentos de stock da oficina. | +| Compras e fornecedores | Declara a composição ainda não integrada; liga aos percursos já disponíveis no contexto, apenas se autorizados. | Não duplica Compras nem cria integração remota. | +| Facturação e caixa | Liga a facturas, rascunhos, oficina → rascunho e recebimentos de teste conforme direitos. Oculta esta entrada ao perfil técnico sem direitos financeiros. | Caixa, despesas e fecho não são simulados. | +| Equipa | Consulta de ordens/responsáveis e ligação ao registo existente de horas. | Cadastro de pessoal, horários e disponibilidade. | +| Relatórios | Consulta actual de ordens, com pesquisa e filtro de estado. | Exportações, margens e relatório diário fechado/corte ainda não implementados. | +| Configurações | Ligações autorizadas à empresa, acessos, serviços, apresentação documental e perfis fiscais existentes. | Não transforma Administrador/Gestor/Recepcionista do documento em concessões universais. | + +As entradas comerciais Oficina → rascunho e Correcção/cancelamento comercial permanecem acessíveis. A correcção comercial existente não é apresentada como cancelamento da ordem operacional. + +## Preservação e permissões + +As consultas usam os endpoints existentes `/api/service-invoices`, `/api/service-invoices/{id}`, `/api/a/billing_drafts` e `/api/workshop`. Não houve novas rotas de negócio, schema ou concessões. Isolamento, atribuição de ordens ao técnico e autorização de acções continuam no servidor. + +O menu filtra consulta por empresa/contexto e unidade. As novas áreas são remontadas quando as concessões mudam para não conservar consultas de permissões anteriores. Respostas de um contexto abandonado são descartadas. Erros de fonte são apresentados como consulta indisponível; não são convertidos em zero documentos ou ordens. + +Os totais das facturas são copiados de `snapshot.calculation.total_document_proposed`. Não se executam cálculos fiscais nem arredondamentos. A base aritmética do rascunho conserva a precisão e não é rotulada como total fiscal. Sem fonte de saldo/vencimento, a lista apresenta a ausência e remete ao percurso financeiro; não calcula atrasos nem presume pagamento. Os filtros de vencimento/estado financeiro e os dados de autor inexistentes nas fontes actuais ficam por completar, sem datas/identidades inventadas. + +## Código alterado + +- `resources/js/billing-menu.js`: mapa de sete entradas, autorização e modelos de consulta/filtros sem efeitos financeiros. +- `resources/js/BillingMenuWorkspace.jsx`: lista, pesquisa, filtros, fotografia documental/PDF e informação dos tipos em falta. +- `resources/js/workshop-menu.js`: mapa de doze entradas, autorização, filtros e reutilização da identidade de equipamento. +- `resources/js/WorkshopMenuWorkspace.jsx`: painel, consulta de equipamentos/ordens e ligações autorizadas. +- `resources/js/WorkspaceNavigation.jsx`: organização dos dois grupos e preservação da configuração documental. +- `resources/js/app.jsx`: montagem e selecção dos percursos/registos existentes. +- `resources/js/ServicesWorkspace.jsx`: acesso à preparação existente a partir de Novo documento, pesquisa dos rascunhos e navegação contextual. +- `resources/js/WorkshopWorkspace.jsx`: navegação contextual e abertura da ordem seleccionada, sem alteração das operações. +- `resources/css/app.css`: disposição responsiva dos submenus/filtros e scroll próprio das tabelas. +- `scripts/billing-menu-check.mjs`, `docs/proofs/billing-menu.json`: verificação dos dois menus e leitura da demonstração existente. + +## Provas e limites + +Compilação Vite aprovada. Passaram os checks de navegação/início existentes e o check orçamental de representação monetária e unidade. O novo check cobre sete entradas de facturação, doze de oficinas, ausência de permissão, unidade diferente, ocultação financeira ao técnico, pesquisa por cliente/NUIT/equipamento, combinação de filtros, valores exactos, datas/saldos desconhecidos e falha parcial de fonte. + +Na demonstração sintética persistente em 8002, o teste HTTP autenticou pela entrada de avaliação já existente e consultou **uma factura, dois rascunhos e uma ordem**. Os valores documentais mantiveram-se iguais às fotografias. Sem criação de documentos, ordens, pagamentos ou efeitos orçamentais; apenas sessão/contexto e auditoria de entrada/saída. Não se executou a bateria destrutiva sobre a base da demonstração. + +São provas técnicas de renderização estática, HTTP e compilação. **Não foi realizada revisão visual/interactiva no navegador**, nem se presume aceitação. Os scripts inicialmente interrompidos durante a leitura HTTP foram repetidos com fecho explícito das ligações e limite de espera; a execução final passou. Nenhum bloqueio de ferramenta foi contornado. + +Permanecem por implementar as restantes funções descritas nos Word: formulário integral/simplificado de factura, novas condições/prestações, documentos fiscais adicionais, envio, exportação, anexos e confirmações/recuperação específicas desses novos percursos; cadastro/fases/documentos operacionais completos da oficina, stock, caixa e relatórios fechados. Esta entrega implementa o menu e a ligação ao que existe, não todos esses módulos por arrasto. + +Nenhuma dependência, conta, permissão, credencial, base real ou implantação pública foi alterada por esta entrega. Não foi executada publicação, push ou checkpoint de encerramento. A recuperação de acesso a `sgf.tempo.co.mz` mantém-se uma tarefa distinta, sem a declarar resolvida pelos menus locais. diff --git a/docs/HOME_NAVIGATION_DELIVERY.md b/docs/HOME_NAVIGATION_DELIVERY.md index bbe13d8..d804b15 100644 --- a/docs/HOME_NAVIGATION_DELIVERY.md +++ b/docs/HOME_NAVIGATION_DELIVERY.md @@ -1,5 +1,29 @@ # Página inicial e menus agrupados +**Reposição da visibilidade de Compras, 29/10/2026:** após separar Stock, o contexto sem direitos purchasing_budget/supplier_payables ficava sem itens no grupo Compras, ocultado pelo filtro de grupos vazios. A junção anterior permanecia visível pelos itens de Stock; nenhuma concessão ou dado tinha sido removido. Acrescentada Área de Compras informativa com company.view, sem acesso operacional implícito; preservadas as opções de orçamento/obrigações para os contextos autorizados. PurchasingHome.jsx distingue a área SGF da aplicação autónoma e não afirma incorporação integral. Regressão reproduz company.view sem direitos de Compras, confirma Compras/Stock separados e conserva filtros por unidade; build aprovado. Sem alteração de acessos ou dados. + +**Ajuste posterior, 29/10/2026:** Compras e Stock separados em dropdowns próprios. Ordem actual: Início, Comercial, Compras, Stock, Oficinas, Financeiro, Configuração. Fonte do menu principal aumentada de 0,87 para 1 rem; itens de submenu de 0,85 para 0,95 rem; fonte móvel de 0,78 para 0,9 rem. Mantidos destinos/permissões e dropdowns. Check de navegação e build aprovados; sem nova revisão visual. + +## Menu horizontal por assunto — 29/10/2026 + +Pedido expresso: alterar a posição para horizontal, ordenar por assunto e preservar o dropdown. `WorkspaceNavigation.jsx` substitui a barra lateral/drawer por barra superior, com seis entradas ordenadas: **Início; Comercial; Compras e stock; Oficinas; Financeiro; Configuração**. Início abre directamente a página inicial; as restantes abrem dropdowns com as secções e destinos anteriores. + +Comercial agrupa Clientes e serviços/Facturação; Compras e stock reúne Compras/Stock; Oficinas conserva os percursos técnicos e comerciais; Financeiro reúne Recebimentos/Contabilidade e fiscalidade/Custos e orçamento; Configuração reúne configuração documental/Administração. A organização por assunto é apresentação, não fusão dos motores ou dos âmbitos. Os IDs, permissões, estado Em preparação, destinos e contexto permanecem os mesmos. Novos grupos futuros sem classificação explícita têm fallback próprio, em vez de desaparecerem. + +Um dropdown aberto de cada vez; selecção e clique fora fecham-no; Escape fecha e devolve foco ao respectivo botão. Enter/Espaço usam os botões nativos; setas e Home/End percorrem destinos. O assunto activo e o destino activo são identificados; `aria-expanded`, `aria-controls` e `aria-current` preservam a semântica de navegação. Os menus começam fechados e o assunto activo não fica forçado a permanecer aberto. O conteúdo não abre por hover apenas. + +Em ecrãs menores, a barra mantém organização horizontal com quebra de linha; dropdowns ficam ancorados à área de navegação, com altura limitada e scroll próprio. Não há drawer lateral nem botão de menu separado. A área de trabalho usa a largura anteriormente ocupada pela barra lateral e o cabeçalho de contexto deixa de competir com o menu fixo. + +Código: `resources/js/WorkspaceNavigation.jsx`, `resources/css/app.css`; regressão em `scripts/home-navigation-check.mjs`. O modelo `navigationGroups` continua a filtrar exactamente os mesmos direitos; `navigationTopics` apenas reordena/agrupa. O check confere igualdade do conjunto completo de destinos antes/depois, unicidade, seis assuntos, fallback, unidade sem direitos e renderização com dropdown/elementos activos. Checks de início/navegação e compilação Vite aprovados. Sem alteração de dependências, operações de negócio, bases, acessos, publicação ou checkpoint. + +A verificação desta alteração é técnica (renderização estática/compilação), não nova revisão visual/interactiva no navegador. A implementação de teclado/responsividade não é apresentada como teste manual executado. Esta orientação substitui a disposição lateral descrita abaixo, preservando os resultados e limites das entregas anteriores. + +**Contabilidade, 29/10/2026:** acrescentados painel, Trabalho diário, Conferência, Análise, Fecho e Configuração, preservando classificação/perfis e com catálogo de relatórios previsto. Ver [âmbito e provas](ACCOUNTING_MENU_DELIVERY.md). Não declara razão, lançamentos ou relatórios financeiros entregues. + +**Stock, 29/10/2026:** acrescentada a navegação informativa do documento fornecido, com estado Em preparação e sem motor/fonte operacional SGF. Ver [correspondência com Compras, código e provas](STOCK_MENU_DELIVERY.md). Não declara saldos, movimentos ou relatórios operacionais disponíveis. + +**Actualização de 29/10/2026:** menus de Facturação e Oficinas implementados a partir dos documentos fornecidos e da confirmação do responsável, com destinos existentes e capacidades ausentes identificadas. Ver [âmbito, correspondências e provas](BILLING_WORKSHOP_MENU_DELIVERY.md). A revisão visual e a publicação permanecem distintas; esta nota não reabre nem substitui a aceitação anterior. + ## Avaliação técnica e preservação local — 28/10/2026 A pedido do utilizador, preservar esta implementação num checkpoint exclusivamente local. Avaliação do agente: organização coerente com os destinos existentes, grupos filtrados por contexto, distinção entre consulta/preparação/validação, ausência de indicadores fictícios e identidade visual alinhada com as folhas públicas do InvesteMoz. Build, renderização por perfil e entrega HTTP aprovados. Esta é avaliação técnica do código e dos resultados automatizados, não aprovação visual pessoal do utilizador. Capturas e verificação visual/interactiva no Edge continuam indisponíveis; o checkpoint não as transforma em provas realizadas. Nenhum segredo ou dado privado incluído, sem push/publicação. Encerramento do ensaio de orçamento conferido em `04870f0` e `docs/INDUSTRIAL_BUDGET_ACCEPTANCE.md`, mantendo expressamente as limitações originais. diff --git a/docs/PRODUCT_DEMO.md b/docs/PRODUCT_DEMO.md index 70c571b..bbf13e0 100644 --- a/docs/PRODUCT_DEMO.md +++ b/docs/PRODUCT_DEMO.md @@ -1,5 +1,13 @@ # Demonstração integrada do produto SGF +## 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. + +Abrir **http://127.0.0.1:8002/avaliar**. Entrada sem palavra-passe nos quatro papéis sintéticos existentes, com contexto seleccionado automaticamente. A ligação «Explorar SGF / trocar de função» permanece acima da aplicação. Não cria contas ou concessões e conserva os exemplos. A página reúne também ligações às instalações separadas de orçamento de Compras e obrigações; não as transforma numa base integrada nem elimina as credenciais ainda exigidas pelo orçamento em 8003. + +Verificadas por HTTP as quatro entradas, contexto, aplicação e consulta de oficina, CSRF obrigatório e recusa de papel arbitrário. Sem comandos de negócio ou testes destrutivos. O acesso de proprietário local foi acrescentado acima; integração das instalações e revisão dos bloqueios de operações continuam pendentes. A ligação SSH ao endereço indicado respondeu mas recusou a chave disponível, sem alterações remotas. Não se declarou publicação ou aceitação visual. + **Percurso orçamental acrescentado em 29/10/2026:** [interface SGF, contas por função e guião](SGF_BUDGET_WORKSPACE_DELIVERY.md), disponível em 8003 com base persistente separada. Reutiliza o núcleo aceite; a demonstração anterior em 8002 e os seus dados mantêm-se. A interface deixa de estar apenas no adaptador, mas a revisão visual da equipa continua pendente. **Orientação posterior sobre Compras:** [núcleo comum e duas composições comerciais](PURCHASING_COMMON_CORE_PLAN.md). Preservar esta demonstração e os seus dados; os ensaios destrutivos da futura extracção terão ambientes sintéticos separados. O plano delimita controlo orçamental comum, sem declarar Compras já incorporado ou exigir Sales no produto autónomo. diff --git a/docs/STOCK_MENU_DELIVERY.md b/docs/STOCK_MENU_DELIVERY.md new file mode 100644 index 0000000..525bdfa --- /dev/null +++ b/docs/STOCK_MENU_DELIVERY.md @@ -0,0 +1,57 @@ +# Menu de Stock no SGF + +29/10/2026. Implementada a estrutura de navegação do documento **Menu Stock.docx**, com estado de disponibilidade explícito. **Não está entregue um motor operacional de stock no SGF.** A composição SGF ainda não tem fonte de armazéns/saldos/movimentos ligada. Compras conserva a sua implementação e os seus dados. + +## Referência e leitura do existente + +Fonte fornecida pelo responsável: `C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/ERP TEMPO/Menu Stock.docx`, 186 blocos de texto/tabelas lidos, SHA-256 `db4707b137a3083649ff5492c4d82da71402443d5bc11fd5ac13a970aaa6da48`. Original preservado. O documento foi utilizado como referência funcional; as propostas de confirmações, estornos e permissões não foram tratadas como autorização para efectuar movimentos reais ou conceder direitos. + +| Capacidade | Compras existente | SGF nesta entrega | +| --- | --- | --- | +| Artigos, armazéns, saldos e movimentos | `app/Http/Controllers/StockController.php`: consulta por artigo/armazém, pesquisa e histórico; criação de cadastros conforme papel. | Menu Existências com estado de fonte indisponível. Nenhum saldo importado ou criado. | +| Entradas de compra | `app/Services/ReceiptWorkflow.php`: recepção parcial da ordem, quantidades recebidas/aceites e movimento de stock da quantidade aceite, quando aplicável. | Identifica o percurso existente e o adaptador em falta. Não cria uma segunda entrada por factura ou obrigação. | +| Saídas | `app/Services/StockWorkflow.php`: actor Armazém, revalidação de contexto/actor, bloqueio da empresa, saldo não negativo, referência única, chave de repetição, fotografia e auditoria. | Menu e campos/etapas previstos, sem comandos ou alteração de quantidades. | +| Transferência | `StockWorkflow.php`: saída e entrada imediatas na mesma transacção, com origem e destino distintos. | Explicita a diferença face à proposta de expedição → trânsito → recepção. Não simula trânsito nem declara esse fluxo entregue. | +| Contagens e ajustes | `StockWorkflow.php`: contagem ligada à versão do livro, sem movimento; ajuste único ligado à contagem e recusado se houver movimentos posteriores/incompatibilidade. | Identificados como percurso a reutilizar futuramente, sem ajuste genérico ou edição de saldo no menu. | +| Devoluções físicas | O fluxo completo do Word não foi identificado nos controladores/serviço de stock existentes. | Indisponível; distingue cliente/fornecedor, devolução física, nota de crédito e devolução de dinheiro. | +| Materiais de oficina | Não foi criada ligação implícita entre aplicações. | `app/Modules/Workshop/WorkshopWorkflow.php` e `WorkshopWorkspace.jsx` mantêm observações de material, sem stock. O menu não converte apontamentos em saldo de armazém. | +| Reservas, bloqueios, mínimos, lotes, séries, validade e valorização | Não presumidos a partir do saldo físico de `quantity_milli`. | Sem configuração/fonte no SGF; não se substituem por zero, custos estimados ou opções operacionais fictícias. | + +A futura adaptação tem de preservar a representação de quantidades existente em milésimos, as protecções e as origens. Não foi feita extracção do serviço nesta entrega nem criada uma segunda cópia do motor. Não se equipararam o tenant de Compras e a empresa/unidade/armazém SGF. + +## Navegação entregue + +Abrir **http://127.0.0.1:8002/avaliar**, escolher **Preparar e gerir** e abrir **Stock — Em preparação**. + +- **Painel:** explica a ausência de fonte de indicadores e encaminha para os restantes submenus, sem valores ilustrativos. +- **Existências:** distingue físico, reservado, bloqueado e disponível; consulta indisponível sem lista vazia que sugira ausência de stock; sem edição directa de saldo. +- **Entradas:** apresenta campos, etapas e origem do percurso previsto, capacidade já existente em Compras e adaptação em falta. +- **Saídas:** distingue expedição, consumo e abate; não desconta quantidades observadas na oficina. +- **Transferências:** distingue transferência imediata existente do transporte com recepção separada ainda ausente. +- **Devoluções:** apresenta sentidos, origem, elegibilidade e tratamento previstos, sem movimentos físicos ou financeiros. +- **Relatórios:** Existências por armazém, Movimentos e Stock mínimo mostram fonte indisponível. Não se disponibilizam geração, impressão ou exportação fictícias. Valorização fica sem dados de custos/selector autorizado; os relatórios complementares só serão expostos quando as capacidades correspondentes estiverem activas. + +Os atalhos do início e o grupo lateral identificam o estado **Em preparação** antes da entrada. A página Peças e stock da oficina liga ao mesmo menu e conserva a distinção entre observações materiais e armazém. Não foi acrescentada dependência de um servidor SGF/Compras remoto, leitura cruzada de bases ou autenticação implícita noutra aplicação. + +`company.view` permite ver apenas este mapa informativo no contexto autorizado. **Não concede stock.view, acesso a armazéns, visualização de custos ou qualquer operação.** A futura integração operacional exige permissões próprias, revalidadas no servidor. Nenhuma concessão foi criada nesta entrega. + +## Ficheiros + +- `resources/js/stock-menu.js`: entradas, descrições dos percursos e relatórios condicionais, sem cálculo/persistência. +- `resources/js/StockMenuWorkspace.jsx`: páginas de disponibilidade, campos/etapas previstos, distinção Painel/Relatórios e ausência de fonte. +- `resources/js/WorkspaceNavigation.jsx`: grupo Stock e indicação de estado. +- `resources/js/WorkspaceHome.jsx`: estado dos atalhos informativos. +- `resources/js/app.jsx`: montagem das páginas no contexto actual. +- `resources/js/WorkshopMenuWorkspace.jsx`: ligação da oficina ao menu comum de Stock. +- `resources/css/app.css`: disposição responsiva das entradas, cartões e definições. +- `scripts/stock-menu-check.mjs`, `docs/proofs/stock-menu.json`: verificação técnica. + +## Provas e limite da entrega + +Passaram o check dos sete submenus, consulta sem autorização, contexto de empresa distinto, ausência de valores/saldos fabricados, relatórios condicionais ocultos, ausência de formulários operacionais e ausência de dados de custos. O check de início/navegação anterior continua aprovado. **Compilação Vite aprovada**, sem alterações de dependências. + +São verificações de renderização estática e compilação, **não revisão visual/interactiva nem ensaio operacional de stock**. Não foram usados endpoints de stock, executados movimentos, alteradas bases, concedidos acessos ou criadas instâncias/contas. Não se modificou código em Compras. As provas de concorrência/idempotência do seu motor não foram repetidas nem atribuídas a um adaptador SGF inexistente. + +Os controlos operacionais do Word permanecem por implementar/adaptar no SGF: fonte comum/adaptadores, permissões por armazém e custos, documentos e confirmação, reservas/bloqueios, trânsito/recepções, devoluções/estornos e relatórios reconciliados. Antes dessa ligação deve ser delimitado o reaproveitamento do `StockWorkflow`/recepção existente e demonstrada a equivalência em testes isolados, sem recálculo ou duplicação de movimentos históricos. Isso é próximo trabalho, não uma capacidade entregue pelo menu. + +Sem publicação, push, checkpoint ou aceitação presumidos. O acesso ao domínio público permanece separado desta navegação local. diff --git a/docs/SUPPLIER_PAYABLE_DELIVERY.md b/docs/SUPPLIER_PAYABLE_DELIVERY.md index 435d22f..a658546 100644 --- a/docs/SUPPLIER_PAYABLE_DELIVERY.md +++ b/docs/SUPPLIER_PAYABLE_DELIVERY.md @@ -1,7 +1,39 @@ # Obrigação a fornecedor — entrega técnica em verificação +**Evolução posterior no projecto Compras:** [passagem explícita do histórico de pagamentos](../../compras/docs/passagem-historico-pagamentos.md), `PAYABLE-HISTORY-IMPORT-v1`, implementada na fonte comum com migração 000022 preparada e activação separada desligada por omissão. Principal integral, dedução única de confirmados, pendentes/rejeitados preservados sem abatimento, revisor distinto e unicidade. Prova PostgreSQL histórica e regressão anterior nas duas composições aprovadas; não aplicada às bases habituais/demos. O adaptador SGF anterior permanece sem fornecedor de histórico; não se declara essa ligação entregue no SGF. Sem novos pagamentos, reconciliação, stock/orçamento ou contabilidade por arrasto. + 29/10/2026. `COMMON-PAYABLE-SOURCE-SIM-v1`, [especificação e regras aprovadas](SUPPLIER_PAYABLE_SCOPE.md). **Implementado apenas em QA, sem aceitação ou checkpoint de encerramento presumidos.** O exemplo executado é o aprovado mais recentemente: base **10 000 MZN**, imposto documental **1 600 MZN**, principal **11 600 MZN**. Exemplos anteriores de 1 160 MZN na especificação são históricos; não são os valores desta entrega. +## Entrada sem palavra-passe — 29/10/2026 + +Por pedido expresso do responsável, a demonstração admite entrada por função sem procurar credenciais: SGF `http://127.0.0.1:8006/demo`, Compras `http://127.0.0.1:8005/demo`. Escolher Preparar, Validar ou Consultar; a empresa/unidade SGF é seleccionada e revalidada no servidor. A página do percurso permite voltar à escolha de função e sair. As sessões são renovadas na troca, os perfis existentes permanecem distintos e nenhuma concessão foi acrescentada. As identidades são sintéticas e partilhadas por função, sem identificar o avaliador real. O cenário/histórico é partilhado e conservado. + +Código de entrada: `scripts/payables-demo-entry.php`, registado exclusivamente por `scripts/payables-router.php` nos hosts QA já isolados. Não altera o login operacional nem disponibiliza entrada anónima na instalação pública. O motor financeiro permanece inalterado. Prova HTTP `scripts/payables-demo-entry-proof.mjs` / `docs/proofs/payables-demo-entry.json`: três funções em ambas as composições, CSRF, recusa de função arbitrária, contexto SGF, saída/reentrada e histórico conservado; sem executar comandos financeiros. Não constitui prova visual. Não é necessário novo build para esta alteração PHP/HTML; nenhuma dependência mudou. + +Verificação pública: `https://sgf.tempo.co.mz/` responde 200, `/api/session` responde sem utilizador autenticado e `/demo` responde 404. Não foi encontrada configuração de implantação nas pesquisas documentais/scripts dos projectos, nem ligação de gestão do alojamento disponível nesta sessão. Não foi possível identificar a base pública ou excluir dados reais por leitura pública. A publicação de uma demonstração sintética separada permanece por executar; os endereços locais não são acessíveis a avaliadores externos. Não se removeu autenticação pública nem se alterou o domínio. A preparação actual é específica do host QA local, não um pacote de implantação pronto para o servidor desconhecido. A avaliação pelo responsável permanece suspensa por falta de acesso público, conforme a mensagem posterior; nenhum encerramento/checkpoint presumido. + +## Diagnóstico de acesso — prioridade de 29/10/2026 + +Pedido: recuperar entrada sem novas contas/instâncias, preservar dados, não avançar módulos; eventual reposição de credencial/acesso exige uma confirmação concreta. Nesta passagem não se alterou credencial, concessão ou schema. + +| Endereço existente | Base efectiva local | Contas / entrada | +| --- | --- | --- | +| `http://127.0.0.1:8002/avaliar` | `sgf_tempo_test` | Entrada de avaliação existente sem palavra-passe: preparador, validador, técnico e verificador. Também verificadas as credenciais do manifesto original para `gestor@example.test`, `consulta@example.test` e contas de oficina. | +| `http://127.0.0.1:8003` | `budget_core_sgf_4abb58d1cabf` | Quatro contas `*-orcamento@example.test`, acesso separado; não alterado. | +| `http://127.0.0.1:8005/demo` | `compras_receipts_test_51bb71f4189c` | Três funções sintéticas sem palavra-passe; as três credenciais existentes também aceites pelo login normal deste host. | +| `http://127.0.0.1:8006/demo` | `payable_sgf_a70cf23e6cdf` | Três funções sintéticas sem palavra-passe; as três credenciais existentes também aceites pelo login normal deste host. | +| `http://sgf-tempo.test` | `sgf_tempo_dev` | Contas reais locais existentes `nala@tempo.co.mz` e `onala@me.com`; sem tentativa de adivinhar ou repor palavras-passe. | +| `http://compras.test` | `compras_dev` | Conta existente `nala@tempo.co.mz`; verificação de email ainda não registada. As rotas Compras protegidas usam `verified`; isto pode ser uma segunda etapa após autenticação válida, não erro de palavra-passe nem bloqueio fiscal. Não se alterou verificação. | +| `https://sgf.tempo.co.mz` | Não identificável pela leitura pública | Responde 200; sessão anónima. O asset público agora coincide nominalmente com o local (`app-CwyD-u3w.js`), ao contrário da consulta anterior; isso não prova igualdade de bases/contas. Nenhuma credencial local foi enviada ao domínio público. | + +O diagnóstico mostra instalações com conjuntos de contas distintos. Os ficheiros `payables-demo-*-access.json` correspondem exclusivamente a 8005/8006, não a todas as instalações. Todas as seis credenciais desses ficheiros coincidem com os hashes locais e foram aceites via HTTP no host correcto; não se reproduziu palavra-passe inválida nesses pares. Não se conhece a combinação concreta tentada pelo responsável, pelo que não se atribui uma causa única ao erro relatado. Os cookies das demonstrações têm nomes distintos; as concessões por contexto continuam separadas. + +Procedimento imediatamente utilizável: abrir `http://127.0.0.1:8002/avaliar` e escolher **Preparar e gerir** para explorar o SGF; trocar função pelo ponto de entrada existente. Para Compras, abrir `http://127.0.0.1:8005/demo` e escolher **Preparar** ou **Consultar**. Para o percurso SGF de obrigações, usar `http://127.0.0.1:8006/demo`. Não há palavra-passe a copiar nesses endereços; não se afirma que os cenários estejam reunidos numa única base. Estes endereços são locais, não links para avaliadores externos. + +Verificação: `scripts/access-diagnose.php` lê configuração efectiva e contas em transacção PostgreSQL READ ONLY, com rollback e sem imprimir segredos/hashes. `scripts/access-login-check.mjs` verifica as quatro entradas existentes em `/avaliar`, contexto, consulta e saída, e as seis credenciais existentes de obrigações, consulta e saída; prova sanitizada em `docs/proofs/access-recovery.json`. Reexecutada a prova existente de entrada livre nos dois hosts e o check de módulos da demonstração integrada. Não se executou operação de negócio. Asserção inicial do teste de redireccionamento foi corrigida para comparar pathname, pois Laravel pode produzir URL raiz sem barra final; não foi necessário mudar a aplicação. Provas HTTP, sem declaração de revisão visual. Emissão fiscal e acessos de entrada mantêm verificações independentes. + +Limite público: acesso administrativo a `sgf.tempo.co.mz` não foi estabelecido; a documentação local de avaliação já regista recusa da chave SSH numa tentativa anterior. Não se repetiu tentativa com outras chaves nem se alterou servidor. Recuperação pública permanece pendente, sem inventar base ou conta remotas. Nenhum novo módulo, conta, instância, alteração de credencial, concessão, publicação ou checkpoint foi efectuado neste diagnóstico. + ## Resultado e responsabilidade financeira Uma factura criada e conferida pelos serviços existentes de Compras origina uma proposta e, por identidade distinta, **uma obrigação reconhecida** no núcleo comum. A origem, fotografia, captura/revisão, decisão e histórico são recuperáveis. O principal é exactamente `total_minor=1160000`, sem recálculo de imposto ou presunção de aplicabilidade fiscal real. diff --git a/docs/proofs/access-recovery.json b/docs/proofs/access-recovery.json new file mode 100644 index 0000000..b0562c3 --- /dev/null +++ b/docs/proofs/access-recovery.json @@ -0,0 +1,80 @@ +{ + "checked_at": "2026-10-29T18:17:47.675Z", + "results": [ + { + "host": "sgf-product", + "role": "preparador", + "password_required": false, + "authorised_consultation": true, + "logout": true + }, + { + "host": "sgf-product", + "role": "validador", + "password_required": false, + "authorised_consultation": true, + "logout": true + }, + { + "host": "sgf-product", + "role": "tecnico", + "password_required": false, + "authorised_consultation": true, + "logout": true + }, + { + "host": "sgf-product", + "role": "verificador", + "password_required": false, + "authorised_consultation": true, + "logout": true + }, + { + "host": "compras", + "role": "preparacao", + "existing_password_accepted": true, + "authorised_consultation": true, + "logout": true + }, + { + "host": "compras", + "role": "validacao", + "existing_password_accepted": true, + "authorised_consultation": true, + "logout": true + }, + { + "host": "compras", + "role": "consulta", + "existing_password_accepted": true, + "authorised_consultation": true, + "logout": true + }, + { + "host": "sgf", + "role": "preparacao", + "existing_password_accepted": true, + "authorised_consultation": true, + "logout": true + }, + { + "host": "sgf", + "role": "validacao", + "existing_password_accepted": true, + "authorised_consultation": true, + "logout": true + }, + { + "host": "sgf", + "role": "consulta", + "existing_password_accepted": true, + "authorised_consultation": true, + "logout": true + } + ], + "accounts_created": 0, + "passwords_changed": 0, + "permissions_changed": 0, + "business_commands": 0, + "visual_review": false +} diff --git a/docs/proofs/accounting-menu.json b/docs/proofs/accounting-menu.json new file mode 100644 index 0000000..3fa7537 --- /dev/null +++ b/docs/proofs/accounting-menu.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-29T20:32:11.872Z", + "sections": 6, + "function_definitions": 11, + "report_definitions": 24, + "existing_paths_preserved": true, + "links_permission_scoped": true, + "unit_scoped_links_checked": true, + "no_permissions_assigned_by_profile": true, + "no_fabricated_financial_balances": true, + "report_search_checked": true, + "business_commands": 0, + "backend_calls": 0, + "visual_review": false, + "publication": false +} diff --git a/docs/proofs/billing-menu.json b/docs/proofs/billing-menu.json new file mode 100644 index 0000000..e710d5a --- /dev/null +++ b/docs/proofs/billing-menu.json @@ -0,0 +1,26 @@ +{ + "checked_at": "2026-10-29T20:21:16.502Z", + "navigation_sections": 7, + "workshop_sections": 12, + "permission_and_unit_checks": true, + "technician_financial_menu_hidden": true, + "equipment_identity_preserved": true, + "filters_checked": true, + "source_outage_distinguished": true, + "exact_document_values_preserved": true, + "actual_host": "http://127.0.0.1:8002", + "source_counts": [ + { + "source": "Facturas", + "count": 1 + }, + { + "source": "Rascunhos", + "count": 2 + } + ], + "visible_workshop_orders": 1, + "business_commands": 0, + "visual_review": false, + "publication": false +} diff --git a/docs/proofs/local-owner-access.json b/docs/proofs/local-owner-access.json new file mode 100644 index 0000000..995caf9 --- /dev/null +++ b/docs/proofs/local-owner-access.json @@ -0,0 +1,31 @@ +{ + "checked_at": "2026-10-29T18:17:39.657Z", + "instance": "http://127.0.0.1:8002", + "synthetic_owner": true, + "consulted_modules": [ + "a/clients", + "a/services", + "a/service_drafts", + "a/billing_drafts", + "workshop", + "workshop-billing", + "workshop-corrections", + "direct-costs", + "industrial-costs", + "industrial-budgets", + "service-invoices", + "service-receivables", + "service-fiscal", + "catalogue-accounting", + "document-preview/model", + "accesses" + ], + "grants": 75, + "repeated_entry_stable": true, + "arbitrary_user_refused": true, + "business_commands": 0, + "real_accounts_changed": false, + "process_separation_rules_removed": false, + "ssh_used": false, + "visual_review": false +} diff --git a/docs/proofs/payables-demo-entry.json b/docs/proofs/payables-demo-entry.json new file mode 100644 index 0000000..972aed2 --- /dev/null +++ b/docs/proofs/payables-demo-entry.json @@ -0,0 +1,37 @@ +{ + "checked_at": "2026-10-29T17:05:08.634Z", + "hosts": [ + { + "host": "compras", + "passed": true, + "password_required": false, + "roles": [ + "preparacao", + "validacao", + "consulta" + ], + "csrf_enforced": true, + "arbitrary_role_refused": true, + "context_selected": false, + "history_preserved": true, + "financial_commands_executed": false + }, + { + "host": "sgf", + "passed": true, + "password_required": false, + "roles": [ + "preparacao", + "validacao", + "consulta" + ], + "csrf_enforced": true, + "arbitrary_role_refused": true, + "context_selected": true, + "history_preserved": true, + "financial_commands_executed": false + } + ], + "visual_review": false, + "public_deployment": false +} diff --git a/docs/proofs/payables-http.json b/docs/proofs/payables-http.json index 1f7bf7c..3967e7a 100644 --- a/docs/proofs/payables-http.json +++ b/docs/proofs/payables-http.json @@ -1,14 +1,14 @@ { "version": "COMMON-PAYABLE-SOURCE-SIM-v1", "passed": true, - "checked_at": "2026-10-29T12:47:37.052Z", + "checked_at": "2026-10-29T19:29:20.603Z", "team_budget_preserved": true, "hosts": [ { "host": "compras", "passed": true, - "core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962", - "source_revision": "9ca4ac29a4720d6c4885101fd1485c58fb2dab3dcc2d5d975cb919679df58f54", + "core_sha256": "6c130ee1dda38e4fec4c55b974b0140e857dbd8bf4beb357906826f537d3a235", + "source_revision": "9bbb4a50fa1743e68b1968cb9a4a32341a8d8911f69afe929efa0065be1e1f9c", "principal_minor": 1160000, "due_on": null, "days_overdue": null, @@ -19,12 +19,12 @@ { "status": 200, "state": "prepared", - "pid": 6472 + "pid": 12724 }, { "status": 200, "state": "prepared", - "pid": 14488 + "pid": 17280 } ] }, @@ -33,11 +33,11 @@ "results": [ { "status": 403, - "pid": 18912 + "pid": 14704 }, { "status": 403, - "pid": 3488 + "pid": 14100 } ] }, @@ -47,12 +47,12 @@ { "status": 200, "state": "recognised", - "pid": 8112 + "pid": 13020 }, { "status": 200, "state": "recognised", - "pid": 804 + "pid": 12336 } ] } @@ -80,8 +80,8 @@ { "host": "sgf", "passed": true, - "core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962", - "source_revision": "9ca4ac29a4720d6c4885101fd1485c58fb2dab3dcc2d5d975cb919679df58f54", + "core_sha256": "6c130ee1dda38e4fec4c55b974b0140e857dbd8bf4beb357906826f537d3a235", + "source_revision": "9bbb4a50fa1743e68b1968cb9a4a32341a8d8911f69afe929efa0065be1e1f9c", "principal_minor": 1160000, "due_on": null, "days_overdue": null, @@ -92,12 +92,12 @@ { "status": 200, "state": "prepared", - "pid": 6892 + "pid": 10808 }, { "status": 200, "state": "prepared", - "pid": 11424 + "pid": 11504 } ] }, @@ -106,11 +106,11 @@ "results": [ { "status": 403, - "pid": 804 + "pid": 7596 }, { "status": 403, - "pid": 20804 + "pid": 21800 } ] }, @@ -120,12 +120,12 @@ { "status": 200, "state": "recognised", - "pid": 20576 + "pid": 4572 }, { "status": 200, "state": "recognised", - "pid": 11332 + "pid": 11468 } ] } diff --git a/docs/proofs/product-demo-http.json b/docs/proofs/product-demo-http.json index fde3f22..d17d5fa 100644 --- a/docs/proofs/product-demo-http.json +++ b/docs/proofs/product-demo-http.json @@ -1,6 +1,6 @@ { "demo": "SGF-PRODUCT-DEMO-v1", - "checked_at": "2026-10-29T08:50:35.350Z", + "checked_at": "2026-10-29T17:05:01.722Z", "type": "HTTP integration, not visual acceptance", "business_operations_changed": false, "results": [ diff --git a/docs/proofs/stock-menu.json b/docs/proofs/stock-menu.json new file mode 100644 index 0000000..c213fb6 --- /dev/null +++ b/docs/proofs/stock-menu.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-29T20:32:25.159Z", + "sections": 7, + "kind": "informational navigation and source-unavailable states", + "not_operational_stock": true, + "no_fabricated_balances": true, + "conditional_reports_hidden": true, + "cost_values_not_loaded": true, + "no_business_forms": true, + "no_company_or_workshop_permission_inferred_as_stock_permission": true, + "company_context_rendering": true, + "backend_calls": 0, + "business_commands": 0, + "visual_review": false, + "publication": false +} diff --git a/packages/payables/bootstrap.php b/packages/payables/bootstrap.php index 1980898..2473279 100644 --- a/packages/payables/bootstrap.php +++ b/packages/payables/bootstrap.php @@ -1,3 +1,8 @@ getDriverName()!=='pgsql'||!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$db->getDatabaseName())throw new LogicException('Payables schema refused outside isolated QA.'); -$scope=function(Blueprint $t){foreach(['tenant_id','company_id','unit_id'] as $k)$t->unsignedBigInteger($k);}; -Schema::create('pa_access',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('user_id');$t->string('person');$t->string('action');$t->unique(['tenant_id','company_id','unit_id','user_id','action']);}); -Schema::create('pa_sources',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->string('source_key',64)->unique();$t->unsignedBigInteger('local_source_id');$t->timestamp('created_at');}); -Schema::create('pa_proposals',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->string('source_revision',64);$t->integer('capture_version');$t->jsonb('snapshot');$t->jsonb('missing');$t->unsignedBigInteger('prepared_by');$t->string('prepared_person');$t->text('reason');$t->timestamp('created_at');$t->unique(['source_id','capture_version']);}); -Schema::create('pa_obligations',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->unique()->constrained('pa_sources');$t->foreignId('proposal_id')->constrained('pa_proposals');$t->string('source_revision',64);$t->bigInteger('principal_minor');$t->string('currency',3);$t->date('due_on')->nullable();$t->unsignedBigInteger('recognised_by');$t->string('recognised_person');$t->text('reason');$t->timestamp('created_at');}); -Schema::create('pa_commands',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->unsignedBigInteger('actor_id');$t->uuid('command_key');$t->string('request_hash',64);$t->jsonb('result');$t->timestamp('created_at');$t->unique(['tenant_id','company_id','unit_id','actor_id','command_key']);}); -Schema::create('pa_events',function(Blueprint $t)use($scope){$t->id();$scope($t);$t->foreignId('source_id')->constrained('pa_sources');$t->unsignedBigInteger('actor_id');$t->string('kind');$t->jsonb('payload');$t->timestamp('created_at');}); -Schema::create('pa_outbox',function(Blueprint $t){$t->foreignId('event_id')->primary()->constrained('pa_events');$t->string('state');}); -DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$"); -foreach(['pa_sources','pa_proposals','pa_obligations','pa_events','pa_outbox','pa_commands'] as $t)DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$t} FOR EACH ROW EXECUTE FUNCTION pa_append_only()"); +use Illuminate\Support\Facades\Schema; +use Tempo\Finance\Payables\PayablesSchema; + +$db = DB::connection(); +if ($db->getDriverName() !== 'pgsql' || ! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->selectOne('select current_database() as d')->d !== $db->getDatabaseName()) { + throw new LogicException('Payables schema refused outside isolated QA.'); +} +// Compras migrations now install the same shared schema before QA fixtures. +if (Schema::hasTable('pa_sources')) { + foreach (['pa_access', 'pa_sources', 'pa_proposals', 'pa_obligations', 'pa_commands', 'pa_events', 'pa_outbox'] as $table) { + if (! Schema::hasTable($table)) { + throw new LogicException('Incomplete payables schema; no automatic repair.'); + } + } + $triggers = DB::selectOne("select count(*) as n from pg_trigger where tgname='pa_history_immutable' and tgrelid in ('pa_sources'::regclass,'pa_proposals'::regclass,'pa_obligations'::regclass,'pa_commands'::regclass,'pa_events'::regclass,'pa_outbox'::regclass)"); + if ((int) $triggers->n !== 6) { + throw new LogicException('Payables append-only protection missing.'); + } + + return; +} +require_once __DIR__.'/src/PayablesSchema.php'; +PayablesSchema::install(); diff --git a/packages/payables/src/HistoricalPayments.php b/packages/payables/src/HistoricalPayments.php new file mode 100644 index 0000000..095ab1b --- /dev/null +++ b/packages/payables/src/HistoricalPayments.php @@ -0,0 +1,113 @@ +db instanceof Connection + && $this->db->getSchemaBuilder()->hasTable('pa_history_imports') + && $this->db->getSchemaBuilder()->hasTable('pa_imported_payments'); + } + + public static function capture(array $source, array $payments): array + { + if (! is_int($source['payment_count'] ?? null) || $source['payment_count'] < 1 || count($payments) !== $source['payment_count']) { + throw new \DomainException('Histórico completo de pagamentos obrigatório; sem omissões ou importação vazia.'); + } + $rows = []; + $totals = ['confirmed' => 0, 'pending' => 0, 'rejected' => 0]; + foreach ($payments as $payment) { + foreach (['id', 'company', 'document', 'amount_minor', 'created_by', 'registration_audit_id'] as $key) { + if (! is_int($payment[$key] ?? null) || $payment[$key] < 1) { + throw new \DomainException('Referência ou montante de pagamento inválido: '.$key); + } + } + $id = $payment['id']; + $status = $payment['status'] ?? ''; + if (isset($rows[$id]) || $payment['company'] !== $source['company'] || $payment['document'] !== $source['document'] + || ($payment['currency'] ?? null) !== ($source['currency'] ?? null) || ! isset($totals[$status])) { + throw new \DomainException('Pagamento duplicado ou incompatível com empresa, factura, moeda ou estado.'); + } + $date = $payment['paid_on'] ?? ''; + if (! is_string($date) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date) + || ! checkdate((int) substr($date, 5, 2), (int) substr($date, 8, 2), (int) substr($date, 0, 4))) { + throw new \DomainException('Data original do pagamento inválida.'); + } + if ($status !== 'pending' && (! is_int($payment['reviewed_by'] ?? null) || $payment['reviewed_by'] < 1 + || $payment['reviewed_by'] === $payment['created_by'] || ! is_int($payment['decision_audit_id'] ?? null) + || $payment['decision_audit_id'] < 1 || empty($payment['reviewed_at']))) { + throw new \DomainException('Confirmação ou rejeição sem decisão independente e auditada.'); + } + if ($status === 'pending' && (($payment['reviewed_by'] ?? null) !== null || ($payment['decision_audit_id'] ?? null) !== null || ($payment['reviewed_at'] ?? null) !== null)) { + throw new \DomainException('Pagamento pendente com decisão incompatível.'); + } + if ($totals[$status] > PHP_INT_MAX - $payment['amount_minor']) { + throw new \DomainException('Total histórico fora dos limites suportados.'); + } + $totals[$status] += $payment['amount_minor']; + $rows[$id] = ['id' => $id, 'company' => $payment['company'], 'document' => $payment['document'], + 'amount_minor' => $payment['amount_minor'], 'currency' => $payment['currency'], 'status' => $status, + 'created_by' => $payment['created_by'], 'paid_on' => $date, 'registration_audit_id' => $payment['registration_audit_id'], + 'reviewed_by' => $payment['reviewed_by'] ?? null, 'reviewed_at' => $payment['reviewed_at'] ?? null, + 'decision_audit_id' => $payment['decision_audit_id'] ?? null, + 'payment_key' => hash('sha256', Payables::canonical([$source['instance'], $source['company'], $id]))]; + } + if (! is_int($source['total_minor'] ?? null) || $totals['confirmed'] > $source['total_minor']) { + throw new \DomainException('Pagamentos confirmados superiores ao principal documental.'); + } + ksort($rows); + $capture = ['version' => self::VERSION, 'source_key' => Payables::identity($source), 'payments' => array_values($rows), + 'confirmed_minor' => $totals['confirmed'], 'pending_minor' => $totals['pending'], 'rejected_minor' => $totals['rejected']]; + + return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))]; + } + + public function imported(int $obligation): ?object + { + return $this->available() ? $this->db->table('pa_history_imports')->where($this->scope)->where('obligation_id', $obligation)->first() : null; + } + + public function record(int $obligation, int $source, int $actor, array $capture): void + { + if (! $this->available() || $this->db->transactionLevel() === 0) { + throw new \LogicException('Esquema e transacção de importação obrigatórios.'); + } + $this->db->table('pa_history_imports')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source, + 'source_key' => $capture['source_key'], 'history_hash' => $capture['history_hash'], + 'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor'], + 'snapshot' => Payables::canonical($capture), 'imported_by' => $actor, 'created_at' => date('Y-m-d H:i:s')]); + foreach ($capture['payments'] as $payment) { + $this->db->table('pa_imported_payments')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source, + 'payment_key' => $payment['payment_key'], 'legacy_payment_id' => $payment['id'], 'amount_minor' => $payment['amount_minor'], + 'status' => $payment['status'], 'snapshot' => Payables::canonical($payment), 'created_at' => date('Y-m-d H:i:s')]); + } + } + + public function matches(object $import, array $capture): bool + { + $rows = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get(); + if ($import->history_hash !== $capture['history_hash'] || $import->source_key !== $capture['source_key'] + || Payables::canonical(json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($capture) || $rows->count() !== count($capture['payments'])) { + return false; + } + foreach ($rows as $index => $row) { + $payment = $capture['payments'][$index]; + if ($row->payment_key !== $payment['payment_key'] || (int) $row->amount_minor !== $payment['amount_minor'] + || $row->status !== $payment['status'] || Payables::canonical(json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($payment)) { + return false; + } + } + + return (int) $import->confirmed_minor === $capture['confirmed_minor'] && (int) $import->pending_minor === $capture['pending_minor'] && (int) $import->rejected_minor === $capture['rejected_minor']; + } +} diff --git a/packages/payables/src/HistorySchema.php b/packages/payables/src/HistorySchema.php new file mode 100644 index 0000000..03be9dd --- /dev/null +++ b/packages/payables/src/HistorySchema.php @@ -0,0 +1,61 @@ +getDriverName(); + if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_obligations') + || Schema::hasTable('pa_history_imports') || Schema::hasTable('pa_imported_payments')) { + throw new \LogicException('Esquema anterior obrigatório; não adoptar nem reparar tabelas históricas implicitamente.'); + } + $scope = function (Blueprint $table) { + foreach (['tenant_id', 'company_id', 'unit_id'] as $key) { + $table->unsignedBigInteger($key); + } + }; + Schema::create('pa_history_imports', function (Blueprint $table) use ($scope) { + $table->id(); + $scope($table); + $table->foreignId('obligation_id')->unique()->constrained('pa_obligations'); + $table->foreignId('source_id')->unique()->constrained('pa_sources'); + $table->string('source_key', 64); + $table->string('history_hash', 64); + $table->bigInteger('confirmed_minor'); + $table->bigInteger('pending_minor'); + $table->bigInteger('rejected_minor'); + $table->jsonb('snapshot'); + $table->unsignedBigInteger('imported_by'); + $table->timestamp('created_at'); + }); + Schema::create('pa_imported_payments', function (Blueprint $table) use ($scope) { + $table->id(); + $scope($table); + $table->foreignId('obligation_id')->constrained('pa_obligations'); + $table->foreignId('source_id')->constrained('pa_sources'); + $table->string('payment_key', 64)->unique(); + $table->unsignedBigInteger('legacy_payment_id'); + $table->bigInteger('amount_minor'); + $table->string('status'); + $table->jsonb('snapshot'); + $table->timestamp('created_at'); + $table->unique(['source_id', 'legacy_payment_id']); + }); + foreach (['pa_history_imports', 'pa_imported_payments'] as $table) { + if ($driver === 'pgsql') { + DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()"); + } else { + foreach (['UPDATE', 'DELETE'] as $operation) { + $trigger = $table.'_immutable_'.strtolower($operation); + DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END"); + } + } + } + } +} diff --git a/packages/payables/src/Payables.php b/packages/payables/src/Payables.php index 461af3d..2cd0d27 100644 --- a/packages/payables/src/Payables.php +++ b/packages/payables/src/Payables.php @@ -10,7 +10,7 @@ final class Payables public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1'; public function __construct(private ConnectionInterface $db, private int $actor, private string $person, private array $scope, - private \Closure $authorize, private \Closure $loadSource) {} + private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null) {} public static function canonical(array $data): string { @@ -21,7 +21,7 @@ final class Payables ksort($v); } -return array_map($sort, $v); + return array_map($sort, $v); }; return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); @@ -65,7 +65,7 @@ return array_map($sort, $v); $m[] = 'Linhas conferidas em falta.'; } if (! array_key_exists('payment_count', $s) || $s['payment_count'] !== 0) { - $m[] = 'Histórico de pagamentos existente ou desconhecido; migração fora do âmbito.'; + $m[] = 'Histórico de pagamentos existente ou desconhecido; exige passagem de responsabilidade própria.'; } return $m; @@ -107,18 +107,116 @@ return array_map($sort, $v); throw new \DomainException('Origem já associada a outro contexto.'); } } + if ((int) $row->local_source_id !== (int) $s['local_id']) { + throw new \DomainException('Origem canónica já ligada a outro documento local.'); + } + $this->singleResponsibility($row, (int) $s['local_id']); return $row; } + private function singleResponsibility(object $source, int $local): void + { + $otherSources = $this->q('sources')->where('local_source_id', $local)->where('id', '<>', $source->id)->pluck('id')->all(); + if ($this->q('obligations')->whereIn('source_id', $otherSources)->exists()) { + throw new \DomainException('Documento local já incorporado com outra identidade; reconciliar sem segunda obrigação.'); + } + } + + private function history(array $source): array + { + if ($this->loadHistory === null || ! (new HistoricalPayments($this->db, $this->scope))->available()) { + throw new \DomainException('Passagem de histórico não activada nesta instalação.'); + } + + return HistoricalPayments::capture($source, ($this->loadHistory)($source['local_id'])); + } + + private function eligibility(array $source, bool $historical): array + { + if ($historical) { + $source['payment_count'] = 0; + } + + return self::missing($source); + } + + private function position(object $obligation, ?array $source): array + { + $store = new HistoricalPayments($this->db, $this->scope); + $import = $store->imported((int) $obligation->id); + $current = null; + $compatible = false; + if ($source) { + $compatible = ($source['revision'] ?? self::revision($source)) === $obligation->source_revision + && $this->eligibility($source, $import !== null) === []; + if ($import) { + try { + $current = (new PaymentDecisions($this->db, $this->scope))->current($import); + $compatible = $compatible && self::canonical($current) === self::canonical($this->history($source)); + } catch (\DomainException $e) { + $compatible = false; + } + } + } + $confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0); + + return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $obligation->principal_minor - $confirmed : null, + 'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed, + 'pending_payments_minor' => $current['pending_minor'] ?? ($import ? (int) $import->pending_minor : 0), 'rejected_payments_minor' => $current['rejected_minor'] ?? ($import ? (int) $import->rejected_minor : 0), + 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], + $compatible ? [] : ['Origem ou histórico divergente; saldo não presumido.'])]; + } + + public function positionForSource(int $local): ?array + { + return $this->db->transaction(function () use ($local) { + ($this->authorize)('view'); + $source = $this->source($local); + $row = $this->q('sources')->where('source_key', self::identity($source))->first(); + if (! $row) { + $ids = $this->q('sources')->where('local_source_id', $local)->pluck('id')->all(); + if ($this->q('obligations')->whereIn('source_id', $ids)->exists()) { + throw new \DomainException('Identidade da instalação alterada; reconciliar origem antes de calcular saldo.'); + } + + return null; + } + $this->singleResponsibility($row, $local); + $obligation = $this->q('obligations')->where('source_id', $row->id)->first(); + + return $obligation ? $this->position($obligation, $source) : null; + }); + } + + public function decideImportedPayment(int $local, int $payment, \Closure $writeOriginal): void + { + $this->db->transaction(function () use ($local, $payment, $writeOriginal) { + ($this->authorize)('validate'); + $source = $this->source($local); + $row = $this->locked($source); + $obligation = $this->q('obligations')->where('source_id', $row->id)->first(); + $import = $obligation ? (new HistoricalPayments($this->db, $this->scope))->imported((int) $obligation->id) : null; + if (! $import || $this->position($obligation, $source)['source_conflict']) { + throw new \DomainException('Obrigação histórica ausente ou divergente.'); + } + $writeOriginal(); + $after = $this->history($this->source($local)); + (new PaymentDecisions($this->db, $this->scope))->record($import, $after, $payment, $this->actor); + $this->event('payable.imported_payment_decided', (int) $row->id, ['obligation_id' => $obligation->id, 'payment_id' => $payment, 'history_hash' => $after['history_hash']]); + }); + } + public function command(string $action, int $id, string $reason, string $key): array { - if (! in_array($action, ['prepare', 'validate'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) { + if (! in_array($action, ['prepare', 'validate', 'prepare_history', 'validate_history'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) { throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.'); } return $this->db->transaction(function () use ($action, $id, $reason, $key) { - ($this->authorize)($action); + $historical = str_ends_with($action, '_history'); + $operation = $historical ? substr($action, 0, -8) : $action; + ($this->authorize)($operation); $hash = hash('sha256', self::canonical([$action, $id, $reason])); $old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first(); if ($old) { @@ -130,25 +228,32 @@ return array_map($sort, $v); $local = $result['local_source_id']; $s = $this->source($local); $row = $this->locked($s); + $capture = $historical ? $this->history($s) : null; $material = self::revision($s); - if (($result['source_revision'] ?? null) !== $material || self::missing($s) !== ($result['missing'] ?? [])) { + if (($result['source_revision'] ?? null) !== $material || $this->eligibility($s, $historical) !== ($result['missing'] ?? []) + || ($result['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) { return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.'); } return $result + ['replayed' => true]; } - if ($action === 'prepare') { + if ($operation === 'prepare') { $s = $this->source($id); $row = $this->locked($s); + $capture = $historical ? $this->history($s) : null; $revision = self::revision($s); - $missing = self::missing($s); + $missing = $this->eligibility($s, $historical); $ob = $this->q('obligations')->where('source_id', $row->id)->first(); if ($ob) { - $result = $ob->source_revision === $revision && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento alterado ou pagamentos associados; nenhuma dívida adicional.'); + $position = $this->position($ob, $s); + $result = ! $position['source_conflict'] && $position['history_imported'] === $historical && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento/histórico alterado ou obrigação já incorporada por outro percurso; nenhuma dívida adicional.'); } else { $candidate = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first(); - if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing) { - $pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($s), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]); + $oldCapture = $candidate ? (json_decode($candidate->snapshot, true)['_history_import'] ?? null) : null; + if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing + || ($oldCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) { + $snapshot = $s + ($capture ? ['_history_import' => $capture] : []); + $pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($snapshot), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]); $this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]); } else { $pid = $candidate->id; @@ -165,21 +270,37 @@ return array_map($sort, $v); } $row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first(); $local = (int) $row->local_source_id; + $this->singleResponsibility($row, $local); $s = $this->source($local); + $capture = $historical ? $this->history($s) : null; + $preparedCapture = json_decode($p->snapshot, true)['_history_import'] ?? null; + if (($preparedCapture !== null) !== $historical) { + throw new \DomainException('Usar o percurso de validação correspondente à proposta.'); + } $revision = self::revision($s); - $missing = self::missing($s); + $missing = $this->eligibility($s, $historical); $latest = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first(); $ob = $this->q('obligations')->where('source_id', $row->id)->first(); - if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []) { + if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== [] + || ($preparedCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) { $result = $this->conflict($row, $local, 'Revisão/decisão/pagamentos incompatíveis ou informação insuficiente.'); } elseif ($ob) { - $result = ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []]; + $position = $this->position($ob, $s); + $result = ! $position['source_conflict'] && $position['history_imported'] === $historical ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $local, 'Responsabilidade existente incompatível; nenhuma segunda obrigação.'); } else { $oid = $this->db->table('pa_obligations')->insertGetId($this->scope + ['source_id' => $row->id, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'currency' => 'MZN', 'due_on' => null, 'recognised_by' => $this->actor, 'recognised_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]); + if ($capture) { + (new HistoricalPayments($this->db, $this->scope))->record((int) $oid, (int) $row->id, $this->actor, $capture); + $this->event('payable.legacy_history_imported', $row->id, ['obligation_id' => $oid, 'history_hash' => $capture['history_hash'], + 'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor']]); + } $this->event('payable.recognised', $row->id, ['obligation_id' => $oid, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'due_on' => null]); $result = ['state' => 'recognised', 'obligation_id' => $oid, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []]; } } + if ($capture && ($result['state'] ?? '') !== 'conflict') { + $result['history_hash'] = $capture['history_hash']; + } $this->db->table('pa_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => self::canonical($result), 'created_at' => date('Y-m-d H:i:s')]); return $result + ['replayed' => false]; @@ -209,10 +330,8 @@ return array_map($sort, $v); $obs = $this->q('obligations')->orderBy('id')->get()->map(function ($o) use ($current) { $origin = $this->q('sources')->where('id', $o->source_id)->first(); $doc = $current[$origin->source_key] ?? null; - // Additional UI-derived revision/missing fields are not part of the material source hash. - $compatible = $doc && $doc['revision'] === $o->source_revision && $doc['missing'] === []; - return (array) $o + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $o->principal_minor : null, 'source_conflict' => ! $compatible, 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], $compatible ? [] : ['Origem/eligibilidade divergente; saldo não presumido.'])]; + return $this->position($o, $doc); })->all(); return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $obs, 'proposals' => $this->q('proposals')->orderBy('id')->get(), 'events' => $this->q('events')->orderBy('id')->get(), 'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor]; diff --git a/packages/payables/src/PayablesSchema.php b/packages/payables/src/PayablesSchema.php new file mode 100644 index 0000000..e28c414 --- /dev/null +++ b/packages/payables/src/PayablesSchema.php @@ -0,0 +1,109 @@ +getDriverName(); + if (! in_array($driver, ['pgsql', 'sqlite'], true)) { + throw new \LogicException('Unsupported payables database.'); + } + $tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_outbox', 'pa_commands']; + $all = [...$tables, 'pa_access']; + $existing = array_filter($all, fn ($table) => Schema::hasTable($table)); + if ($existing) { + throw new \LogicException('Payables tables already exist; explicit schema reconciliation required.'); + } + $scope = function (Blueprint $t) { + foreach (['tenant_id', 'company_id', 'unit_id'] as $k) { + $t->unsignedBigInteger($k); + } + }; + Schema::create('pa_access', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->unsignedBigInteger('user_id'); + $t->string('person'); + $t->string('action'); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'user_id', 'action']); + }); + Schema::create('pa_sources', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->string('source_key', 64)->unique(); + $t->unsignedBigInteger('local_source_id'); + $t->timestamp('created_at'); + }); + Schema::create('pa_proposals', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('source_id')->constrained('pa_sources'); + $t->string('source_revision', 64); + $t->integer('capture_version'); + $t->jsonb('snapshot'); + $t->jsonb('missing'); + $t->unsignedBigInteger('prepared_by'); + $t->string('prepared_person'); + $t->text('reason'); + $t->timestamp('created_at'); + $t->unique(['source_id', 'capture_version']); + }); + Schema::create('pa_obligations', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('source_id')->unique()->constrained('pa_sources'); + $t->foreignId('proposal_id')->constrained('pa_proposals'); + $t->string('source_revision', 64); + $t->bigInteger('principal_minor'); + $t->string('currency', 3); + $t->date('due_on')->nullable(); + $t->unsignedBigInteger('recognised_by'); + $t->string('recognised_person'); + $t->text('reason'); + $t->timestamp('created_at'); + }); + Schema::create('pa_commands', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->unsignedBigInteger('actor_id'); + $t->uuid('command_key'); + $t->string('request_hash', 64); + $t->jsonb('result'); + $t->timestamp('created_at'); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']); + }); + Schema::create('pa_events', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('source_id')->constrained('pa_sources'); + $t->unsignedBigInteger('actor_id'); + $t->string('kind'); + $t->jsonb('payload'); + $t->timestamp('created_at'); + }); + Schema::create('pa_outbox', function (Blueprint $t) { + $t->foreignId('event_id')->primary()->constrained('pa_events'); + $t->string('state'); + }); + if ($driver === 'pgsql') { + DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$"); + foreach ($tables as $table) { + DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()"); + } + } else { + foreach ($tables as $table) { + foreach (['UPDATE', 'DELETE'] as $operation) { + $trigger = $table.'_immutable_'.strtolower($operation); + DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END"); + } + } + } + } +} diff --git a/packages/payables/src/PaymentDecisionSchema.php b/packages/payables/src/PaymentDecisionSchema.php new file mode 100644 index 0000000..692c522 --- /dev/null +++ b/packages/payables/src/PaymentDecisionSchema.php @@ -0,0 +1,40 @@ +getDriverName(); + if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_imported_payments') || Schema::hasTable('pa_payment_decisions')) { + throw new \LogicException('Histórico anterior obrigatório; não adoptar tabela de decisões existente.'); + } + Schema::create('pa_payment_decisions', function (Blueprint $table) { + $table->id(); + foreach (['tenant_id', 'company_id', 'unit_id'] as $key) { + $table->unsignedBigInteger($key); + } + $table->foreignId('obligation_id')->constrained('pa_obligations'); + $table->foreignId('imported_payment_id')->unique()->constrained('pa_imported_payments'); + $table->unsignedInteger('sequence'); + $table->string('previous_hash', 64); + $table->string('history_hash', 64); + $table->jsonb('payment_snapshot'); + $table->unsignedBigInteger('actor_id'); + $table->timestamp('created_at'); + $table->unique(['obligation_id', 'sequence']); + }); + if ($driver === 'pgsql') { + DB::unprepared('CREATE TRIGGER pa_decision_immutable BEFORE UPDATE OR DELETE ON pa_payment_decisions FOR EACH ROW EXECUTE FUNCTION pa_append_only()'); + } else { + foreach (['UPDATE', 'DELETE'] as $operation) { + DB::unprepared('CREATE TRIGGER pa_decision_'.strtolower($operation)." BEFORE {$operation} ON pa_payment_decisions BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END"); + } + } + } +} diff --git a/packages/payables/src/PaymentDecisions.php b/packages/payables/src/PaymentDecisions.php new file mode 100644 index 0000000..3f756e9 --- /dev/null +++ b/packages/payables/src/PaymentDecisions.php @@ -0,0 +1,108 @@ + PHP_INT_MAX - $payment['amount_minor']) { + throw new \DomainException('Totais de decisões incompatíveis.'); + } + $capture[$key] += $payment['amount_minor']; + } + + return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))]; + } + + private function transition(array $capture, array $next, int $actor): array + { + foreach ($capture['payments'] as $index => $payment) { + if ($payment['id'] !== ($next['id'] ?? null)) { + continue; + } + $unchanged = ['id', 'company', 'document', 'amount_minor', 'currency', 'created_by', 'paid_on', 'registration_audit_id', 'payment_key']; + if ($payment['status'] !== 'pending' || ! in_array($next['status'] ?? null, ['confirmed', 'rejected'], true) + || ($next['reviewed_by'] ?? null) !== $actor || $actor === $payment['created_by'] + || ! is_int($next['decision_audit_id'] ?? null) || $next['decision_audit_id'] < 1 || empty($next['reviewed_at']) + || count($payment) !== count($next) || array_diff_key($payment, $next) !== []) { + throw new \DomainException('Decisão histórica inválida ou já final.'); + } + foreach ($unchanged as $key) { + if ($payment[$key] !== $next[$key]) { + throw new \DomainException('A decisão não pode reescrever o pagamento original.'); + } + } + $capture['payments'][$index] = $next; + + return $this->rebuild($capture); + } + throw new \DomainException('Pagamento ausente do histórico importado.'); + } + + public function current(object $import): array + { + $capture = json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR); + if (Payables::canonical($this->rebuild($capture)) !== Payables::canonical($capture) + || ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture)) { + throw new \DomainException('Fotografia original da importação incompatível.'); + } + if (! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) { + return $capture; + } + $sequence = 0; + foreach ($this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) { + $next = json_decode($decision->payment_snapshot, true, 64, JSON_THROW_ON_ERROR); + $link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first(); + if ((int) $decision->sequence !== ++$sequence || $decision->previous_hash !== $capture['history_hash'] || ! $link || (int) $link->legacy_payment_id !== $next['id']) { + throw new \DomainException('Cadeia de decisões históricas incompatível.'); + } + $capture = $this->transition($capture, $next, (int) $decision->actor_id); + if ($decision->history_hash !== $capture['history_hash']) { + throw new \DomainException('Decisão histórica divergente.'); + } + } + + return $capture; + } + + public function record(object $import, array $after, int $paymentId, int $actor): void + { + if ($this->db->transactionLevel() === 0 || ! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) { + throw new \LogicException('Esquema e transacção obrigatórios para decidir histórico.'); + } + $before = $this->current($import); + $next = null; + foreach ($after['payments'] as $payment) { + if ($payment['id'] === $paymentId) { + $next = $payment; + } + } + if (! $next) { + throw new \DomainException('Pagamento ausente da nova fotografia.'); + } + $expected = $this->transition($before, $next, $actor); + if (Payables::canonical($expected) !== Payables::canonical($after)) { + throw new \DomainException('Alterações fora da decisão pendente; operação recusada.'); + } + $link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('legacy_payment_id', $paymentId)->first(); + if (! $link || $link->status !== 'pending') { + throw new \DomainException('Apenas pendentes originalmente importados podem ser decididos.'); + } + $sequence = 1 + (int) $this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->max('sequence'); + $this->db->table('pa_payment_decisions')->insert($this->scope + ['obligation_id' => $import->obligation_id, 'imported_payment_id' => $link->id, + 'sequence' => $sequence, 'previous_hash' => $before['history_hash'], 'history_hash' => $after['history_hash'], + 'payment_snapshot' => Payables::canonical($next), 'actor_id' => $actor, 'created_at' => date('Y-m-d H:i:s')]); + } +} diff --git a/packages/payables/ui/page.php b/packages/payables/ui/page.php index 57b1a02..7c747b7 100644 --- a/packages/payables/ui/page.php +++ b/packages/payables/ui/page.php @@ -1,19 +1,21 @@ htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $money=fn($v,$currency='MZN')=>number_format(intdiv((int)$v,100),0,'.',' ').'.'.str_pad((string)abs((int)$v%100),2,'0',STR_PAD_LEFT).' '.$e($currency); ?>
= $e($settings['host']) ?> · = $e($settings['actor']) ?>
= $e($settings['organisation']) ?>
ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso só reconhece o principal.
Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.
Trocar função ou sair da demonstração
← = $e($settings['return_label']??'Voltar à aplicação') ?>= $e($settings['host']) ?> · = $e($settings['actor']) ?>
= $e($settings['organisation']) ?>
ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso reconhece o principal e, quando activado, preserva o histórico existente.
Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.
| Documento | Fornecedor | Base | Imposto documental | Total | Conferência / revisão |
|---|---|---|---|---|---|
| = $e($d['number']??'Indeterminado') ?> | = $e($d['supplier_name']??'Indeterminado') ?> | = isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?> | = isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?> | = isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?> | = $e($d['status']) ?>Origem e pendências= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?> |
Preserva o principal integral e liga os pagamentos originais uma única vez. Só os confirmados reduzem o saldo; pendentes e rejeitados mantêm o seu estado. Não executa nem confirma pagamentos.
Ainda não há propostas.
Preparador #= $e($p->prepared_by) ?> · = $e($p->created_at) ?>
= $e($p->reason) ?>
= $e($p->snapshot) ?>
= $e($p->missing) ?>
Histórico proposto: confirmados = $money($history['confirmed_minor']) ?> · pendentes = $money($history['pending_minor']) ?> · rejeitados = $money($history['rejected_minor']) ?>. Os pendentes e rejeitados não reduzem o saldo.
+Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.
= $money($o['principal_minor']) ?>
Principal reconhecido — vencimento pendente
Atraso indeterminado · Dias de atraso: sem valor
Validador #= $e($o['recognised_by']) ?> · = $e($o['created_at']) ?>
= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?>
= $money($o['principal_minor']) ?>
Principal reconhecido — vencimento pendente
Pagamentos históricos confirmados: = $money($o['confirmed_payments_minor']) ?>
Pendentes: = $money($o['pending_payments_minor']) ?> · Rejeitados: = $money($o['rejected_payments_minor']) ?> — sem abatimento.
Saldo por liquidar: = $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?>
Importação original preservada. A conferência independente dos pendentes actualiza este saldo uma única vez.
Conferir pagamentos pendentes →
Importação original preservada. Conferência de pendentes não activada nesta instalação.
Origem ou histórico divergente; saldo não presumido. O principal original permanece preservado.
Atraso indeterminado · Dias de atraso: sem valor
Validador #= $e($o['recognised_by']) ?> · = $e($o['created_at']) ?>
= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?>
= $e($event->payload) ?>
Sem pagamento, cobrança, afectação, stock, novo consumo orçamental ou lançamento automático. A consulta não marca o documento como pago.
+Sem execução de novos pagamentos, cobrança, efeitos de stock, novo consumo orçamental ou lançamento automático. A passagem de histórico não confirma pagamentos pendentes.