commit3
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__.'/src/Payables.php';
|
||||
require_once __DIR__.'/src/Workspace.php';
|
||||
require_once __DIR__.'/src/PayablesSchema.php';
|
||||
require_once __DIR__.'/src/HistoricalPayments.php';
|
||||
require_once __DIR__.'/src/HistorySchema.php';
|
||||
require_once __DIR__.'/src/PaymentDecisions.php';
|
||||
require_once __DIR__.'/src/PaymentDecisionSchema.php';
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<?php
|
||||
|
||||
// Explicitly called by disposable/new QA setup only, never registered in host migrations.
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
$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.');
|
||||
$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();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** Imports existing facts only; never executes or confirms a payment. */
|
||||
final class HistoricalPayments
|
||||
{
|
||||
public const VERSION = 'PAYABLE-HISTORY-IMPORT-v1';
|
||||
|
||||
public function __construct(private ConnectionInterface $db, private array $scope) {}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return $this->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'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class HistorySchema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
$driver = DB::connection()->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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Shared additive schema; installation never grants access or imports documents. */
|
||||
final class PayablesSchema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
$driver = DB::connection()->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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class PaymentDecisionSchema
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
$driver = DB::connection()->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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** Immutable final decisions on payments that were pending when imported. */
|
||||
final class PaymentDecisions
|
||||
{
|
||||
public function __construct(private ConnectionInterface $db, private array $scope) {}
|
||||
|
||||
private function rebuild(array $capture): array
|
||||
{
|
||||
unset($capture['history_hash']);
|
||||
foreach (['confirmed', 'pending', 'rejected'] as $status) {
|
||||
$capture[$status.'_minor'] = 0;
|
||||
}
|
||||
foreach ($capture['payments'] as $payment) {
|
||||
$key = $payment['status'].'_minor';
|
||||
if (! array_key_exists($key, $capture) || $payment['amount_minor'] < 1 || $capture[$key] > 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')]);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
<?php $e=fn($v)=>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); ?>
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Obrigações a fornecedores — ensaio</title>
|
||||
<style>body{font:16px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;margin:18px 0;border:1px solid #d5e0dc;border-radius:12px}h1{font-size:28px}h2{font-size:20px}.notice{padding:16px;background:#fff2cc}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}label{display:block;margin:12px 0}select,textarea,button{font:inherit;padding:10px;max-width:100%}textarea{display:block;width:90%}button{background:#125a4b;color:white;border:0;border-radius:6px;cursor:pointer}button:disabled{opacity:.5}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.scroll{overflow:auto}.amount{font-size:26px;font-weight:bold}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
|
||||
<header><a href="/">← Voltar à aplicação</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso só reconhece o principal.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
|
||||
<header><?php if(session()->get('payables_demo_entry')): ?><p><a href="/demo">Trocar função ou sair da demonstração</a></p><?php endif ?><a href="<?= $e($settings['return_url']??'/') ?>">← <?= $e($settings['return_label']??'Voltar à aplicação') ?></a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">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.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
|
||||
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
|
||||
<section><h2>Facturas de origem</h2><div class="scroll"><table><thead><tr><th>Documento</th><th>Fornecedor</th><th>Base</th><th>Imposto documental</th><th>Total</th><th>Conferência / revisão</th></tr></thead><tbody>
|
||||
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?></td><td><?= $e($d['supplier_name']??'Indeterminado') ?></td><td><?= isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?></td><td><?= isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?></td><td><?= isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?></td><td><?= $e($d['status']) ?><details><summary>Origem e pendências</summary><pre><?= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></td></tr><?php endforeach ?>
|
||||
</tbody></table></div></section>
|
||||
<?php if($data['permissions']['prepare']): ?><section><h2>Preparar a obrigação</h2><form data-action="prepare"><label>Factura existente<select name="id"><?php foreach($data['documents'] as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Proposta sintética com base na factura conferida.</textarea></label><p>Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.</p><button>Preparar da origem</button></form></section><?php endif ?>
|
||||
<?php $historyDocuments=array_filter($data['documents'],fn($d)=>($d['payment_count']??0)>0); if(($data['history_import_available']??false)&&$data['permissions']['prepare']&&$historyDocuments): ?><section><h2>Incorporar factura com pagamentos anteriores</h2><p>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.</p><form data-action="prepare_history"><label>Factura com histórico<select name="id"><?php foreach($historyDocuments as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Passagem de responsabilidade com preservação dos pagamentos históricos.</textarea></label><button>Preparar passagem com histórico</button></form></section><?php endif ?>
|
||||
<section><h2>Propostas e decisões</h2><?php if(!$data['proposals']->count()): ?><p>Ainda não há propostas.</p><?php endif ?>
|
||||
<?php foreach($data['proposals'] as $p): ?><article><h3>Proposta #<?= $e($p->id) ?> · captura <?= $e($p->capture_version) ?></h3><p>Preparador #<?= $e($p->prepared_by) ?> · <?= $e($p->created_at) ?></p><p><?= $e($p->reason) ?></p><details><summary>Fotografia e pendências</summary><pre><?= $e($p->snapshot) ?></pre><pre><?= $e($p->missing) ?></pre></details>
|
||||
<?php if($data['permissions']['validate']): ?><form data-action="validate"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Validar revisão exacta e reconhecer</button></form><?php endif ?></article><?php endforeach ?></section>
|
||||
<?php $history=json_decode($p->snapshot,true)['_history_import']??null; if($history): ?><p>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.</p><?php endif ?>
|
||||
<?php if($data['permissions']['validate']&&(!$history||($data['history_import_available']??false))): ?><form data-action="<?= $history?'validate_history':'validate' ?>"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button><?= $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?></button></form><?php endif ?></article><?php endforeach ?></section>
|
||||
<section><h2>Obrigações reconhecidas</h2><?php if(!$data['obligations']): ?><p>Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.</p><?php endif ?><div class="grid">
|
||||
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
|
||||
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><?php if($o['history_imported']??false): ?><p>Pagamentos históricos confirmados: <?= $money($o['confirmed_payments_minor']) ?></p><p>Pendentes: <?= $money($o['pending_payments_minor']) ?> · Rejeitados: <?= $money($o['rejected_payments_minor']) ?> — sem abatimento.</p><p><strong>Saldo por liquidar: <?= $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?></strong></p><?php if($data['history_decisions_available']??false): ?><p>Importação original preservada. A conferência independente dos pendentes actualiza este saldo uma única vez.</p><p><a href="/pagamentos">Conferir pagamentos pendentes →</a></p><?php else: ?><p>Importação original preservada. Conferência de pendentes não activada nesta instalação.</p><?php endif ?><?php endif ?><?php if($o['source_conflict']??false): ?><p role="alert">Origem ou histórico divergente; saldo não presumido. O principal original permanece preservado.</p><?php endif ?><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
|
||||
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
</main><script>
|
||||
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;
|
||||
document.querySelector('#reload').onclick=()=>location.reload();
|
||||
|
||||
@@ -47,3 +47,16 @@
|
||||
.workspace main{padding:20px 16px}.home-context dl{grid-template-columns:1fr}.workspace header .local-badge{white-space:normal;text-align:center}
|
||||
.home-shortcuts{display:grid;grid-template-columns:1fr}.context-card{grid-template-columns:1fr}.workspace header strong{overflow-wrap:anywhere}.workspace header .local-badge{display:block}
|
||||
}
|
||||
|
||||
/* Facturação: document lists and filters, kept separate from financial engines. */
|
||||
.billing-section-tabs{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 20px}.billing-section-tabs [aria-current="page"]{background:#125a4b;color:white;border-color:#125a4b}.billing-toolbar{margin-bottom:14px}.billing-filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0 18px}.billing-filters label:first-child{grid-column:1/-1}.billing-number{font-variant-numeric:tabular-nums;white-space:nowrap}.billing-document-list th{white-space:nowrap}.billing-document-detail{overflow-wrap:anywhere}.billing-menu-workspace .fiscal-notice{display:block}.billing-menu-workspace .fiscal-notice strong{margin-right:8px}@media(max-width:560px){.billing-section-tabs button{flex:1 1 140px}.billing-filters{grid-template-columns:1fr}.billing-toolbar button{width:100%}}
|
||||
|
||||
/* Stock information menu. No operational balance controls until a source is connected. */
|
||||
.stock-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.stock-tabs [aria-current="page"]{background:#125a4b;color:white;border-color:#125a4b}.stock-route-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}.stock-route-grid button{text-align:left;padding:18px}.stock-route-grid span{display:block;font-size:.8rem;font-weight:400;margin-top:8px;line-height:1.5}.stock-definitions{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px}.stock-definitions div{padding:16px;background:#f3f7f4;border-radius:8px}.stock-definitions dt{font-weight:700}.stock-definitions dd{margin:8px 0 0;line-height:1.6}.stock-unavailable{font-weight:600}.stock-menu-workspace li{line-height:1.65;margin-bottom:8px}@media(max-width:560px){.stock-tabs button{flex:1 1 130px}}
|
||||
.navigation-status{display:block;font-size:.65rem;font-weight:400;color:#c5dace;margin-top:3px}.shortcut-status{display:block;font-size:.7rem;font-weight:400;color:#5d716d;margin-top:4px}
|
||||
|
||||
/* Accounting navigation and report definitions, without accounting balances. */
|
||||
.accounting-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.accounting-tabs [aria-current="page"],.accounting-report-list [aria-pressed="true"]{background:#125a4b;color:white;border-color:#125a4b}.accounting-routes,.accounting-report-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.accounting-routes button,.accounting-report-list button{text-align:left;padding:16px}.accounting-routes span,.accounting-report-list small{display:block;font-size:.8rem;font-weight:400;line-height:1.6;margin-top:8px}.accounting-report-detail{margin-top:24px;padding:18px;border-left:4px solid #4f8775;background:#f3f7f4;overflow-wrap:anywhere}@media(max-width:560px){.accounting-tabs button{flex:1 1 140px}}
|
||||
|
||||
/* Horizontal subject navigation. Dropdowns retain every permitted destination. */
|
||||
.shell{display:block}.workspace-topbar{position:sticky;top:0;z-index:70;display:flex;align-items:center;flex-wrap:wrap;gap:12px 20px;padding:12px 24px;background:var(--tempo-dark,#090909);border-top:4px solid var(--tempo-accent,#ed1c24);color:#fff;box-shadow:0 3px 12px #0002}.topbar-brand{color:#fff;text-decoration:none;font-weight:750;letter-spacing:.1em;white-space:nowrap}.topbar-brand span{font-size:.72rem;letter-spacing:.18em;color:#cbd5e1}.topic-navigation{position:relative;display:flex;align-items:center;flex-wrap:wrap;gap:4px;flex:1}.navigation-topic{position:static}.workspace-topbar .topic-trigger{background:transparent;border:1px solid transparent;color:#e2e8f0;font-size:1rem;padding:10px 12px;white-space:nowrap}.workspace-topbar .topic-trigger:hover,.workspace-topbar .topic-trigger[aria-expanded="true"]{background:#262626;color:#fff}.workspace-topbar .topic-trigger.topic-active{border-bottom-color:var(--tempo-accent,#ed1c24);color:#fff;background:#262626}.topic-dropdown{position:absolute;top:calc(100% + 8px);left:0;width:min(680px,calc(100vw - 32px));max-height:70dvh;overflow-y:auto;padding:18px;background:white;color:var(--tempo-ink,#171717);border:1px solid var(--tempo-line,#e2e8f0);border-radius:10px;box-shadow:0 16px 42px #0003}.topic-dropdown[hidden]{display:none}.topic-align-right .topic-dropdown{right:0;left:auto}.topic-sections{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr));gap:18px}.topic-sections h2{font-size:.88rem;text-transform:uppercase;letter-spacing:.05em;margin:0 0 8px;padding:0 10px 8px;border-bottom:1px solid #e2e8f0}.topic-sections h2 small{display:block;font-size:.65rem;font-weight:400;text-transform:none;letter-spacing:0;color:#64748b;margin-top:4px}.workspace-topbar .topic-item{display:block;width:100%;text-align:left;border:0;border-radius:6px;font-size:.95rem;font-weight:500;padding:10px;color:var(--tempo-ink,#171717);background:white;line-height:1.5}.workspace-topbar .topic-item:hover{background:var(--tempo-accent-soft,#fef2f2)}.workspace-topbar .topic-item-active{background:var(--tempo-accent-soft,#fef2f2);border-left:3px solid var(--tempo-accent,#ed1c24);font-weight:700}.topic-item small{display:block;font-size:.75rem;font-weight:400;color:#64748b}.topbar-account{display:flex;align-items:center;gap:12px;margin-left:auto}.topbar-account strong,.topbar-account small{display:block;max-width:190px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.topbar-account strong{font-size:.8rem}.topbar-account small{font-size:.7rem;color:#cbd5e1}.topbar-account button{background:#262626;color:white;border-color:#525252;font-size:.8rem}.workspace>header{position:static;padding:22px 32px;min-height:0}.workspace main{max-width:1500px;margin:0 auto}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}@media(max-width:1100px){.topic-navigation{order:3;flex-basis:100%;position:relative}.navigation-topic{position:static}.topic-dropdown,.topic-align-right .topic-dropdown{top:calc(100% + 8px);left:0;right:auto;width:min(680px,calc(100vw - 32px))}}@media(max-width:560px){.workspace-topbar{padding:10px 14px;gap:8px}.topic-navigation{gap:3px}.workspace-topbar .topic-trigger{font-size:.9rem;padding:9px 8px}.topic-dropdown,.topic-align-right .topic-dropdown{width:calc(100vw - 28px);max-height:60dvh;padding:14px}.topic-sections{grid-template-columns:1fr}.topbar-account strong,.topbar-account small{max-width:130px}.topbar-account small{display:none}.workspace>header{padding:18px}.workspace main{padding:18px}.workspace header .local-badge{display:block}}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {accountingSections,visibleAccountingSections,accountingFunctions,accountingReports,filterAccountingReports} from './accounting-menu.js';
|
||||
|
||||
export default function AccountingMenuWorkspace({section,context,can,navigate}) {
|
||||
const [search,setSearch]=useState(''),[family,setFamily]=useState(''),[selected,setSelected]=useState(null);
|
||||
useEffect(()=>{setSearch('');setFamily('');setSelected(null);},[context.token]);
|
||||
if(!visibleAccountingSections(can).length)return <section className="panel"><h1>Contabilidade</h1><p>Sem contexto autorizado para esta consulta.</p></section>;
|
||||
const active=accountingSections.find(s=>s.id===section);if(!active)return null;
|
||||
const unit=context.unit?.id??null;
|
||||
const action=f=>f.target&&(!f.unit||unit!==null)&&can(f.permission,f.unit?unit:null)?<button onClick={()=>navigate(f.target)}>{f.action}</button>:null;
|
||||
const functions=accountingFunctions.filter(f=>f.section===section);
|
||||
const report=accountingReports.find(r=>r.id===selected);
|
||||
return <div className="accounting-menu-workspace"><div className="page-title"><p className="eyebrow">CONTABILIDADE</p><h1>{active.label}</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>
|
||||
<nav className="accounting-tabs" aria-label="Submenus de contabilidade">{visibleAccountingSections(can).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
|
||||
<p className="alert home-notice">Navegação em preparação. Classificação contabilística, documentos, obrigações, liquidações e lançamentos têm responsabilidades distintas. Este menu não gera lançamentos ou declarações fiscais.</p>
|
||||
{section==='accounting_panel'&&<>
|
||||
<section className="panel"><h2>Trabalho contabilístico no contexto seleccionado</h2><p>Ainda não há fonte agregada de documentos por validar, contas por reconciliar ou estado do fecho. A indisponibilidade não é apresentada como zero pendências.</p><div className="accounting-routes">{accountingSections.filter(s=>s.id!==section).map(s=><button key={s.id} onClick={()=>navigate(s.id)}>{s.label}<span>Consultar percursos e disponibilidade →</span></button>)}</div></section>
|
||||
<section className="panel"><h2>Percursos já disponíveis</h2><div className="actions">{can('catalogue_accounting.view')&&<button onClick={()=>navigate('catalogue_accounting')}>Classificação contabilística</button>}{can('service_fiscal.view')&&<button onClick={()=>navigate('service_fiscal')}>Perfis fiscais do serviço</button>}{unit!==null&&can('service_receivables.view',unit)&&<button onClick={()=>navigate('service_receivables')}>Recebimentos e reconciliação de teste</button>}</div><p>Estas ligações conservam os direitos e os limites dos respectivos módulos; não representam uma contabilidade geral já integrada.</p></section>
|
||||
</>}
|
||||
{functions.map(f=><section className="panel" key={f.id}><h2>{f.label}</h2><p>{f.description}</p><p><strong>Disponibilidade:</strong> {f.status}</p>{action(f)}</section>)}
|
||||
{section==='accounting_daily'&&<section className="panel"><h2>Origem documental</h2><p>Documento recebido → preparação → classificação → conferência → validação → reconciliação → relatório → fecho. As etapas que ainda não têm implementação não são executadas por este menu.</p><div className="actions">{unit!==null&&can('billing_drafts.view',unit)&&<button onClick={()=>navigate('billing_drafts')}>Abrir rascunhos de facturação existentes</button>}{unit!==null&&can('supplier_payables.view',unit)&&<button onClick={()=>navigate('supplier_payables')}>Abrir obrigações a fornecedores de teste</button>}</div><p>Uma obrigação com principal reconhecido e vencimento desconhecido continua sem classificação automática de atraso. Preparar ou aprovar um pagamento não comprova execução bancária.</p></section>}
|
||||
{section==='accounting_analysis'&&<section className="panel"><h2>Catálogo de relatórios previstos</h2><p>Catálogo para orientar a implementação. Não contém relatórios gerados, saldos, dados salariais ou bancários.</p><div className="form-grid"><label>Pesquisar relatório<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Área<select value={family} onChange={e=>setFamily(e.target.value)}><option value="">Todas as áreas</option>{[...new Set(accountingReports.map(r=>r.family))].map(f=><option key={f}>{f}</option>)}</select></label></div>
|
||||
<div className="accounting-report-list">{filterAccountingReports(search,family).map(r=><button key={r.id} aria-pressed={r.id===selected} onClick={()=>setSelected(r.id)}>{r.label}<small>{r.family} · Por implementar</small></button>)}</div>{!filterAccountingReports(search,family).length&&<p>Nenhuma definição de relatório corresponde à pesquisa. Isto não é uma consulta de movimentos.</p>}
|
||||
{report&&<article className="accounting-report-detail"><h3>{report.label}</h3><p>{report.description}</p><p role="status"><strong>Relatório indisponível.</strong> Falta a fonte contabilística/auxiliar validada e a configuração aplicável. Não há geração, impressão ou exportação nesta página.</p><p>Filtros previstos: empresa, período ou data de referência, moeda e dimensões efectivamente aplicáveis. Os resultados e as exportações terão as mesmas restrições de acesso, com ligação aos documentos de origem.</p></article>}
|
||||
<p>Não se misturam empresas, períodos ou moedas sem base consistente. Rascunhos, movimentos validados e previsões não são somados como se fossem equivalentes. As demonstrações serão reconciliadas com razão e balancete.</p>
|
||||
</section>}
|
||||
{section==='accounting_configuration'&&<section className="panel"><h2>Fiscalidade separada da entrada e da classificação</h2><p>O menu não adopta regras fiscais a partir de referências comerciais ou da comparação com outros produtos. Os perfis fiscais existentes mantêm as suas versões, decisões e bloqueios próprios.</p>{can('service_fiscal.view')&&<button onClick={()=>navigate('service_fiscal')}>Abrir perfis fiscais existentes</button>}</section>}
|
||||
<section className="panel"><h2>Funções e responsabilidade</h2><p>Gestão acompanha e aprova dentro das suas alçadas; Contabilidade prepara, confere e valida conforme mandato; Tesouraria prepara operações e reconcilia dentro do seu âmbito. Estes nomes descrevem a organização proposta, não atribuem permissões à conta.</p><p>Preparação, aprovação, execução e revisão permanecem distintas. Administração técnica não substitui autorização financeira. Dados bancários, salários, custos e auditoria exigem consulta própria; não são carregados neste mapa.</p><p>Correcções e estornos preservam o original, com motivo e autorização. Nenhum modelo de relatório, menu ou comprovativo activa um cálculo, pagamento, fecho ou obrigação fiscal.</p></section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {billingSections,visibleBillingSections,filterBillingRows,loadBillingRows} from './billing-menu.js';
|
||||
|
||||
const emptyFilters={search:'',type:'',currency:'',series:'',state:'',author:'',from:'',to:''};
|
||||
const value=v=>v===null||v===undefined||v===''?'Por determinar':String(v);
|
||||
|
||||
export function BillingDocumentList({rows,filters,onFilter,onClear,onOpen}) {
|
||||
const filtered=filterBillingRows(rows,filters);
|
||||
const options=field=>[...new Set(rows.map(r=>r[field]).filter(v=>v!==null&&v!==undefined&&v!==''))].sort();
|
||||
return <section className="panel billing-document-list"><h2>Lista de documentos</h2><div className="billing-filters">
|
||||
<label>Pesquisar número, cliente ou NUIT<input type="search" value={filters.search} onChange={e=>onFilter('search',e.target.value)}/></label>
|
||||
<label>Tipo<select value={filters.type} onChange={e=>onFilter('type',e.target.value)}><option value="">Todos os tipos disponíveis</option>{options('type').map(t=><option value={t} key={t}>{t==='invoice'?'Factura de teste':'Rascunho'}</option>)}</select></label>
|
||||
{['currency','series','state','author'].map(field=><label key={field}>{{currency:'Moeda',series:'Série',state:'Estado documental',author:'Autor (identificador registado)'}[field]}<select value={filters[field]} onChange={e=>onFilter(field,e.target.value)}><option value="">Todos</option>{options(field).map(v=><option key={v} value={v}>{v}</option>)}</select></label>)}
|
||||
<label>Emissão desde<input type="date" value={filters.from} onChange={e=>onFilter('from',e.target.value)}/></label><label>Emissão até<input type="date" value={filters.to} onChange={e=>onFilter('to',e.target.value)}/></label>
|
||||
</div><button onClick={onClear}>Limpar filtros</button>{filters.from&&filters.to&&filters.from>filters.to?<p role="alert">A data inicial deve ser anterior ou igual à data final.</p>:<>
|
||||
<p role="status">{filtered.length} documento(s) nesta consulta.</p><div className="table-scroll"><table><thead><tr><th>Número / referência</th><th>Tipo</th><th>Cliente</th><th>Emissão</th><th>Vencimento</th><th>Total</th><th>Moeda</th><th>Saldo por receber</th><th>Estados</th><th>Acções</th></tr></thead><tbody>{filtered.map(r=><tr key={r.key}><td>{r.number}{r.name&&<small>{r.name}</small>}{r.version&&<small>Versão {r.version}</small>}</td><td>{r.typeLabel}</td><td>{value(r.client)}{r.nuit&&<small>NUIT {r.nuit}</small>}</td><td>{r.type==='draft'?'Não emitido':value(r.issuedDate)}</td><td>{r.type==='draft'?'Não aplicável':value(r.dueDate)}</td><td className="billing-number">{r.type==='draft'?'Provisório — por determinar':value(r.total)}{r.base!==undefined&&r.base!==null&&<small>Base aritmética: {r.base} (não é total fiscal)</small>}</td><td>{value(r.currency)}</td><td>{r.type==='draft'?'Não aplicável':'Consultar percurso financeiro'}</td><td>{r.state}<small>Pagamento: {r.payment}</small></td><td><button onClick={()=>onOpen(r)}>Abrir</button></td></tr>)}</tbody></table></div>{!filtered.length&&<p>Nenhum documento corresponde aos filtros nas fontes disponíveis.</p>}</>}
|
||||
<p className="help">Valores documentais preservados, sem recálculo. Não são somadas moedas diferentes. Ausência de saldo ou vencimento não significa zero, factura paga ou atraso. Filtros de emissão excluem rascunhos e datas desconhecidas.</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function BillingMenuWorkspace({section,context,can,api,navigate}) {
|
||||
const unit=context.unit?.id??null;
|
||||
const [result,setResult]=useState(null),[loading,setLoading]=useState(false),[reload,setReload]=useState(0),[filters,setFilters]=useState(emptyFilters),[selected,setSelected]=useState(null);
|
||||
const active=billingSections.find(s=>s.id===section);
|
||||
useEffect(()=>{setSelected(null);},[section]);
|
||||
useEffect(()=>{
|
||||
let current=true;setResult(null);setSelected(null);
|
||||
if(unit===null||active?.pending)return ()=>{current=false;};
|
||||
setLoading(true);
|
||||
loadBillingRows(api,can,unit).then(r=>{if(current){setResult(r);setLoading(false);}});
|
||||
return ()=>{current=false;};
|
||||
},[context.token,section,reload]);
|
||||
if(!active)return null;
|
||||
const newDraft=()=>navigate('billing_drafts',null,true);
|
||||
return <div className="billing-menu-workspace"><div className="page-title"><p className="eyebrow">FACTURAÇÃO</p><h1>{active.label}</h1><p>Documentos comerciais no contexto de {context.company.name}{context.unit?` / ${context.unit.name}`:''}.</p></div>
|
||||
<nav className="billing-section-tabs" aria-label="Submenus de facturação">{visibleBillingSections(can,unit).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
|
||||
<p className="fiscal-notice"><strong>Demonstração — sem validade fiscal.</strong> A entrada e a consulta não habilitam emissão real. Documento, dívida e recebimento são factos distintos.</p>
|
||||
{!context.unit&&<section className="panel"><h2>Seleccione uma unidade</h2><p>Escolha uma unidade autorizada para consultar os seus documentos.</p></section>}
|
||||
{active.pending?<section className="panel"><h2>Capacidade ainda não disponível</h2><p>{active.pending}</p>{section==='billing_receipts'&&can('service_receivables.view',unit)&&<button onClick={()=>navigate('service_receivables')}>Abrir recebimentos e afectações de teste</button>}{section==='billing_credit_notes'&&can('service_invoices.view',unit)&&<button onClick={()=>navigate('service_invoice_setup')}>Consultar facturas e pedidos de correcção existentes</button>}<p>Este submenu identifica o âmbito previsto; não contém documentos simulados nem executa efeitos financeiros.</p></section>:context.unit&&<>
|
||||
<div className="actions billing-toolbar">{can('billing_drafts.view',unit)&&can('billing_drafts.create',unit)&&<button className="primary" onClick={newDraft}>Novo documento — preparar rascunho</button>}{can('service_invoices.view',unit)&&<button onClick={()=>navigate('service_invoice_setup')}>Séries e emissão de teste</button>}<button disabled={loading} onClick={()=>setReload(r=>r+1)}>Actualizar lista</button></div>
|
||||
<p className="help">A criação reutiliza uma prestação existente e não emite automaticamente. Guardar rascunho, confirmar internamente, emitir em teste e receber mantêm os seus circuitos próprios.</p>
|
||||
{loading&&<p role="status">A carregar documentos autorizados…</p>}
|
||||
{result?.sources.map(s=>s.error?<p className="alert error" role="alert" key={s.label}>{s.label}: consulta indisponível. {s.error} Use Actualizar lista para tentar novamente.</p>:s.notice?<p className="alert home-notice" key={s.label}>{s.label}: {s.notice}</p>:null)}
|
||||
{result&&!loading&&(result.sources.some(s=>!s.error&&!s.notice)?<BillingDocumentList rows={result.rows.filter(r=>section!=='service_invoices'||r.type==='invoice')} filters={filters} onFilter={(k,v)=>setFilters(f=>({...f,[k]:v}))} onClear={()=>setFilters(emptyFilters)} onOpen={r=>r.type==='draft'?navigate('billing_drafts',r.id):setSelected(r)}/>:<p>Não há uma fonte documental disponível para esta consulta. Não é possível concluir que existam zero documentos.</p>)}
|
||||
{selected&&<article className="panel billing-document-detail"><h2>{selected.number}</h2><p>Factura de teste — fotografia preservada, sem edição directa.</p><button onClick={()=>setSelected(null)}>Fechar ficha</button>{selected.detail.document_lines.map(([kind,text],i)=>kind==='title'?<h3 key={i}>{text}</h3>:kind==='section'?<h4 key={i}>{text}</h4>:<p key={i}>{text}</p>)}<a href={`/api/service-invoices/${selected.id}/pdf`}>Descarregar PDF de teste</a><h3>Pedidos de correcção</h3>{selected.detail.corrections.length?selected.detail.corrections.map(c=><p key={c.id}>#{c.id}: {c.reason} · {c.created_at} · Pendente, sem efeito fiscal.</p>):<p>Nenhum pedido registado.</p>}<p className="help">Enviar ao cliente, duplicar documentos, exportar selecções e emitir notas de crédito ainda não estão disponíveis neste menu. Não existe emissão ou rectificação em massa.</p></article>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function PurchasingHome({context,can,navigate}) {
|
||||
const unit=context.unit?.id??null;
|
||||
const budget=unit!==null&&can('purchasing_budget.view',unit);
|
||||
const payables=unit!==null&&can('supplier_payables.view',unit);
|
||||
return <><div className="page-title"><p className="eyebrow">COMPRAS</p><h1>Área de Compras</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>
|
||||
<section className="panel"><h2>Aquisição, recepção e obrigação</h2><p>Compras acompanha as aquisições e os seus documentos. A recepção de mercadoria, o consumo, a obrigação ao fornecedor e o pagamento são factos distintos; cada efeito mantém um único responsável.</p><p>Compras e Stock têm entradas separadas. Separar a navegação não remove os percursos existentes nem concede acesso a operações de outra empresa ou composição.</p></section>
|
||||
<section className="panel"><h2>Percursos disponíveis neste contexto</h2>{!context.unit&&<p>Seleccione uma unidade autorizada para verificar os percursos de trabalho.</p>}<div className="actions">{budget&&<button onClick={()=>navigate('purchasing_budget')}>Controlo orçamental</button>}{payables&&<button onClick={()=>navigate('supplier_payables')}>Obrigações a fornecedores</button>}</div>{!budget&&!payables&&<p>Esta composição ainda não apresenta percursos operacionais de Compras autorizados para a unidade actual. As demonstrações próprias de orçamento e obrigações conservam os seus dados e acessos; não foram eliminadas.</p>}</section>
|
||||
<section className="panel"><h2>Compras autónomo e ERP</h2><p>O módulo autónomo conserva requisições, fornecedores, cotações, adjudicação, ordens e recepções. Esses percursos ainda não estão incorporados integralmente nesta interface SGF. Não se apresenta esta entrada como substituto funcional dessa aplicação.</p><p>Orçamento e obrigações reutilizam o núcleo comum. A entrada neste mapa não permite pagamentos, movimentos de stock ou emissão fiscal.</p></section>
|
||||
</>;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DocumentPreview from './DocumentPreview.jsx';
|
||||
import {visibleBillingSections} from './billing-menu.js';
|
||||
|
||||
export const serviceAreas = { clients: 'Clientes', services: 'Serviços', service_drafts: 'Prestações em rascunho', billing_drafts: 'Rascunhos de facturação' };
|
||||
const actions = { view: 'Consultar', create: 'Criar', edit: 'Editar', manage: 'Gerir concessões' };
|
||||
const isDraft = area => area === 'service_drafts' || area === 'billing_drafts';
|
||||
const newLine = () => ({ service_id: '', quantity: '', unit_price: '', description: '' });
|
||||
|
||||
export default function ServicesWorkspace({ area, context, can, api, perform, busy, initialId, navigate }) {
|
||||
export default function ServicesWorkspace({ area, context, can, api, perform, busy, initialId, initialCreate=false, navigate }) {
|
||||
const unit = isDraft(area) ? context.unit?.id ?? null : null;
|
||||
const allowed = action => can(`${area}.${action}`, unit);
|
||||
const [records, setRecords] = useState([]);
|
||||
@@ -18,11 +19,12 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
|
||||
const [access, setAccess] = useState(false);
|
||||
const [reviewConfirmation, setReviewConfirmation] = useState(false);
|
||||
const [previewMode, setPreviewMode] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const path = `/api/a/${area}`;
|
||||
|
||||
async function load() { const result = await api(path); setRecords(result.records); }
|
||||
async function open(id) { setError(''); setReviewConfirmation(false); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } }
|
||||
useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); }, [area, context.token]);
|
||||
useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); else if(initialCreate&&allowed('create')) start(); }, [area, context.token]);
|
||||
|
||||
async function start(record = null) {
|
||||
setError(''); setAccess(false);
|
||||
@@ -71,11 +73,12 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
|
||||
if (!allowed('view')) return <section className="panel"><h1>{serviceAreas[area]}</h1><p>Sem consulta autorizada neste contexto.</p></section>;
|
||||
if (previewMode && selected) return <DocumentPreview id={selected.id} historical={previewMode === 'history'} context={context} api={api} onClose={() => setPreviewMode(null)} />;
|
||||
return <>
|
||||
{area==='billing_drafts'&&<nav className="billing-section-tabs" aria-label="Submenus de facturação">{visibleBillingSections(can,unit).map(s=><button key={s.id} aria-current={s.id===area?'page':undefined} onClick={()=>{if(s.id!==area)navigate(s.id);}}>{s.label}</button>)}</nav>}
|
||||
<div className="a-heading"><div><p className="eyebrow">SERVIÇOS · PREPARAÇÃO E CONFIRMAÇÃO INTERNA</p><h1>{serviceAreas[area]}</h1><p className="muted">{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}</p></div><div className="actions">{allowed('create') && <button className="primary" disabled={busy} onClick={() => start()}>Novo registo</button>}{allowed('manage') && <button onClick={() => { setAccess(!access); setForm(null); }}>Gerir acessos desta área</button>}</div></div>
|
||||
{error && <div role="alert" className="alert error">{error}</div>}
|
||||
{isDraft(area) && <div className="a-draft-notice">{area === 'billing_drafts' ? 'Rascunhos e confirmação interna — sem validade fiscal. ' : 'Apenas rascunhos. '}Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.</div>}
|
||||
{access ? <AreaAccess area={area} context={context} api={api} perform={perform} busy={busy} /> : <>
|
||||
<div className="a-grid"><section className="panel a-list"><h2>Registos</h2>{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.map(record => <button key={record.id} className={selected?.id === record.id ? 'chosen' : ''} onClick={() => open(record.id)}><strong>{record.name}</strong><small>#{record.id} · Versão {record.version}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
|
||||
<div className="a-grid"><section className="panel a-list"><h2>Registos</h2>{area==='billing_drafts'&&<label>Pesquisar referência, designação ou cliente<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label>}{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.filter(record=>area!=='billing_drafts'||[record.name,record.id,record.data?.client_name].some(v=>String(v??'').toLocaleLowerCase('pt').includes(search.trim().toLocaleLowerCase('pt')))).map(record => <button key={record.id} className={selected?.id === record.id ? 'chosen' : ''} onClick={() => open(record.id)}><strong>{record.name}</strong><small>#{record.id} · Versão {record.version}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
|
||||
{form ? <form onSubmit={save}><h2>{selected ? 'Editar' : 'Criar'} {isDraft(area) ? 'rascunho' : 'cadastro'}</h2><label>{area === 'clients' ? 'Nome do cliente' : area === 'services' ? 'Nome do serviço' : 'Designação do rascunho (opcional)'}<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} maxLength={255} required={!isDraft(area)} /></label>
|
||||
{area !== 'clients' && <label>Descrição (opcional)<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} maxLength={2000} /></label>}
|
||||
{area === 'billing_drafts' && !selected ? <><label>Prestação de origem<select value={form.source_id} onChange={e => setForm({ ...form, source_id: e.target.value })} required><option value="">Seleccionar prestação em rascunho</option>{references.source.map(record => <option key={record.id} value={record.id}>{record.name} · versão {record.version}</option>)}</select></label><p className="help">Cliente, linhas e moeda serão reutilizados da prestação, sem redigitação. Poderá editar o rascunho depois de o preparar.</p></> : isDraft(area) && <>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {stockSections,visibleStockSections,stockOperations,availableStockReports} from './stock-menu.js';
|
||||
|
||||
export default function StockMenuWorkspace({section,context,can,navigate}) {
|
||||
const [reportId,setReportId]=useState('balances');
|
||||
useEffect(()=>{setReportId('balances');},[context.token]);
|
||||
if(!can('company.view'))return <section className="panel"><h1>Stock</h1><p>Seleccione uma empresa autorizada.</p></section>;
|
||||
const active=stockSections.find(s=>s.id===section);
|
||||
if(!active)return null;
|
||||
const operation=stockOperations[section];
|
||||
// This SGF composition has no stock source, feature configuration or cost permission yet.
|
||||
// Do not substitute workshop rights, company rights or purchasing roles for stock rights.
|
||||
const reports=availableStockReports({},false),report=reports.find(r=>r.id===reportId)??reports[0];
|
||||
const tabs=<nav className="stock-tabs" aria-label="Submenus de stock">{visibleStockSections(can).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>;
|
||||
return <div className="stock-menu-workspace"><div className="page-title"><p className="eyebrow">STOCK</p><h1>{active.label}</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>{tabs}
|
||||
<div className="alert home-notice" role="status"><strong>Menu em preparação — fonte de stock ainda não ligada ao SGF.</strong><p>Os saldos e movimentos de Compras permanecem na sua aplicação. Aqui não são apresentados como zero, importados ou alterados. Os apontamentos da oficina não são existências de armazém.</p></div>
|
||||
{section==='stock_panel'&&<>
|
||||
<section className="panel"><h2>Situação actual</h2><p>Não há indicadores de stock disponíveis nesta composição. Não é possível determinar produtos abaixo do mínimo, entradas/saídas, valor do stock ou mercadoria em trânsito.</p><p>O painel sinalizará situações actuais; os relatórios permitirão investigar os movimentos e o período. Nenhum indicador é calculado a partir de exemplos ilustrativos.</p></section>
|
||||
<section className="panel"><h2>Escolher percurso</h2><div className="stock-route-grid">{stockSections.filter(s=>s.id!=='stock_panel').map(s=><button key={s.id} onClick={()=>navigate(s.id)}>{s.label}<span>Consultar o âmbito e o estado de disponibilidade →</span></button>)}</div></section>
|
||||
</>}
|
||||
{section==='stock_balances'&&<>
|
||||
<section className="panel"><h2>Consulta de existências indisponível</h2><p>Falta a fonte autorizada de produtos, armazéns e saldos desta empresa. Não há uma lista vazia de existências nem um saldo editável.</p><dl className="stock-definitions"><div><dt>Físico</dt><dd>Quantidade efectivamente registada no armazém, incluindo reservado e bloqueado.</dd></div><div><dt>Reservado</dt><dd>Quantidade comprometida com documentos próprios, quando o controlo estiver activo.</dd></div><div><dt>Bloqueado</dt><dd>Quantidade indisponível por condição ou qualidade, sem sobreposição com reservas.</dd></div><div><dt>Disponível</dt><dd>Físico menos reservado e bloqueado. Mercadoria em trânsito não está disponível no destino.</dd></div></dl><p>Se uma dessas parcelas não estiver sustentada, o disponível fica indeterminado; não se usa zero como substituto.</p></section>
|
||||
<section className="panel"><h2>Pesquisa e rastreabilidade previstas</h2><p>Produto por código, designação ou código de barras; armazém, categoria e situação de stock. Localização, lote, série e validade só serão apresentados se configurados.</p><p>A ficha dará acesso ao movimento e ao documento original. Diferenças físicas serão tratadas por contagem e ajuste autorizado em Inventário, nunca por edição directa do saldo.</p><button onClick={()=>navigate('stock_reports')}>Ver relatórios de controlo</button></section>
|
||||
</>}
|
||||
{operation&&<>
|
||||
<section className="panel"><h2>{operation.title}</h2><p>{operation.purpose}</p><p className="stock-unavailable">Operação indisponível no SGF. Não há confirmação ou gravação neste ecrã.</p><h3>Campos do percurso previsto</h3><ul>{operation.fields.map(f=><li key={f}>{f}</li>)}</ul><p>Localizações, lotes, séries, validade e câmbio só entram quando o produto e a configuração os exigirem. Não são assumidos por omissão.</p></section>
|
||||
<section className="panel"><h2>Etapas previstas</h2><ol>{operation.flow.map(step=><li key={step}>{step}</li>)}</ol><h3>O que já existe e o que falta</h3><p>{operation.existing}</p><p>{operation.missing}</p><p><strong>Prevenção de duplicações:</strong> {operation.control}</p></section>
|
||||
</>}
|
||||
{section==='stock_reports'&&<>
|
||||
<section className="panel"><h2>Relatórios essenciais</h2><label>Relatório<select value={reportId} onChange={e=>setReportId(e.target.value)}>{reports.map(r=><option key={r.id} value={r.id}>{r.label}</option>)}</select></label><h3>{report.label}</h3><p><strong>Base temporal:</strong> {report.basis}.</p><p><strong>Informação prevista:</strong> {report.fields}</p><p role="status"><strong>Consulta indisponível.</strong> {report.missing}</p><p>Não é possível gerar, imprimir ou exportar um relatório sem a sua fonte. A falta de informação não é apresentada como ausência de movimentos.</p></section>
|
||||
<section className="panel"><h2>Disponibilidade dos restantes relatórios</h2><p>Valorização exige custos, método configurado e permissão própria. Dados de custos não são carregados por este menu. Relatórios de trânsito, devoluções, lotes, inactividade e diferenças de inventário serão apresentados quando os respectivos percursos estiverem activos.</p><p>A consulta e a exportação terão de preservar os mesmos filtros, autorizações, abertura e fecho. Reservas e bloqueios históricos não serão substituídos por valores actuais.</p></section>
|
||||
</>}
|
||||
<section className="panel"><h2>Regras comuns do percurso</h2><p>Um rascunho não altera stock. A confirmação revalida permissões e quantidades, tem um único efeito e conserva actor, data, motivo e origem. Movimentos confirmados não são editados ou apagados; correcções exigem operação própria ligada ao original.</p><p>Acesso a este mapa não concede consulta de armazéns, custos ou movimentos. A integração operacional terá autorizações específicas e contexto explícito, preservando as diferenças entre empresa, unidade e armazém.</p></section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {workshopSections,visibleWorkshopSections,workshopStates,filterWorkshopOrders,visibleWorkshopEquipment} from './workshop-menu.js';
|
||||
|
||||
export default function WorkshopMenuWorkspace({section,context,can,api,navigate}) {
|
||||
const unit=context.unit?.id??null,active=workshopSections.find(s=>s.id===section);
|
||||
const [data,setData]=useState(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[search,setSearch]=useState(''),[state,setState]=useState('');
|
||||
useEffect(()=>{
|
||||
let current=true;setData(null);setError('');
|
||||
if(unit===null||!can('workshop.view',unit)||!['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section))return ()=>{current=false;};
|
||||
api('/api/workshop').then(d=>{if(current)setData(d);}).catch(e=>{if(current)setError(e.message);});
|
||||
return ()=>{current=false;};
|
||||
},[context.token,section,reload]);
|
||||
if(!active)return null;
|
||||
const orders=filterWorkshopOrders(data?.orders??[],search,state);
|
||||
const link=(id,label,permission,scope=null)=>can(permission,scope)?<button key={id} onClick={()=>navigate(id)}>{label}</button>:null;
|
||||
const orderTable=<div className="table-scroll"><table><thead><tr><th>Ordem</th><th>Equipamento</th><th>Cliente</th><th>Estado registado</th><th>Responsável</th><th>Acção</th></tr></thead><tbody>{orders.map(o=><tr key={o.id}><td>{o.reference}<small>#{o.id} · Revisão {o.revision}</small></td><td>{o.equipment?.identifier}<small>{o.equipment?.description}</small></td><td>#{o.client_id}</td><td>{workshopStates[o.state]??o.state}</td><td>{o.assigned_to?`#${o.assigned_to}`:'Por atribuir'}</td><td><button onClick={()=>navigate('workshop',o.id)}>Abrir ordem</button></td></tr>)}</tbody></table>{!orders.length&&<p>Sem ordens visíveis para os filtros seleccionados.</p>}</div>;
|
||||
return <div className="workshop-menu-workspace"><div className="page-title"><p className="eyebrow">OFICINAS</p><h1>{active.label}</h1><p>{context.company.name} / {context.unit?.name??'Seleccione uma unidade'}</p></div>
|
||||
<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,unit).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
|
||||
<p className="alert home-notice">Demonstração sintética. O estado operacional, a autorização de trabalho, a facturação e o pagamento são distintos. Abrir o menu não altera permissões nem confirma uma operação.</p>
|
||||
{active.pending&&<section className="panel"><h2>Capacidade ainda não disponível</h2><p>{active.pending}</p>{['workshop_stock','workshop_quotes'].includes(section)&&<button onClick={()=>navigate('workshop')}>Consultar ordens, autorizações e apontamentos existentes</button>}{section==='workshop_stock'&&can('company.view')&&<button onClick={()=>navigate('stock_panel')}>Abrir o menu de Stock — em preparação</button>}{section==='workshop_purchasing'&&<div className="actions">{link('purchasing_budget','Controlo orçamental disponível','purchasing_budget.view',unit)}{link('supplier_payables','Obrigações a fornecedores disponíveis','supplier_payables.view',unit)}</div>}</section>}
|
||||
{section==='workshop_clients'&&<section className="panel"><h2>Cadastro de clientes existente</h2><p>Reutilize o cadastro da empresa; não há um segundo registo de clientes para a oficina. Conta corrente e histórico de manutenção completo não são acrescentados por este menu.</p>{link('clients','Abrir clientes','clients.view')}</section>}
|
||||
{section==='workshop_finance'&&<section className="panel"><h2>Documentos e percursos financeiros existentes</h2><div className="actions">{link('service_invoices','Facturas e documentos de teste','service_invoices.view',unit)}{link('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit)}{link('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)}{link('service_receivables','Recebimentos e afectações de teste','service_receivables.view',unit)}</div><p>Caixa, despesas, abertura e fecho de caixa ainda não estão implementados. Não se apresentam saldos fictícios, margens ou valores recebidos inferidos a partir de facturas.</p></section>}
|
||||
{section==='workshop_settings'&&<section className="panel"><h2>Configurações existentes</h2><div className="actions">{link('company','Dados da empresa','company.view')}{link('access','Acessos autorizados','access.manage')}{link('services','Catálogo de serviços','services.view')}{link('preview_model','Modelo de apresentação documental','issuer_documents.view')}{link('service_fiscal','Perfis fiscais existentes','service_fiscal.view')}</div><p>Catálogo de tipos de equipamento, características técnicas, política de oficina e gestão de cópias de segurança ainda não têm interface própria. Os perfis do documento são uma proposta; não atribuem direitos automaticamente.</p></section>}
|
||||
{['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section)&&<>
|
||||
<div className="actions"><button onClick={()=>setReload(r=>r+1)}>Actualizar consulta</button>{can('workshop.open',unit)&&<button className="primary" onClick={()=>navigate('workshop')}>Abrir percurso de ordem de serviço</button>}</div>
|
||||
{error?<p role="alert" className="alert error">Dados indisponíveis: {error}. Não é possível concluir que existam zero ordens.</p>:!data?<p role="status">A carregar ordens autorizadas…</p>:data.blocked?<section className="panel"><h2>Ensaio indisponível neste ambiente</h2><p>O percurso existente continua limitado à base sintética autorizada.</p></section>:<>
|
||||
<section className="panel"><h2>{section==='workshop_equipment'?'Pesquisar equipamentos nas ordens autorizadas':'Pesquisar ordens autorizadas'}</h2><div className="form-grid"><label>Referência, equipamento, série, cliente ou responsável<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Estado registado<select value={state} onChange={e=>setState(e.target.value)}><option value="">Todos</option>{Object.entries(workshopStates).map(([s,label])=><option value={s} key={s}>{label}</option>)}</select></label></div>
|
||||
{section==='workshop_equipment'?<><p>Equipamentos identificados nas ordens visíveis. O cadastro independente, tipos e características técnicas ainda não estão implementados.</p><div className="table-scroll"><table><thead><tr><th>Código</th><th>Descrição</th><th>Número de série</th><th>Ordens visíveis</th></tr></thead><tbody>{visibleWorkshopEquipment(orders).map(e=><tr key={e.id}><td>{e.identifier}</td><td>{e.description}</td><td>{e.serial||'Não registado'}</td><td>{e.orders.map(id=><button key={id} onClick={()=>navigate('workshop',id)}>Ordem #{id}</button>)}</td></tr>)}</tbody></table></div>{!orders.length&&<p>Sem equipamentos nas ordens que correspondem aos filtros.</p>}</>:orderTable}</section>
|
||||
<section className="panel"><h2>{section==='workshop_team'?'Responsáveis e horas de serviço':section==='workshop_reports'?'Consulta operacional disponível':'Âmbito do painel'}</h2><p>{section==='workshop_team'?'A atribuição de responsáveis e o registo de horas mantêm-se nas ordens. O cadastro de recursos humanos, horários e disponibilidade não foram implementados.':section==='workshop_reports'?'Esta lista é uma consulta actual das ordens autorizadas. Relatório diário fechado, cortes, exportação e relatórios financeiros ainda não estão implementados.':'As ordens apresentadas respeitam a consulta e a atribuição autorizadas pelo servidor. Entradas, entregas do dia, alertas, agenda e indicadores de caixa ainda não dispõem das fontes necessárias neste percurso.'}</p><p>Os estados actuais de apontamentos não são convertidos nas fases futuras Recebida, Em reparação ou Entregue. Não se inferem margens, stock ou pendências de custódia.</p></section>
|
||||
</>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {visibleWorkshopSections} from './workshop-menu.js';
|
||||
const kindLabels={assignment:'Responsável atribuído',task:'Tarefa',authorization:'Autorização registada',start:'Início autorizado',time:'Tempo relatado',material:'Material observado'};
|
||||
const eventLabels={required:'Requisitado',delivered:'Entregue',consumed:'Aplicado/consumido',returned:'Devolvido'};
|
||||
const decisionLabels={verified:'Verificado',refused:'Recusado',acknowledged:'Ocorrência reconhecida para auditoria — continua fora do âmbito'};
|
||||
const iso=v=>new Date(v).toISOString();
|
||||
export default function WorkshopWorkspace({context,can,api,busy}) {
|
||||
const [data,setData]=useState(null),[orderId,setOrderId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
|
||||
export default function WorkshopWorkspace({context,can,api,busy,initialOrderId=null,navigate}) {
|
||||
const [data,setData]=useState(null),[orderId,setOrderId]=useState(initialOrderId??''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
|
||||
const attempt=useRef(null);
|
||||
const allowed=r=>can(`workshop.${r}`,context.unit?.id??null);
|
||||
const load=async()=>{const d=await api('/api/workshop');setData(d);return d;};
|
||||
@@ -33,6 +34,7 @@ export default function WorkshopWorkspace({context,can,api,busy}) {
|
||||
}
|
||||
const correctionFields=kind=><><label>Versão anterior a corrigir (opcional)<select name="previous_id"><option value="">Novo acontecimento</option>{order.entries.filter(e=>e.kind===kind&&e.current).map(e=><option key={e.id} value={e.id}>#{e.id} · {e.origin} v{e.version}</option>)}</select></label><label>Motivo de correcção (se aplicável)<input name="correction_reason"/></label></>;
|
||||
return <>
|
||||
{navigate&&<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,context.unit?.id??null).map(s=><button key={s.id} aria-current={s.id==='workshop'?'page':undefined} onClick={()=>{if(s.id!=='workshop')navigate(s.id);}}>{s.label}</button>)}</nav>}
|
||||
<section className="panel"><p className="eyebrow">WORKSHOP-RESOURCE-RECORD-v1</p><h1>Oficina — registo de recursos</h1><div className="alert error"><strong>TESTE — SEM UTILIZAÇÃO OPERACIONAL</strong><p>Sem stock, custos, facturação ou recebimentos.</p></div>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar revisão explicitamente</button><p className="help">Abertura não autoriza trabalho. Relatar uma ocorrência passada não lhe dá autorização retroactiva. Alterações concorrentes exigem nova revisão.</p></section>
|
||||
{!data?<p>A carregar…</p>:data.blocked?<section className="panel"><h2>Utilização operacional bloqueada</h2><p>Disponível apenas na instância isolada ligada à base de testes.</p></section>:<>
|
||||
{allowed('open')&&<section className="panel"><h2>Abrir ordem de teste</h2><Form label="Abrir ordem" disabled={disabled} save={v=>send('/orders',{...v,client_id:Number(v.client_id)})}><label>Referência operacional<input name="reference" required/></label><label>Cliente existente<select name="client_id">{data.clients.map(c=><option key={c.id} value={c.id}>#{c.id} · {c.name}</option>)}</select></label><label>Identificação individual do equipamento<input name="equipment_identifier" required/></label><label>Descrição / marca / modelo<input name="equipment_description" required/></label><label>Número de série (se conhecido)<input name="serial"/></label><label>Pedido / avaria<textarea name="request" required/></label><label>Localização<input name="location" required/></label><label>Evidência do pedido<input name="evidence" required/></label></Form><p className="help">Para reutilizar equipamento já identificado, a API aceita o seu ID; não criar outro só por existir uma nova ordem.</p></section>}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function WorkspaceHome({context,can,groups,navigate}) {
|
||||
['catalogue_accounting','catalogue_accounting.approve',null],['service_fiscal','service_fiscal.approve',null],['service_invoices','invoice_series.approve',null],
|
||||
['service_receivables','service_receivables.confirm',unit],['service_receivables','service_receivables.correction_approve',unit],['workshop','workshop.verify',unit],['direct_costs','direct_costs.validate',unit],['industrial_costs','industrial_costs.validate',unit],['industrial_budgets','industrial_budgets.validate',unit],
|
||||
]);
|
||||
function actions(items){return <div className="home-shortcuts">{[...new Map(items.map(i=>[i.id,i])).values()].map(i=><button key={i.id} onClick={()=>navigate(i.id)}>{i.label}<span aria-hidden="true"> →</span></button>)}</div>;}
|
||||
function actions(items){return <div className="home-shortcuts">{[...new Map(items.map(i=>[i.id,i])).values()].map(i=><button key={i.id} onClick={()=>navigate(i.id)}>{i.label}{i.informational&&<small className="shortcut-status">Em preparação</small>}<span aria-hidden="true"> →</span></button>)}</div>;}
|
||||
return <><div className="page-title"><p className="eyebrow">O SEU ESPAÇO DE TRABALHO</p><h1>Início</h1><p className="muted">Escolha o percurso no contexto seleccionado. Cada acção mantém a sua autorização própria.</p></div>
|
||||
{!context.unit&&<p className="alert home-notice" role="status">Seleccione uma unidade para consultar os percursos de trabalho dessa unidade.</p>}
|
||||
<div className="home-work-grid">{prepare.length>0&&<section className="panel"><h2>A preparar e acompanhar</h2><p>Abra o percurso para preparar propostas e acompanhar o estado das versões.</p>{actions(prepare)}</section>}{validate.length>0&&<section className="panel"><h2>A validar</h2><p>Consulte as versões no respectivo percurso antes de tomar uma decisão. Não é permitida a auto-validação.</p>{actions(validate)}</section>}</div>
|
||||
|
||||
@@ -1,26 +1,76 @@
|
||||
import React, {useRef, useState} from 'react';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {visibleBillingSections} from './billing-menu.js';
|
||||
import {visibleWorkshopSections} from './workshop-menu.js';
|
||||
import {visibleStockSections} from './stock-menu.js';
|
||||
import {visibleAccountingSections} from './accounting-menu.js';
|
||||
|
||||
export function navigationGroups(can, unit) {
|
||||
const item=(id,label,permission,scope=null)=>({id,label,visible:can(permission,scope)});
|
||||
return [
|
||||
{id:'start',label:'Início',items:[{id:'home',label:'Página inicial',visible:true}]},
|
||||
{id:'purchasing',label:'Compras',items:[item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]},
|
||||
{id:'purchasing',label:'Compras',items:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]},
|
||||
{id:'stock',label:'Stock',status:'Em preparação',items:visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true}))},
|
||||
{id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]},
|
||||
{id:'billing',label:'Facturação',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit),item('service_invoices','Documentos e séries de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
|
||||
{id:'billing',label:'Facturação',items:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))},
|
||||
{id:'billing_support',label:'Configuração documental',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('service_invoice_setup','Séries e emissão de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
|
||||
{id:'receivables',label:'Recebimentos',items:[item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit)]},
|
||||
{id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento','workshop_corrections.view',unit)]},
|
||||
{id:'workshop',label:'Oficinas',items:[...visibleWorkshopSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true})),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento comercial','workshop_corrections.view',unit)]},
|
||||
{id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]},
|
||||
{id:'accounting',label:'Contabilidade e fiscalidade',items:[item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
|
||||
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
|
||||
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('access','Acessos','access.manage')]},
|
||||
].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length);
|
||||
}
|
||||
|
||||
export default function WorkspaceNavigation({groups,page,navigate,user,busy,logout}) {
|
||||
const [expanded,setExpanded]=useState({});const drawer=useRef(null);
|
||||
function choose(id){navigate(id);drawer.current?.close();}
|
||||
function contents(prefix){return <><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav aria-label="Navegação principal">{groups.map(g=>{
|
||||
const active=g.items.some(i=>i.id===page),open=active||!!expanded[g.id];
|
||||
return <section className="navigation-group" key={g.id}><button className="navigation-heading" aria-expanded={open} aria-controls={`${prefix}-${g.id}`} onClick={()=>setExpanded(s=>({...s,[g.id]:!open}))}><span>{g.label}</span><span aria-hidden="true">{open?'−':'+'}</span></button><div id={`${prefix}-${g.id}`} hidden={!open}>{g.items.map(i=><button key={i.id} className={`navigation-item ${page===i.id?'active':''}`} aria-current={page===i.id?'page':undefined} onClick={()=>choose(i.id)}>{i.label}</button>)}</div></section>;
|
||||
})}</nav><div className="sidebar-bottom"><strong>{user.name}</strong><small>{user.email}</small><button disabled={busy} onClick={logout}>Sair da aplicação</button></div></>;}
|
||||
return <><button className="mobile-menu-toggle" onClick={()=>drawer.current.showModal()} aria-haspopup="dialog">Menu</button><aside className="sidebar grouped-sidebar">{contents('desktop-nav')}</aside><dialog className="navigation-drawer" ref={drawer} aria-label="Menu de navegação"><button className="drawer-close" onClick={()=>drawer.current.close()} autoFocus>Fechar menu</button><div className="sidebar">{contents('mobile-nav')}</div></dialog></>;
|
||||
export function navigationTopics(groups) {
|
||||
const layout=[
|
||||
{id:'start',label:'Início',groups:['start']},
|
||||
{id:'commercial',label:'Comercial',groups:['services','billing']},
|
||||
{id:'purchasing',label:'Compras',groups:['purchasing']},
|
||||
{id:'stock',label:'Stock',groups:['stock']},
|
||||
{id:'workshop',label:'Oficinas',groups:['workshop']},
|
||||
{id:'finance',label:'Financeiro',groups:['receivables','accounting','costs']},
|
||||
{id:'settings',label:'Configuração',groups:['billing_support','admin']},
|
||||
];
|
||||
const known=new Set(layout.flatMap(t=>t.groups));
|
||||
return [...layout.map(t=>({...t,sections:t.groups.map(id=>groups.find(g=>g.id===id)).filter(Boolean)})),
|
||||
...groups.filter(g=>!known.has(g.id)).map(g=>({id:g.id,label:g.label,sections:[g]}))]
|
||||
.filter(t=>t.sections.some(g=>g.items.length));
|
||||
}
|
||||
|
||||
export default function WorkspaceNavigation({groups,page,navigate,user,busy,logout}) {
|
||||
const [open,setOpen]=useState(null);const root=useRef(null);const triggers=useRef({});
|
||||
const topics=navigationTopics(groups);
|
||||
useEffect(()=>{
|
||||
const outside=e=>{if(root.current&&!root.current.contains(e.target))setOpen(null);};
|
||||
const escape=e=>{if(e.key==='Escape'&&open){e.preventDefault();setOpen(null);triggers.current[open]?.focus();}};
|
||||
document.addEventListener('pointerdown',outside);document.addEventListener('keydown',escape);
|
||||
return ()=>{document.removeEventListener('pointerdown',outside);document.removeEventListener('keydown',escape);};
|
||||
},[open]);
|
||||
useEffect(()=>{setOpen(null);},[page,JSON.stringify(groups)]);
|
||||
function choose(id){navigate(id);setOpen(null);}
|
||||
function focusItem(e,topic){
|
||||
if(!['ArrowDown','ArrowUp','Home','End'].includes(e.key))return;
|
||||
const items=[...root.current.querySelectorAll(`#topic-${topic} .topic-item`)];
|
||||
if(!items.length)return;e.preventDefault();const index=items.indexOf(e.target);
|
||||
const next=e.key==='Home'?0:e.key==='End'?items.length-1:e.key==='ArrowDown'?(index+1)%items.length:(index-1+items.length)%items.length;
|
||||
items[next].focus();
|
||||
}
|
||||
function openWithKeyboard(e,id){
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'){
|
||||
e.preventDefault();setOpen(id);
|
||||
requestAnimationFrame(()=>{const items=root.current?.querySelectorAll(`#topic-${id} .topic-item`);(e.key==='ArrowUp'?items?.[items.length-1]:items?.[0])?.focus();});
|
||||
}
|
||||
}
|
||||
return <div className="workspace-topbar" ref={root} onBlur={e=>{if(e.relatedTarget&&!e.currentTarget.contains(e.relatedTarget))setOpen(null);}}>
|
||||
<a className="topbar-brand" href="/">TEMPO <span>SGF</span></a>
|
||||
<nav className="topic-navigation" aria-label="Navegação principal por assunto">{topics.map((t,index)=>{
|
||||
const active=t.sections.some(g=>g.items.some(i=>i.id===page));
|
||||
if(t.id==='start')return <button className={`topic-trigger ${active?'topic-active':''}`} aria-current={active?'page':undefined} key={t.id} onClick={()=>choose('home')}>Início</button>;
|
||||
return <div className={`navigation-topic ${index>=4?'topic-align-right':''}`} key={t.id}>
|
||||
<button className={`topic-trigger ${active?'topic-active':''}`} ref={el=>{triggers.current[t.id]=el;}} aria-expanded={open===t.id} aria-controls={`topic-${t.id}`} onClick={()=>setOpen(open===t.id?null:t.id)} onKeyDown={e=>openWithKeyboard(e,t.id)}>{t.label}<span aria-hidden="true"> ▾</span>{active&&<span className="sr-only"> — área actual</span>}</button>
|
||||
<div id={`topic-${t.id}`} className="topic-dropdown" hidden={open!==t.id} onKeyDown={e=>focusItem(e,t.id)}><div className="topic-sections">{t.sections.map(g=><section key={g.id}><h2>{g.label}{g.status&&<small>{g.status}</small>}</h2>{g.items.map(i=><button className={`topic-item ${page===i.id?'topic-item-active':''}`} aria-current={page===i.id?'page':undefined} key={i.id} onClick={()=>choose(i.id)}>{i.label}{i.informational&&<small>Em preparação</small>}</button>)}</section>)}</div></div>
|
||||
</div>;
|
||||
})}</nav>
|
||||
<div className="topbar-account"><div><strong>{user.name}</strong><small>{user.email}</small></div><button disabled={busy} onClick={logout}>Sair</button></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export const accountingSections=[
|
||||
{id:'accounting_panel',label:'Painel contabilístico'},
|
||||
{id:'accounting_daily',label:'Trabalho diário'},
|
||||
{id:'accounting_review',label:'Conferência'},
|
||||
{id:'accounting_analysis',label:'Análise'},
|
||||
{id:'accounting_closing',label:'Fecho'},
|
||||
{id:'accounting_configuration',label:'Configuração'},
|
||||
];
|
||||
|
||||
export function visibleAccountingSections(can) {
|
||||
return can('company.view')||can('catalogue_accounting.view')||can('service_fiscal.view')?accountingSections:[];
|
||||
}
|
||||
|
||||
export const accountingFunctions=[
|
||||
{id:'accounts',section:'accounting_configuration',label:'Plano de contas',description:'Códigos, natureza, hierarquia e estado das contas; versões e aprovação das alterações estruturais.',status:'Existe classificação contabilística com propostas de plano/contas e validação por versão. Não equivale a um razão ou a um plano integral com todos os controlos operacionais.',target:'catalogue_accounting',permission:'catalogue_accounting.view',action:'Abrir plano e classificação existentes'},
|
||||
{id:'journals',section:'accounting_configuration',label:'Diários',description:'Vendas, compras, bancos, caixa e operações diversas; configuração separada da utilização.',status:'Diários contabilísticos, numeração e contas predefinidas ainda não estão implementados. Séries de facturas não são diários contabilísticos.'},
|
||||
{id:'entries',section:'accounting_daily',label:'Lançamentos',description:'Data, diário, referência, conta, descrição, débito, crédito, moeda, entidade e dimensão analítica.',status:'Criação, importação, conferência, validação e estorno de lançamentos ainda não estão implementados. Capturar um documento ou uma classificação não gera um lançamento.'},
|
||||
{id:'recurrences',section:'accounting_daily',label:'Modelos e recorrências',description:'Despesas periódicas, acréscimos e diferimentos com frequência, vigência e responsável.',status:'Não existem modelos nem geração recorrente de lançamentos. A evolução prevista gera rascunhos para revisão, sem publicação automática.'},
|
||||
{id:'reconciliation',section:'accounting_review',label:'Conferência e reconciliação',description:'Conferir clientes, fornecedores, bancos e impostos, mantendo correspondências parciais, diferenças e justificações.',status:'A reconciliação contabilística ainda não está implementada. O percurso de recebimentos simulados existente é separado do razão e de uma ligação bancária real.',target:'service_receivables',permission:'service_receivables.view',unit:true,action:'Abrir recebimentos e reconciliação de teste'},
|
||||
{id:'balances',section:'accounting_analysis',label:'Balancetes e extractos',description:'Saldos iniciais, débitos, créditos, saldo final e ligação ao lançamento e ao comprovativo.',status:'Não existe razão contabilístico para produzir balancetes ou extractos. Ausência de fonte não significa saldo zero.'},
|
||||
{id:'analytics',section:'accounting_analysis',label:'Contabilidade analítica',description:'Centros de custo, projectos, departamentos e repartições, com movimentos por classificar visíveis.',status:'A contabilidade analítica por lançamentos ainda não existe. Custos e orçamento industrial de teste mantêm o seu âmbito próprio e não são um razão analítico.',target:'industrial_budgets',permission:'industrial_budgets.view',unit:true,action:'Consultar orçamento industrial de teste — âmbito distinto'},
|
||||
{id:'closing',section:'accounting_closing',label:'Operações de fecho',description:'Acréscimos, diferimentos, depreciações, câmbio e resultados, com responsável, evidência e aprovação.',status:'Não existe fecho contabilístico executável. Este menu não gera ajustamentos, depreciações ou apuramentos.'},
|
||||
{id:'periods',section:'accounting_closing',label:'Períodos e bloqueios',description:'Abertura, fecho mensal/anual, datas de bloqueio e reabertura autorizada, com motivo e histórico.',status:'Os bloqueios e a reabertura de períodos contabilísticos ainda não estão implementados. Os bloqueios actuais de emissão fiscal são um controlo diferente.'},
|
||||
{id:'statements',section:'accounting_analysis',label:'Demonstrações financeiras',description:'Balanço, resultados e fluxos de caixa com comparativos e ligação às contas de origem.',status:'Não há demonstrações financeiras geradas. São necessários lançamentos validados e modelos adequados ao referencial aplicável; não se apresentam estimativas como saldos reais.'},
|
||||
{id:'audit',section:'accounting_review',label:'Auditoria e arquivo',description:'Histórico de criação, alteração, validação, estorno e reabertura; consulta de evidência dentro do âmbito autorizado.',status:'Os percursos existentes conservam o seu histórico próprio. Ainda não há um arquivo contabilístico ou uma consulta de auditoria integral agregada.',target:'catalogue_accounting',permission:'catalogue_accounting.view',action:'Consultar classificação e histórico existentes'},
|
||||
];
|
||||
|
||||
export const accountingReports=[
|
||||
['trial_balance','Balancete geral','Contabilidade','Saldos iniciais, débitos, créditos e saldos finais por conta e período.'],
|
||||
['ledger','Razão e extractos de contas','Contabilidade','Movimentos, contrapartidas, comprovativos e saldo progressivo.'],
|
||||
['journal','Diário de lançamentos','Contabilidade','Data, diário, referência e estado, separando rascunhos de lançamentos validados.'],
|
||||
['balance_sheet','Balanço','Demonstrações','Activo, passivo e capital próprio à data de referência.'],
|
||||
['income','Demonstração de resultados','Demonstrações','Rendimentos, gastos e resultados do período. Lucro não equivale a dinheiro disponível.'],
|
||||
['cash_flow','Demonstração de fluxos de caixa','Demonstrações','Fluxos operacionais, de investimento e de financiamento, conferidos com caixa e equivalentes.'],
|
||||
['equity','Alterações no capital próprio','Demonstrações','Movimentos nas rubricas e reconciliação dos saldos, quando aplicável.'],
|
||||
['notes','Anexo às demonstrações financeiras','Demonstrações','Notas, políticas contabilísticas e suporte das rubricas relevantes.'],
|
||||
['customers','Clientes e antiguidade de saldos','Tesouraria','Documento, saldo e vencimento sustentado. Vencimento desconhecido não é atraso zero.'],
|
||||
['suppliers','Fornecedores e antiguidade de saldos','Tesouraria','Obrigações, vencimentos, moeda e aprovação, sem confundir dívida com pagamento.'],
|
||||
['cash_bank','Posição de caixa e bancos','Tesouraria','Saldos contabilísticos e de extracto separados, por conta e moeda.'],
|
||||
['bank_reconciliation','Reconciliação bancária','Tesouraria','Correspondências, pendentes e diferenças, com revisão independente.'],
|
||||
['forecast','Previsão de tesouraria','Tesouraria','Recebimentos e pagamentos esperados, separados de estimativas e execução efectiva.'],
|
||||
['payments','Pagamentos e aprovações','Tesouraria','Proposto, aprovado, executado, rejeitado e devolvido são estados distintos.'],
|
||||
['vat','Apuramento de IVA','Fiscalidade','Operações e imposto reconciliados com contas e declarações aplicáveis. Não é activado por este menu.'],
|
||||
['withholding','Retenções e obrigações fiscais','Fiscalidade','Bases, retenções e obrigações aplicáveis, sem presumir taxas ou incidências.'],
|
||||
['payroll','Salários e contribuições','Pessoal','Resumo autorizado e obrigações aplicáveis; dados individuais permanecem restritos.'],
|
||||
['assets','Activos e depreciações','Património','Cadastro, aquisições, alienações, depreciações e valor líquido.'],
|
||||
['cost_centres','Centros de custo e projectos','Análise','Rendimentos e gastos por dimensão; movimentos sem classificação identificados.'],
|
||||
['budget','Orçamento versus realizado','Análise','Desvios por período e dimensão, com base em valores validados.'],
|
||||
['inventory','Inventário e valorização','Análise','Reconciliação do inventário com a contabilidade, quando aplicável.'],
|
||||
['fx','Operações em moeda estrangeira','Contabilidade','Moeda, taxa, data de referência e diferenças cambiais.'],
|
||||
['closing','Fecho e pendências','Fecho','Conferências, ajustamentos, documentos, bloqueios e aprovações do período.'],
|
||||
['audit','Auditoria de alterações','Fecho','Actor, data, motivo e evidência, com consulta e exportação restritas.'],
|
||||
].map(([id,label,family,description])=>({id,label,family,description}));
|
||||
|
||||
export function filterAccountingReports(search='',family='') {
|
||||
const normalise=s=>String(s).normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLocaleLowerCase('pt');
|
||||
const query=normalise(search).trim();
|
||||
return accountingReports.filter(r=>(!family||r.family===family)&&(!query||normalise(r.label+' '+r.description).includes(query)));
|
||||
}
|
||||
+18
-4
@@ -18,6 +18,15 @@ import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx';
|
||||
import WorkspaceHome from './WorkspaceHome.jsx';
|
||||
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
|
||||
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx';
|
||||
import BillingMenuWorkspace from './BillingMenuWorkspace.jsx';
|
||||
import {billingSections} from './billing-menu.js';
|
||||
import WorkshopMenuWorkspace from './WorkshopMenuWorkspace.jsx';
|
||||
import {workshopSections} from './workshop-menu.js';
|
||||
import StockMenuWorkspace from './StockMenuWorkspace.jsx';
|
||||
import {stockSections} from './stock-menu.js';
|
||||
import AccountingMenuWorkspace from './AccountingMenuWorkspace.jsx';
|
||||
import {accountingSections} from './accounting-menu.js';
|
||||
import PurchasingHome from './PurchasingHome.jsx';
|
||||
|
||||
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
|
||||
|
||||
@@ -32,7 +41,7 @@ function App() {
|
||||
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
|
||||
|
||||
const groups = navigationGroups(can, context?.unit?.id ?? null);
|
||||
const navigate = id => { setPage(id); setError(''); };
|
||||
const navigate = (id,selection=null) => { setASelection(selection); setPage(id); setError(''); };
|
||||
useEffect(() => { if (!groups.some(g => g.items.some(i => i.id === page))) setPage('home'); }, [context?.token, JSON.stringify(context?.grants), page]);
|
||||
|
||||
async function api(path, method = 'GET', body, token = context?.token, commandKey = null) {
|
||||
@@ -75,17 +84,22 @@ function App() {
|
||||
{page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
|
||||
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
|
||||
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
|
||||
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
|
||||
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>}
|
||||
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
|
||||
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
|
||||
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} initialOrderId={aSelection?.area==='workshop'?aSelection.id:null} navigate={id=>navigate(id)} />}
|
||||
{workshopSections.some(s=>s.id===page)&&page!=='workshop'&&<WorkshopMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null)=>navigate(area,{area,id})}/>}
|
||||
{page === 'direct_costs' && <DirectCostsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'industrial_budgets' && <IndustrialBudgetsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'purchasing_budget' && <PurchasingBudgetWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'purchasing_home' && <PurchasingHome context={context} can={can} navigate={navigate}/>}
|
||||
{page === 'supplier_payables' && <section className="panel"><h1>Obrigações a fornecedores</h1><p>Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.</p><a href="/supplier-payables">Abrir preparação e consulta de obrigações</a></section>}
|
||||
{page === 'industrial_costs' && <IndustrialCostsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'service_invoice_setup' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Navigation and read models only. Financial values are copied from preserved sources.
|
||||
export const billingSections = [
|
||||
{id:'billing_all',label:'Todos os documentos'},
|
||||
{id:'service_invoices',label:'Facturas'},
|
||||
{id:'billing_invoice_receipts',label:'Facturas-recibo',pending:'A emissão de facturas-recibo ainda não está implementada. Uma factura e um recebimento não serão reunidos automaticamente neste tipo de documento.'},
|
||||
{id:'billing_receipts',label:'Recibos',pending:'O documento fiscal Recibo ainda não está implementado. O percurso existente de recebimentos e afectações de teste conserva a sua identidade própria.'},
|
||||
{id:'billing_credit_notes',label:'Notas de crédito',pending:'A emissão de notas de crédito ainda não está implementada. Pedidos de correcção existentes não são notas de crédito e não alteram automaticamente a dívida, o stock ou o dinheiro.'},
|
||||
{id:'billing_quotes',label:'Orçamentos',pending:'A proposta comercial ao cliente e a sua conversão em rascunho ainda não estão implementadas. Este orçamento comercial é distinto do controlo orçamental de Compras e do orçamento de custos industriais.'},
|
||||
{id:'billing_drafts',label:'Rascunhos'},
|
||||
];
|
||||
|
||||
export function billingAccess(can,unit) {
|
||||
return (unit!==null && (can('service_invoices.view',unit)||can('billing_drafts.view',unit)||can('service_receivables.view',unit)))
|
||||
|| can('issuer_documents.view')||can('client_documents.view')||(unit!==null&&can('draft_documents.view',unit));
|
||||
}
|
||||
|
||||
export function visibleBillingSections(can,unit) {
|
||||
if(!billingAccess(can,unit))return [];
|
||||
return billingSections.filter(s=>s.id==='service_invoices'?unit!==null&&can('service_invoices.view',unit)
|
||||
:s.id==='billing_drafts'?unit!==null&&can('billing_drafts.view',unit):true);
|
||||
}
|
||||
|
||||
export function invoiceRow(record,detail) {
|
||||
const snapshot=detail.snapshot,doc=snapshot.document;
|
||||
return {key:`invoice:${record.id}`,id:record.id,type:'invoice',typeLabel:'Factura de teste',number:`${record.series_code} / ${record.number}`,
|
||||
client:doc.client?.name??null,nuit:doc.client?.data?.nuit??null,issuedDate:(snapshot.issued_local??record.issued_at)?.slice(0,10)??null,
|
||||
dueDate:null,total:snapshot.calculation?.total_document_proposed??null,currency:doc.data?.currency??null,
|
||||
series:record.series_code,author:null,state:'Emitido apenas em teste',balance:null,payment:'Indeterminado',detail};
|
||||
}
|
||||
|
||||
export function draftRow(record) {
|
||||
return {key:`draft:${record.id}`,id:record.id,type:'draft',typeLabel:'Rascunho',number:`Interno #${record.id}`,name:record.name,
|
||||
client:record.data?.client_name??null,nuit:null,issuedDate:null,dueDate:null,total:null,base:record.base_amount??null,currency:record.data?.currency??null,
|
||||
series:null,author:record.actor_id??null,state:record.workshop_commercial_state==='superseded'?'Histórico / substituído':record.workshop_commercial_state==='cancelled'?'Cancelado':record.internal_confirmation?'Confirmado internamente':'Rascunho',
|
||||
balance:null,payment:'Não aplicável',version:record.version,updatedAt:record.updated_at};
|
||||
}
|
||||
|
||||
export function filterBillingRows(rows,filters) {
|
||||
const normalise=v=>String(v??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLocaleLowerCase('pt');
|
||||
const query=normalise(filters.search).trim();
|
||||
return rows.filter(r=>(!query||[r.number,r.name,r.client,r.nuit].some(v=>normalise(v).includes(query)))
|
||||
&&(!filters.type||r.type===filters.type)&&(!filters.currency||r.currency===filters.currency)
|
||||
&&(!filters.series||r.series===filters.series)&&(!filters.state||r.state===filters.state)
|
||||
&&(!filters.author||String(r.author??'')===filters.author)
|
||||
&&(!filters.from||(r.issuedDate&&r.issuedDate>=filters.from))&&(!filters.to||(r.issuedDate&&r.issuedDate<=filters.to)));
|
||||
}
|
||||
|
||||
export async function loadBillingRows(api,can,unit) {
|
||||
const jobs=[];
|
||||
if(unit!==null&&can('service_invoices.view',unit))jobs.push(['Facturas',async()=>{
|
||||
const list=await api('/api/service-invoices');
|
||||
if(!list.test_enabled)return {rows:[],notice:list.notice??'Documentos de teste indisponíveis neste ambiente.'};
|
||||
const rows=[];
|
||||
// Bound simultaneous requests while reusing the existing scoped immutable document endpoint.
|
||||
for(let start=0;start<list.invoices.length;start+=6){
|
||||
rows.push(...await Promise.all(list.invoices.slice(start,start+6).map(async r=>invoiceRow(r,await api(`/api/service-invoices/${r.id}`)))));
|
||||
}
|
||||
return {rows};
|
||||
}]);
|
||||
if(unit!==null&&can('billing_drafts.view',unit))jobs.push(['Rascunhos',async()=>({rows:(await api('/api/a/billing_drafts')).records.map(draftRow)})]);
|
||||
const sources=await Promise.all(jobs.map(async([label,job])=>{
|
||||
try{return {label,...await job()};}catch(e){return {label,error:e.message,rows:[]};}
|
||||
}));
|
||||
return {rows:sources.flatMap(s=>s.rows),sources};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Informational navigation until the existing stock implementation has a common adapter.
|
||||
// No quantities, balances, permissions or stock commands are fabricated here.
|
||||
export const stockSections=[
|
||||
{id:'stock_panel',label:'Painel'},
|
||||
{id:'stock_balances',label:'Existências'},
|
||||
{id:'stock_receipts',label:'Entradas'},
|
||||
{id:'stock_issues',label:'Saídas'},
|
||||
{id:'stock_transfers',label:'Transferências'},
|
||||
{id:'stock_returns',label:'Devoluções'},
|
||||
{id:'stock_reports',label:'Relatórios'},
|
||||
];
|
||||
|
||||
export function visibleStockSections(can) {
|
||||
// company.view exposes this informational menu only, never warehouse data or actions.
|
||||
return can('company.view')?stockSections:[];
|
||||
}
|
||||
|
||||
export const stockOperations={
|
||||
stock_receipts:{title:'Recepção de mercadoria',purpose:'Registar mercadoria efectivamente recebida. Um rascunho não aumenta o stock.',
|
||||
fields:['Data efectiva e data de registo','Origem, motivo e documento original','Fornecedor, quando se trata de uma compra','Produto, quantidade e unidade','Armazém de destino','Responsável e evidência de recepção'],
|
||||
flow:['Preparar a partir da origem ou de uma entrada justificada','Conferir quantidades, destino e informação obrigatória','Submeter para aprovação, quando o circuito o exigir','Confirmar uma única vez e conservar o comprovativo'],
|
||||
existing:'Compras já tem recepções parciais de ordens e entradas de quantidades aceites. Esse percurso permanece na aplicação autónoma, sem ser duplicado neste menu.',
|
||||
missing:'Falta disponibilizar o adaptador de stock no SGF. Entradas manuais, saldo inicial e quarentena do documento não estão entregues neste percurso.',
|
||||
control:'Devoluções de clientes e recepções de transferências usam as suas origens próprias. Uma factura de fornecedor não gera outra entrada da mercadoria já recebida.'},
|
||||
stock_issues:{title:'Expedição, consumo e abate',purpose:'Registar uma saída física, distinguindo venda, consumo interno e abate.',
|
||||
fields:['Data efectiva e data de registo','Tipo, motivo e destinatário','Documento ou autorização de origem','Produto, quantidade e unidade','Armazém de origem','Responsável e comprovativo'],
|
||||
flow:['Identificar origem, destinatário e motivo','Consultar disponibilidade na origem','Submeter a autorização aplicável','Revalidar o saldo e confirmar a saída uma única vez'],
|
||||
existing:'Compras já tem saídas de stock com destino, motivo, responsável e prevenção de saldo negativo. Os apontamentos materiais da oficina não executam essas saídas.',
|
||||
missing:'Falta ligar o percurso ao SGF. Consumo de reservas próprias, bloqueios, saídas por venda e autorização de abates exigem os respectivos fluxos.',
|
||||
control:'Não descontar duas vezes uma reserva. Uma factura ou um apontamento de consumo não deve criar uma segunda saída para o mesmo facto.'},
|
||||
stock_transfers:{title:'Transferência entre armazéns',purpose:'Mover mercadoria entre origem e destino sem representar uma venda.',
|
||||
fields:['Armazéns de origem e destino distintos','Produto, unidade e quantidades pedida, expedida e recebida','Datas efectivas de expedição e recepção','Motivo e documento de acompanhamento','Divergências e responsáveis'],
|
||||
flow:['Preparar e autorizar o pedido','Expedir da origem','Receber e conferir no destino','Resolver diferenças e concluir'],
|
||||
existing:'Compras já tem transferência imediata entre armazéns, com dois movimentos na mesma transacção. Não representa o transporte em duas fases proposto no documento.',
|
||||
missing:'Faltam o adaptador SGF e o fluxo separado de expedição, trânsito, recepção parcial e divergências. Não se inventa stock em trânsito a partir da transferência imediata.',
|
||||
control:'Mercadoria em trânsito não fica disponível no destino antes da recepção. Uma transferência não aumenta a quantidade total da empresa.'},
|
||||
stock_returns:{title:'Devoluções de mercadoria',purpose:'Distinguir devolução de cliente, que recebe mercadoria, de devolução a fornecedor, que a expede.',
|
||||
fields:['Tipo de devolução e entidade','Documento original e linhas elegíveis','Quantidade, unidade, motivo e condição','Armazém e tratamento da mercadoria','Datas efectivas, responsáveis e comprovativos','Referência financeira, quando existir'],
|
||||
flow:['Seleccionar a origem e a quantidade ainda elegível','Registar motivo e condição','Obter autorização','Receber ou expedir e conservar a ligação ao original'],
|
||||
existing:'As devoluções documentais e físicas deste modelo ainda não estão implementadas como percurso operacional no SGF. Um material marcado como devolvido na oficina é uma observação, não este movimento.',
|
||||
missing:'Faltam elegibilidade acumulada, reservas de pedidos activos, inspecção, quarentena, expedição e estornos próprios. Não são substituídos por ajustes genéricos.',
|
||||
control:'Devolução física, nota de crédito e devolução de dinheiro têm efeitos separados. A nota de crédito não pode gerar outro movimento físico do mesmo facto.'},
|
||||
};
|
||||
|
||||
export const stockReports=[
|
||||
{id:'balances',label:'Existências por armazém',basis:'Situação à data de referência',fields:'Produto, unidade, armazém, localização quando configurada, físico, reservado, bloqueado e disponível.',missing:'Sem fonte de saldos SGF, a consulta é indisponível. Reservado e bloqueado não são substituídos por zero.'},
|
||||
{id:'movements',label:'Movimentos de stock',basis:'Período entre duas datas',fields:'Data, documento, origem/destino, produto, unidade, entrada, saída e abertura/fecho com os mesmos filtros.',missing:'Falta ligar o livro de movimentos e as suas origens. Não se usam os apontamentos da oficina como movimentos de armazém.'},
|
||||
{id:'minimum',label:'Stock mínimo',basis:'Data de referência e mínimos configurados',fields:'Produto, armazém, disponível, mínimo e diferença até ao mínimo.',missing:'Mínimos e fonte de disponível ainda não estão configurados no SGF. Não se geram alertas ou propostas de compra automáticos.'},
|
||||
{id:'valuation',label:'Valorização do stock',basis:'Data de referência, método e moeda',fields:'Consulta restrita a utilizadores com autorização própria para custos.',restricted:true,missing:'Requer método, custos fiáveis e autorização de consulta. Não se estima valorização a partir de preços de venda ou dos custos incompletos da oficina.'},
|
||||
{id:'inactive',label:'Produtos sem movimento',feature:'history',basis:'Período de inactividade escolhido',fields:'Produto, último movimento elegível e dias sem movimento.',missing:'Requer histórico suficiente e definição das operações elegíveis.'},
|
||||
{id:'transit',label:'Transferências em trânsito',feature:'transit',basis:'Data de referência',fields:'Expedido, recebido e pendente por produto e unidade.',missing:'Requer o fluxo de expedição e recepção separado.'},
|
||||
{id:'returns',label:'Devoluções',feature:'returns',basis:'Período, tipo e estado',fields:'Origem, entidade, quantidade e tratamento.',missing:'Requer o fluxo próprio de devoluções.'},
|
||||
{id:'expiry',label:'Lotes próximos da validade',feature:'lots',basis:'Data de referência e janela configurada',fields:'Produto, lote, validade e quantidades elegíveis.',missing:'Requer controlo de lotes/validade activo.'},
|
||||
{id:'inventory',label:'Diferenças de inventário',feature:'inventory',basis:'Contagem e período',fields:'Saldo à contagem, quantidade contada e ajuste associado.',missing:'Requer contagem física e ajuste autorizado integrados.'},
|
||||
];
|
||||
|
||||
export function availableStockReports(features={},canViewCosts=false) {
|
||||
return stockReports.filter(r=>(!r.feature||features[r.feature]===true)&&(!r.restricted||canViewCosts));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const workshopSections=[
|
||||
{id:'workshop_panel',label:'Painel da oficina'},
|
||||
{id:'workshop_clients',label:'Clientes'},
|
||||
{id:'workshop_equipment',label:'Equipamentos'},
|
||||
{id:'workshop_schedule',label:'Agendamentos',pending:'Calendário, disponibilidade e lembretes ainda não estão implementados. Não são criadas marcações ou enviados lembretes por este menu.'},
|
||||
{id:'workshop_quotes',label:'Orçamentos',pending:'O orçamento comercial da reparação e o registo da aprovação do cliente ainda não estão implementados como documento próprio. As autorizações de trabalho existentes conservam o seu âmbito e evidência; não são convertidas em orçamentos.'},
|
||||
{id:'workshop',label:'Ordens de serviço'},
|
||||
{id:'workshop_stock',label:'Peças e stock',pending:'O stock de oficina, reservas, mínimos e inventário ainda não estão implementados neste módulo. Os apontamentos de material existentes são observações de ocorrências, não saldos nem movimentos de armazém.'},
|
||||
{id:'workshop_purchasing',label:'Compras e fornecedores',pending:'O percurso completo de Compras ainda não está incorporado nesta composição da oficina. As demonstrações de obrigações e orçamento mantêm os seus contextos próprios; nenhuma compra é criada por esta página.'},
|
||||
{id:'workshop_finance',label:'Facturação e caixa'},
|
||||
{id:'workshop_team',label:'Equipa'},
|
||||
{id:'workshop_reports',label:'Relatórios'},
|
||||
{id:'workshop_settings',label:'Configurações'},
|
||||
];
|
||||
|
||||
export function visibleWorkshopSections(can,unit) {
|
||||
if(unit===null||!can('workshop.view',unit))return [];
|
||||
return workshopSections.filter(s=>s.id!=='workshop_clients'||can('clients.view')).filter(s=>s.id!=='workshop_finance'
|
||||
||can('service_invoices.view',unit)||can('billing_drafts.view',unit)||can('service_receivables.view',unit)||can('workshop_billing.view',unit));
|
||||
}
|
||||
|
||||
export const workshopStates={open:'Aberta',in_review:'Apontamentos em revisão',exception_review:'Ocorrências fora do âmbito para revisão',records_verified:'Registos verificados — sem conclusão comercial'};
|
||||
|
||||
export function filterWorkshopOrders(orders,search='',state='') {
|
||||
const normalise=v=>String(v??'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLocaleLowerCase('pt');
|
||||
const query=normalise(search).trim();
|
||||
return orders.filter(o=>(!state||o.state===state)&&(!query||[o.reference,o.equipment?.identifier,o.equipment?.description,o.equipment?.serial,o.client_id,o.assigned_to].some(v=>normalise(v).includes(query))));
|
||||
}
|
||||
|
||||
export function visibleWorkshopEquipment(orders) {
|
||||
return [...new Map(orders.filter(o=>o.equipment).map(o=>[o.equipment.id,{...o.equipment,orders:orders.filter(other=>other.equipment?.id===o.equipment.id).map(other=>other.id)}])).values()];
|
||||
}
|
||||
@@ -7,5 +7,9 @@
|
||||
@viteReactRefresh
|
||||
@vite('resources/js/app.jsx')
|
||||
</head>
|
||||
<body><div id="app"></div></body>
|
||||
<body>
|
||||
@if(app()->environment('browser-testing'))
|
||||
<nav aria-label="Avaliação local" style="padding:10px 20px;background:#e1f1eb;color:#183d35;font:16px system-ui"><a href="/avaliar">Explorar SGF / trocar de função</a> · Ambiente de testes</nav>
|
||||
@endif
|
||||
<div id="app"></div></body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,10 @@ use App\Modules\Identity\Http\RequireCompanyContext;
|
||||
use App\Modules\Identity\Http\IdentityController;
|
||||
use App\Modules\Identity\Http\RequireValidSession;
|
||||
|
||||
if (app()->environment('browser-testing')) {
|
||||
require base_path('scripts/product-demo-entry.php');
|
||||
}
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('app');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
// Read-only access inventory. Never prints passwords, hashes, keys or cookies.
|
||||
$mode = $argv[1] ?? '';
|
||||
$root = $mode === 'compras-main' || $mode === 'compras-payables' ? 'C:/laragon/www/compras' : 'C:/laragon/www/sgf-tempo';
|
||||
if (! in_array($mode, ['sgf-main', 'compras-main', 'sgf-product', 'sgf-budget', 'sgf-payables', 'compras-payables'], true)) {
|
||||
throw new LogicException('Unknown instance');
|
||||
}
|
||||
$manifest = null;
|
||||
if (str_ends_with($mode, '-payables')) {
|
||||
$host = str_starts_with($mode, 'compras') ? 'compras' : 'sgf';
|
||||
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$host.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$manifest = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$host.'-access.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
} elseif ($mode === 'sgf-budget') {
|
||||
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/budget-demo.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
} elseif ($mode === 'sgf-product') {
|
||||
$settings = ['APP_ENV' => 'browser-testing'];
|
||||
$manifest = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
$settings = [];
|
||||
}
|
||||
foreach ($settings as $k => $v) {
|
||||
putenv($k.'='.$v);
|
||||
$_ENV[$k] = $_SERVER[$k] = $v;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db = \Illuminate\Support\Facades\DB::connection();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
if ($db->getDriverName() === 'pgsql') {
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
}
|
||||
$accounts = [];
|
||||
foreach (\App\Models\User::query()->orderBy('id')->get() as $u) {
|
||||
$record = ['id' => $u->id, 'email' => $u->email, 'verified' => (bool) $u->email_verified_at];
|
||||
if (str_contains($u->email, '@example.test')) {
|
||||
$record['synthetic'] = true;
|
||||
$candidate = null;
|
||||
if (isset($manifest['accounts'])) {
|
||||
foreach ($manifest['accounts'] as $role => $a) {
|
||||
if ($a['email'] === $u->email) {
|
||||
$record['role'] = $role;
|
||||
$candidate = $a['password'];
|
||||
}
|
||||
}
|
||||
} elseif (isset($manifest['password'])) {
|
||||
$candidate = $manifest['password'];
|
||||
}
|
||||
if ($candidate !== null) {
|
||||
$record['stored_demo_credential_matches'] = \Illuminate\Support\Facades\Hash::check($candidate, $u->password);
|
||||
}
|
||||
}
|
||||
if (isset($u->tenant_id)) {
|
||||
$record['tenant_id'] = (int) $u->tenant_id;
|
||||
}
|
||||
$accounts[] = $record;
|
||||
}
|
||||
echo json_encode(['instance' => $mode, 'environment' => app()->environment(), 'database' => $db->getDatabaseName(),
|
||||
'effective_database' => $db->getDriverName() === 'pgsql' ? $db->selectOne('select current_database() as d')->d : $db->getDatabaseName(),
|
||||
'session_cookie' => config('session.cookie'), 'session_domain' => config('session.domain'),
|
||||
'accounts' => $accounts, 'read_only' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
} finally {
|
||||
$db->rollBack();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Checks existing synthetic credentials only; never resets passwords or creates users.
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const reports=[];
|
||||
// Existing integrated evaluation entry: verify all four roles without credentials.
|
||||
for(const role of ['preparador','validador','tecnico','verificador']){
|
||||
const cookies=new Map();
|
||||
async function get(path,method='GET',body){
|
||||
const r=await fetch('http://127.0.0.1:8002'+path,{method,redirect:'manual',headers:{Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
|
||||
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
|
||||
return {status:r.status,text:await r.text(),location:r.headers.get('location')};
|
||||
}
|
||||
const landing=await get('/avaliar');assert.equal(landing.status,200);
|
||||
const token=landing.text.match(/name="_token" value="([^"]+)"/)?.[1];assert(token);
|
||||
const entry=await get('/avaliar/entrar','POST',{role,_token:token});assert.equal(entry.status,302);assert.equal(new URL(entry.location, 'http://127.0.0.1:8002').pathname,'/','evaluation redirect');
|
||||
assert.equal((await get('/')).status,200);
|
||||
const session=JSON.parse((await get('/api/session')).text);assert(session.user);assert(session.context);
|
||||
assert.equal((await get('/api/workshop')).status,200);
|
||||
const logout=await fetch('http://127.0.0.1:8002/api/logout',{method:'POST',headers:{Accept:'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':session.csrf}});assert.equal(logout.status,200);
|
||||
reports.push({host:'sgf-product',role,password_required:false,authorised_consultation:true,logout:true});
|
||||
}
|
||||
for(const mode of ['compras','sgf']){
|
||||
const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-demo-${mode}-access.json`,'utf8'));
|
||||
const base=`http://127.0.0.1:${mode==='compras'?8005:8006}`;
|
||||
for(const [role,account] of Object.entries(fixture.accounts)){
|
||||
const cookies=new Map();let csrf='',context='';
|
||||
async function request(path,method='GET',body){
|
||||
const r=await fetch(base+path,{method,redirect:'manual',headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':csrf,'X-XSRF-TOKEN':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':context},...(body?{body:JSON.stringify(body)}:{})});
|
||||
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
|
||||
const text=await r.text();let data={};try{data=JSON.parse(text);}catch{}
|
||||
if(data.csrf)csrf=data.csrf;if(data.context?.token)context=data.context.token;
|
||||
return {status:r.status,data};
|
||||
}
|
||||
assert.equal((await request(mode==='compras'?'/login':'/api/session')).status,200);
|
||||
const login=await request(mode==='compras'?'/login':'/api/login','POST',{email:account.email,password:account.password});assert.equal(login.status,200,mode+' '+role+' existing login');
|
||||
if(mode==='sgf')assert.equal((await request('/api/context','POST',fixture.scope)).status,200);
|
||||
assert.equal((await request('/api/supplier-payables')).status,200);
|
||||
assert.equal((await request(mode==='compras'?'/logout':'/api/logout','POST',{})).status,mode==='compras'?204:200);
|
||||
reports.push({host:mode,role,existing_password_accepted:true,authorised_consultation:true,logout:true});
|
||||
}
|
||||
}
|
||||
await fs.writeFile('docs/proofs/access-recovery.json',JSON.stringify({checked_at:new Date().toISOString(),results:reports,accounts_created:0,passwords_changed:0,permissions_changed:0,business_commands:0,visual_review:false},null,2)+'\n');
|
||||
console.log('Integrated password-free SGF entry passed for four roles; six existing payables credentials accepted by their correct hosts. Consultation and logout passed.');
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
const vite=await createServer({server:{middlewareMode:true},appType:'custom'});
|
||||
try{
|
||||
const {accountingSections,accountingFunctions,accountingReports,visibleAccountingSections,filterAccountingReports}=await vite.ssrLoadModule('/resources/js/accounting-menu.js');
|
||||
const {default:Workspace}=await vite.ssrLoadModule('/resources/js/AccountingMenuWorkspace.jsx');
|
||||
const {navigationGroups}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const context={token:'synthetic',company:{id:1,name:'Empresa sintética A'},unit:{id:7,name:'Unidade A'}};
|
||||
const companyCan=p=>p==='company.view';
|
||||
assert.equal(accountingSections.length,6);assert.equal(accountingFunctions.length,11);assert.equal(accountingReports.length,24);
|
||||
assert.deepEqual(visibleAccountingSections(()=>false),[]);assert.deepEqual(visibleAccountingSections(p=>p==='access.manage'),[]);
|
||||
assert.equal(visibleAccountingSections(p=>p==='catalogue_accounting.view').length,6);
|
||||
const group=navigationGroups(companyCan,7).find(g=>g.id==='accounting');assert.equal(group.items.length,6);assert(group.items.every(i=>i.informational));
|
||||
assert(!group.items.some(i=>i.id==='catalogue_accounting'));
|
||||
const accountantCan=p=>companyCan(p)||p==='catalogue_accounting.view';
|
||||
assert(navigationGroups(accountantCan,7).find(g=>g.id==='accounting').items.some(i=>i.id==='catalogue_accounting'));
|
||||
assert(filterAccountingReports('depreciacoes').some(r=>r.id==='assets'));
|
||||
assert(filterAccountingReports('fornecedores','Tesouraria').some(r=>r.id==='suppliers'));
|
||||
assert.deepEqual(filterAccountingReports('fornecedores','Pessoal'),[]);
|
||||
for(const section of accountingSections){
|
||||
const render=can=>renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context,can,navigate:()=>{}}));
|
||||
const html=render(companyCan);assert(html.includes('Submenus de contabilidade'));assert(html.includes('Empresa sintética A'));
|
||||
assert(!html.includes('<form'));assert(!html.includes('type="submit"'));assert(!html.includes('0.00 MZN'));
|
||||
assert(!html.includes('Abrir plano e classificação existentes'));assert(!html.includes('Abrir recebimentos e reconciliação de teste'));
|
||||
if(section.id==='accounting_configuration')assert(render(accountantCan).includes('Abrir plano e classificação existentes'));
|
||||
const differentUnit=(p,u=null)=>p==='company.view'||(p==='service_receivables.view'&&u===8);
|
||||
assert(!render(differentUnit).includes('Abrir recebimentos e reconciliação de teste'));
|
||||
const other=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context:{...context,company:{id:2,name:'Empresa sintética B'}},can:companyCan,navigate:()=>{}}));assert(!other.includes('Empresa sintética A'));
|
||||
}
|
||||
const denied=renderToStaticMarkup(React.createElement(Workspace,{section:'accounting_panel',context,can:()=>false,navigate:()=>{}}));assert(!denied.includes('Submenus de contabilidade'));
|
||||
await fs.writeFile('docs/proofs/accounting-menu.json',JSON.stringify({checked_at:new Date().toISOString(),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},null,2)+'\n');
|
||||
console.log('Accounting menu passed: six groups, eleven functions, 24 report definitions, permission/unit scoped links, report search and no fabricated balances or business commands.');
|
||||
}finally{await vite.close();}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
const vite=await createServer({server:{middlewareMode:true},appType:'custom'});
|
||||
try{
|
||||
console.log('Checking billing modules and rendering…');
|
||||
const model=await vite.ssrLoadModule('/resources/js/billing-menu.js');
|
||||
const {navigationGroups}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const {BillingDocumentList,default:Workspace}=await vite.ssrLoadModule('/resources/js/BillingMenuWorkspace.jsx');
|
||||
const workshop=await vite.ssrLoadModule('/resources/js/workshop-menu.js');
|
||||
const {default:WorkshopMenu}=await vite.ssrLoadModule('/resources/js/WorkshopMenuWorkspace.jsx');
|
||||
const context={token:'synthetic',company:{name:'Empresa sintética'},unit:{id:7,name:'Unidade sintética'}};
|
||||
const grants=[['service_invoices.view',7],['billing_drafts.view',7],['billing_drafts.create',7],['service_receivables.view',7],['issuer_documents.view',null]];
|
||||
const can=(p,u=null)=>grants.some(g=>g[0]===p&&g[1]===u);
|
||||
assert.deepEqual(navigationGroups(can,7).find(g=>g.id==='billing').items.map(i=>i.label),['Todos os documentos','Facturas','Facturas-recibo','Recibos','Notas de crédito','Orçamentos','Rascunhos']);
|
||||
assert(!navigationGroups(()=>false,7).some(g=>g.id==='billing'));
|
||||
assert(!navigationGroups(can,8).flatMap(g=>g.items).some(i=>['service_invoices','billing_drafts'].includes(i.id)));
|
||||
assert(navigationGroups(can,7).flatMap(g=>g.items).some(i=>i.id==='service_invoice_setup'));
|
||||
const techCan=(p,u=null)=>u===7&&['workshop.view','workshop.record'].includes(p);
|
||||
const workshopCan=(p,u=null)=>techCan(p,u)||can(p,u)||(['clients.view','company.view','services.view'].includes(p)&&u===null);
|
||||
assert.equal(workshop.visibleWorkshopSections(workshopCan,7).length,12);
|
||||
assert(!workshop.visibleWorkshopSections(techCan,7).some(s=>['workshop_finance','workshop_clients'].includes(s.id)));
|
||||
assert.deepEqual(workshop.visibleWorkshopSections(techCan,8),[]);
|
||||
const orders=[{id:1,reference:'OS-SIM-1',client_id:5,state:'open',assigned_to:12,equipment:{id:4,identifier:'BOMBA-SIM',description:'Bomba sintética',serial:'SER-SIM'}},{id:2,reference:'OS-SIM-2',client_id:5,state:'records_verified',assigned_to:12,equipment:{id:4,identifier:'BOMBA-SIM',description:'Bomba sintética',serial:'SER-SIM'}}];
|
||||
assert.deepEqual(workshop.filterWorkshopOrders(orders,'sintetica','open').map(o=>o.id),[1]);
|
||||
assert.equal(workshop.filterWorkshopOrders(orders,'SER-SIM').length,2);
|
||||
assert.deepEqual(workshop.visibleWorkshopEquipment(orders).map(e=>e.orders),[[1,2]]);
|
||||
for(const section of workshop.workshopSections.filter(s=>s.pending)){
|
||||
const pending=renderToStaticMarkup(React.createElement(WorkshopMenu,{section:section.id,context,can:workshopCan,api:()=>{},navigate:()=>{}}));
|
||||
assert(pending.includes('Capacidade ainda não disponível'));assert(!pending.includes('Saldo inicial'));
|
||||
}
|
||||
const invoice=model.invoiceRow({id:1,series_code:'TEST-A',number:4,issued_at:'2026-10-29T10:00:00Z'},{snapshot:{issued_local:'2026-10-29 12:00:00',document:{client:{name:'João sintético',data:{nuit:'SIM-001'}},data:{currency:'MZN'}},calculation:{total_document_proposed:'11600.00'}},document_lines:[],corrections:[]});
|
||||
const draft=model.draftRow({id:2,name:'Proposta sintética',version:3,data:{client_name:'Cliente sintético',currency:'USD'},base_amount:'10.000000000001',actor_id:9});
|
||||
assert.equal(invoice.total,'11600.00');assert.equal(invoice.balance,null);assert.equal(invoice.dueDate,null);assert.equal(draft.total,null);assert.equal(draft.base,'10.000000000001');
|
||||
const rows=[invoice,draft];
|
||||
for(const [filters,expected] of [[{search:'joao'},['invoice:1']],[{search:'SIM-001'},['invoice:1']],[{currency:'USD'},['draft:2']],[{from:'2026-10-01'},['invoice:1']],[{to:'2026-10-01'},[]],[{author:'9'},['draft:2']],[{series:'TEST-A',type:'draft'},[]]])assert.deepEqual(model.filterBillingRows(rows,filters).map(r=>r.key),expected);
|
||||
const calls=[];
|
||||
const loaded=await model.loadBillingRows(async path=>{calls.push(path);if(path==='/api/a/billing_drafts')return {records:[]};throw new Error('fixture outage');},can,7);
|
||||
assert.equal(loaded.sources.find(s=>s.label==='Facturas').error,'fixture outage');assert.equal(loaded.sources.find(s=>s.label==='Rascunhos').error,undefined);
|
||||
assert.deepEqual(await model.loadBillingRows(()=>{throw Error('Unauthorised fetch');},()=>false,7),{rows:[],sources:[]});
|
||||
const html=renderToStaticMarkup(React.createElement(BillingDocumentList,{rows,filters:{search:'',type:'',currency:'',series:'',state:'',author:'',from:'',to:''},onFilter:()=>{},onClear:()=>{},onOpen:()=>{}}));
|
||||
for(const text of ['11600.00','10.000000000001','Consultar percurso financeiro','Por determinar','SIM-001','MZN','USD','Limpar filtros'])assert(html.includes(text));
|
||||
for(const section of model.billingSections.filter(s=>s.pending)){
|
||||
const pending=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context,can,api:()=>{},navigate:()=>{}}));
|
||||
assert(pending.includes('Capacidade ainda não disponível'));assert(!pending.includes('Confirmar emissão'));
|
||||
}
|
||||
// Actual existing isolated SGF host: use the established evaluation entry, no credentials.
|
||||
console.log('Checking existing isolated HTTP sources…');
|
||||
const cookies=new Map();
|
||||
async function request(path,method='GET',body){
|
||||
const r=await fetch('http://127.0.0.1:8002'+path,{signal:AbortSignal.timeout(15000),method,redirect:'manual',headers:{Connection:'close',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
|
||||
for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
|
||||
const text=await r.text();return {status:r.status,text};
|
||||
}
|
||||
const landing=await request('/avaliar');assert.equal(landing.status,200);const csrf=landing.text.match(/name="_token" value="([^"]+)"/)?.[1];assert(csrf);
|
||||
assert.equal((await request('/avaliar/entrar','POST',{role:'preparador',_token:csrf})).status,302);
|
||||
const session=JSON.parse((await request('/api/session')).text),unit=session.context.unit.id;
|
||||
const actualCan=(p,u=null)=>session.context.grants.some(g=>g.permission===p&&g.unit_id===u);
|
||||
const actual=await model.loadBillingRows(async path=>{const r=await request(path);assert.equal(r.status,200,path);return JSON.parse(r.text);},actualCan,unit);
|
||||
assert(actual.sources.every(s=>!s.error));assert(actual.rows.length>0);
|
||||
for(const row of actual.rows.filter(r=>r.type==='invoice'))assert.equal(row.total,row.detail.snapshot.calculation.total_document_proposed);
|
||||
const actualWorkshop=JSON.parse((await request('/api/workshop')).text);assert.equal(actualWorkshop.blocked,false);
|
||||
assert(actualWorkshop.orders.every(o=>o.equipment&&o.reference&&o.state));
|
||||
const logout=await fetch('http://127.0.0.1:8002/api/logout',{method:'POST',headers:{Connection:'close',Accept:'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':session.csrf}});assert.equal(logout.status,200);
|
||||
await fs.writeFile('docs/proofs/billing-menu.json',JSON.stringify({checked_at:new Date().toISOString(),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:actual.sources.map(s=>({source:s.label,count:s.rows.length})),visible_workshop_orders:actualWorkshop.orders.length,business_commands:0,visual_review:false,publication:false},null,2)+'\n');
|
||||
console.log('Billing menu passed: seven sections, scoped navigation, exact values, combined filters, unknown balances/dates, unavailable sources and actual isolated host reads. Not a browser visual review.');
|
||||
}finally{await vite.close();}
|
||||
@@ -5,8 +5,9 @@ import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
|
||||
try {
|
||||
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const {navigationGroups,navigationTopics,default:Navigation}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const {default:Home}=await server.ssrLoadModule('/resources/js/WorkspaceHome.jsx');
|
||||
const {default:PurchasingHome}=await server.ssrLoadModule('/resources/js/PurchasingHome.jsx');
|
||||
const context={company:{name:'Empresa sintética'},unit:{id:7,name:'Unidade sintética'}};
|
||||
const check=(grants)=>{
|
||||
const can=(permission,unit=null)=>grants.some(g=>g[0]===permission&&g[1]===unit);
|
||||
@@ -24,5 +25,25 @@ try {
|
||||
const reader=check([['company.view',null],['industrial_budgets.view',7]]);
|
||||
assert(!reader.html.includes('<h2>A validar</h2>'));assert(!reader.html.includes('<h2>A preparar e acompanhar</h2>'));
|
||||
assert(reader.html.includes('não agrega nem conta tarefas pendentes'));
|
||||
const allGroups=navigationGroups(()=>true,7),topics=navigationTopics(allGroups);
|
||||
assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Configuração']);
|
||||
const before=allGroups.flatMap(g=>g.items.map(i=>i.id)).sort(),after=topics.flatMap(t=>t.sections.flatMap(g=>g.items.map(i=>i.id))).sort();
|
||||
assert.deepEqual(after,before);assert.equal(new Set(after).size,after.length);
|
||||
const navHtml=renderToStaticMarkup(React.createElement(Navigation,{groups:allGroups,page:'billing_drafts',navigate:()=>{},user:{name:'Avaliador sintético',email:'consulta@example.test'},busy:false,logout:()=>{}}));
|
||||
assert(navHtml.includes('workspace-topbar'));assert(!navHtml.includes('grouped-sidebar'));assert(!navHtml.includes('navigation-drawer'));
|
||||
for(const topic of topics.filter(t=>t.id!=='start'))assert(navHtml.includes(`aria-controls="topic-${topic.id}"`));
|
||||
assert(navHtml.includes('aria-expanded="false"'));assert(navHtml.includes('topic-item-active'));assert(navHtml.includes('aria-current="page"'));
|
||||
const foreignOnly=navigationTopics(otherUnit.groups);assert(!foreignOnly.flatMap(t=>t.sections).some(g=>g.id==='costs'));
|
||||
const future=navigationTopics([{id:'future_module',label:'Novo assunto',items:[{id:'future_page',label:'Destino autorizado'}]}]);assert.equal(future[0].sections[0].items[0].id,'future_page');
|
||||
assert.equal(navigationTopics(noAccess.groups).length,1);
|
||||
const companyOnly=check([['company.view',null]]),purchaseTopic=navigationTopics(companyOnly.groups).find(t=>t.id==='purchasing');
|
||||
assert(purchaseTopic);assert.deepEqual(purchaseTopic.sections[0].items.map(i=>i.id),['purchasing_home']);
|
||||
assert(navigationTopics(companyOnly.groups).some(t=>t.id==='stock'));
|
||||
const purchaseHtml=renderToStaticMarkup(React.createElement(PurchasingHome,{context,can:p=>p==='company.view',navigate:()=>{}}));
|
||||
assert(purchaseHtml.includes('Área de Compras'));assert(purchaseHtml.includes('ainda não apresenta percursos operacionais'));
|
||||
assert(!purchaseHtml.includes('<button'));
|
||||
const budgetOnly=check([['company.view',null],['purchasing_budget.view',7]]);
|
||||
assert(budgetOnly.groups.find(g=>g.id==='purchasing').items.some(i=>i.id==='purchasing_budget'));
|
||||
assert(!budgetOnly.groups.find(g=>g.id==='purchasing').items.some(i=>i.id==='supplier_payables'));
|
||||
console.log('Home/navigation render checks passed: preparer, reviewer, reader, other unit and absent permissions. No database or browser used.');
|
||||
} finally {await server.close();}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const base = 'http://127.0.0.1:8002';
|
||||
const cookies = new Map();
|
||||
let csrf = '', context = '';
|
||||
async function request(path, method = 'GET', body) {
|
||||
const response = await fetch(base + path, {method, redirect: 'manual', headers: {
|
||||
Accept: 'application/json', Cookie: [...cookies].map(([k,v]) => `${k}=${v}`).join('; '),
|
||||
'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-TOKEN': csrf, 'X-SGF-Context': context,
|
||||
}, ...(body ? {body: new URLSearchParams(body)} : {})});
|
||||
for (const cookie of response.headers.getSetCookie()) {
|
||||
const pair = cookie.split(';', 1)[0], index = pair.indexOf('=');
|
||||
cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
}
|
||||
return {status: response.status, text: await response.text()};
|
||||
}
|
||||
async function enter() {
|
||||
const page = await request('/avaliar');
|
||||
assert.equal(page.status, 200);
|
||||
csrf = page.text.match(/name="_token" value="([^"]+)"/)[1];
|
||||
assert.equal((await request('/avaliar/entrar', 'POST', {role: 'proprietario', _token: csrf})).status, 302);
|
||||
const session = JSON.parse((await request('/api/session')).text);
|
||||
assert.equal(session.user.email, 'proprietario-avaliacao@example.test');
|
||||
assert(session.context);
|
||||
csrf = session.csrf; context = session.context.token;
|
||||
return session;
|
||||
}
|
||||
const first = await enter();
|
||||
const 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'];
|
||||
for (const module of modules) assert.equal((await request('/api/' + module)).status, 200, module);
|
||||
for (const permission of ['workshop.record', 'workshop.verify', 'direct_costs.prepare', 'direct_costs.validate',
|
||||
'industrial_costs.prepare', 'industrial_costs.validate', 'catalogue_accounting.propose', 'catalogue_accounting.approve']) {
|
||||
assert(first.context.grants.some(g => g.permission === permission), permission);
|
||||
}
|
||||
assert.equal((await request('/avaliar/entrar', 'POST', {role: 'proprietario', user_id: '1', _token: csrf})).status, 422);
|
||||
const second = await enter();
|
||||
assert.equal(second.user.id, first.user.id);
|
||||
assert.deepEqual(second.context.grants, first.context.grants, 'Re-entry must not duplicate grants');
|
||||
await fs.writeFile('docs/proofs/local-owner-access.json', JSON.stringify({checked_at: new Date().toISOString(),
|
||||
instance: base, synthetic_owner: true, consulted_modules: modules, grants: second.context.grants.length,
|
||||
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}, null, 2) + '\n');
|
||||
console.log('Local owner entry, 16 module queries, combined permissions and repeat entry passed.');
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const reports=[];
|
||||
for (const [host,port,page] of [['compras',8005,'/obrigacoes-fornecedores'],['sgf',8006,'/supplier-payables']]) {
|
||||
const cookies=new Map();
|
||||
async function request(path,method='GET',body) {
|
||||
const r=await fetch(`http://127.0.0.1:${port}${path}`, {method,redirect:'manual',headers:{Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})});
|
||||
for(const c of r.headers.getSetCookie()) {const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));}
|
||||
return {status:r.status,text:await r.text(),location:r.headers.get('location')};
|
||||
}
|
||||
const token=text=>{const m=text.match(/name="_token" value="([^"]+)"/);assert(m,'CSRF token present');return m[1];};
|
||||
const entry=await request('/demo');assert.equal(entry.status,200);assert(!entry.text.includes('type="password"'));
|
||||
assert.equal((await request('/demo/entrar','POST',{role:'consulta'})).status,419);
|
||||
assert.equal((await request('/demo/entrar','POST',{role:'admin',_token:token(entry.text)})).status,302);
|
||||
let baseline;
|
||||
for(const role of ['preparacao','validacao','consulta']) {
|
||||
const landing=await request('/demo');
|
||||
const login=await request('/demo/entrar','POST',{role,_token:token(landing.text)});
|
||||
assert.equal(login.status,302,host+' '+role+' '+login.text.slice(0,150));assert(login.location.endsWith(page));
|
||||
const ui=await request(page);assert.equal(ui.status,200,host+' authorised page');assert(ui.text.includes('Trocar função ou sair da demonstração'));
|
||||
assert.equal(ui.text.includes('data-action="prepare"'),role==='preparacao');
|
||||
if(role==='consulta')assert(!ui.text.includes('<form data-action='));
|
||||
const data=JSON.parse((await request('/api/supplier-payables')).text);
|
||||
assert.equal(data.documents[0].total_minor,1160000);
|
||||
const counts=[data.proposals.length,data.obligations.length,data.events.length];
|
||||
if(baseline)assert.deepEqual(counts,baseline);else baseline=counts;
|
||||
if(host==='sgf'){const session=JSON.parse((await request('/api/session')).text);assert.equal(session.user.email,role+'-obrigacoes@example.test');assert(session.context);assert.equal((await request('/')).status,200);}
|
||||
}
|
||||
const landing=await request('/demo');assert.equal((await request('/demo/sair','POST',{_token:token(landing.text)})).status,302);
|
||||
assert.notEqual((await request(page)).status,200);
|
||||
const again=await request('/demo');assert.equal((await request('/demo/entrar','POST',{role:'consulta',_token:token(again.text)})).status,302);
|
||||
const recovered=JSON.parse((await request('/api/supplier-payables')).text);assert.deepEqual([recovered.proposals.length,recovered.obligations.length,recovered.events.length],baseline);
|
||||
await request('/demo/sair','POST',{_token:token((await request('/demo')).text)});
|
||||
reports.push({host,passed:true,password_required:false,roles:['preparacao','validacao','consulta'],csrf_enforced:true,arbitrary_role_refused:true,context_selected:host==='sgf',history_preserved:true,financial_commands_executed:false});
|
||||
}
|
||||
await fs.writeFile('docs/proofs/payables-demo-entry.json',JSON.stringify({checked_at:new Date().toISOString(),hosts:reports,visual_review:false,public_deployment:false},null,2)+'\n');
|
||||
console.log('Password-free synthetic entry passed in both compositions; no financial commands executed.');
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
// Registered exclusively by the isolated payables router, never by production routes.
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
$isSgf = str_starts_with($s['DB_DATABASE'], 'payable_sgf_');
|
||||
$destination = $isSgf ? '/supplier-payables' : '/obrigacoes-fornecedores';
|
||||
$guardDemo = static function (): void {
|
||||
$db = DB::connection();
|
||||
abort_unless(app()->environment('payable-testing') && $db->getDriverName() === 'pgsql'
|
||||
&& $db->getConfig('host') === '127.0.0.1'
|
||||
&& preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName())
|
||||
&& $db->selectOne('select current_database() as d')->d === $db->getDatabaseName(), 403);
|
||||
};
|
||||
|
||||
Route::middleware('web')->group(function () use ($guardDemo, $isSgf, $destination) {
|
||||
Route::get('/demo', function (Request $r) use ($guardDemo, $isSgf, $destination) {
|
||||
$guardDemo();
|
||||
$e = static fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$title = $isSgf ? 'SGF — demonstração' : 'Compras — demonstração';
|
||||
$html = '<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>'.$title.'</title><style>body{font:18px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:850px;margin:auto;padding:30px}section{background:white;padding:24px;border-radius:12px;margin:20px 0}button,a{font:inherit}button{padding:14px;background:#125a4b;color:white;border:0;border-radius:8px;cursor:pointer}form{margin:18px 0}a{color:#125a4b}</style><main><h1>'.$title.'</h1><p>Entre para experimentar e identificar melhorias. Não precisa de conta nem de palavra-passe.</p><section><h2>Escolha a função que quer experimentar</h2><p>Todos os dados são sintéticos. A função escolhida determina as acções disponíveis.</p>';
|
||||
foreach (['preparacao' => ['Preparar', 'Consultar a factura e propor a obrigação.'], 'validacao' => ['Validar', 'Rever a proposta preparada por outra identidade sintética.'], 'consulta' => ['Consultar', 'Explorar valores, origem e histórico, sem alterar dados.']] as $role => [$label, $description]) {
|
||||
$html .= '<form method="post" action="/demo/entrar"><input type="hidden" name="_token" value="'.$e(csrf_token()).'"><input type="hidden" name="role" value="'.$role.'"><button>'.$label.'</button> '.$description.'</form>';
|
||||
}
|
||||
$html .= '</section><section><h2>O que experimentar</h2><p>Factura: base de 10 000 MZN + imposto documental de 1 600 MZN = 11 600 MZN. Preparar, trocar para validar e consultar o resultado. Repetir deve recuperar a mesma obrigação.</p><p>O vencimento permanece pendente e o atraso indeterminado. Este percurso não executa pagamentos.</p><p>As funções usam identidades de demonstração partilhadas; não identificam a pessoa real que está a testar. O cenário e o histórico são partilhados e conservados entre visitas.</p></section>';
|
||||
if ($r->user()) {
|
||||
$html .= '<p>Sessão actual: '.$e($r->user()->name).' · <a href="'.$destination.'">Continuar o percurso</a> · <a href="/">Abrir a aplicação</a></p><form method="post" action="/demo/sair"><input type="hidden" name="_token" value="'.$e(csrf_token()).'"><button>Sair da demonstração</button></form>';
|
||||
}
|
||||
return response($html.'</main></html>')->header('Cache-Control', 'no-store')->header('X-Robots-Tag', 'noindex, nofollow');
|
||||
});
|
||||
|
||||
Route::post('/demo/entrar', function (Request $r) use ($guardDemo, $isSgf, $destination) {
|
||||
$guardDemo();
|
||||
$d = $r->validate(['role' => 'required|in:preparacao,validacao,consulta']);
|
||||
abort_if(array_diff(array_keys($r->all()), ['role', '_token']) !== [], 422);
|
||||
// No arbitrary user identifier or permissions may be supplied by a visitor.
|
||||
$u = User::where('email', $d['role'].'-obrigacoes@example.test')->firstOrFail();
|
||||
$grants = DB::table('pa_access')->where(['user_id' => $u->id, 'action' => 'view'])->get();
|
||||
abort_unless($grants->count() === 1, 403);
|
||||
$g = $grants->first();
|
||||
if ($isSgf) {
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $g->tenant_id, $g->company_id, $g->unit_id);
|
||||
} else {
|
||||
abort_unless((int) $u->tenant_id === (int) $g->tenant_id && (int) $g->company_id === 0 && (int) $g->unit_id === 0, 403);
|
||||
}
|
||||
Auth::logout();
|
||||
$r->session()->invalidate();
|
||||
$r->session()->regenerateToken();
|
||||
Auth::login($u, false);
|
||||
$r->session()->regenerate();
|
||||
$r->session()->put('payables_demo_entry', true);
|
||||
if ($isSgf) {
|
||||
$r->session()->put('auth_version', (int) $u->auth_version);
|
||||
$r->session()->put('company_context', ['tenant_id' => (int) $g->tenant_id, 'company_id' => (int) $g->company_id, 'unit_id' => (int) $g->unit_id, 'token' => bin2hex(random_bytes(24))]);
|
||||
\App\Modules\Identity\Services\Audit::record('demo.entered', $u->id, $g->tenant_id, $g->company_id, $g->unit_id, details: ['role' => $d['role'], 'synthetic' => true]);
|
||||
}
|
||||
return redirect($destination);
|
||||
})->middleware('throttle:30,1');
|
||||
|
||||
Route::post('/demo/sair', function (Request $r) use ($guardDemo) {
|
||||
$guardDemo();
|
||||
Auth::logout();
|
||||
$r->session()->invalidate();
|
||||
$r->session()->regenerateToken();
|
||||
return redirect('/demo');
|
||||
});
|
||||
});
|
||||
@@ -6,4 +6,11 @@ if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$s['DB_D
|
||||
foreach($s as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
|
||||
$public=realpath($s['HOST_ROOT'].'/public');$uri=rawurldecode(parse_url($_SERVER['REQUEST_URI'],PHP_URL_PATH)?:'/');$file=realpath($public.$uri);
|
||||
if($uri!=='/'&&$file&&str_starts_with($file,$public.DIRECTORY_SEPARATOR)&&is_file($file))return false;
|
||||
require $public.'/index.php';
|
||||
// Password-free entry belongs only to this isolated demonstration host.
|
||||
define('LARAVEL_START', microtime(true));
|
||||
require $s['HOST_ROOT'].'/vendor/autoload.php';
|
||||
$app = require $s['HOST_ROOT'].'/bootstrap/app.php';
|
||||
$app->booted(function () use ($s) {
|
||||
require __DIR__.'/payables-demo-entry.php';
|
||||
});
|
||||
$app->handleRequest(\Illuminate\Http\Request::capture());
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
// Evaluation entry exists only on the local synthetic product host.
|
||||
use App\Models\User;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
$demoManifest = static function (Request $request): array {
|
||||
abort_unless(app()->environment('browser-testing')
|
||||
&& in_array($request->ip(), ['127.0.0.1', '::1'], true)
|
||||
&& in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1'], true), 404);
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
$manifest = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json'), true, 64, JSON_THROW_ON_ERROR);
|
||||
abort_unless(($manifest['product_demo']['version'] ?? '') === 'SGF-PRODUCT-DEMO-v1'
|
||||
&& ($manifest['product_demo']['synthetic_only'] ?? false) === true, 404);
|
||||
return $manifest;
|
||||
};
|
||||
|
||||
Route::get('/avaliar', function (Request $request) use ($demoManifest) {
|
||||
$demoManifest($request);
|
||||
$csrf = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
|
||||
$html = '<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Explorar SGF Tempo</title><style>body{font:18px system-ui;background:#f4f6f5;color:#183d35;margin:0}main{max-width:960px;margin:auto;padding:30px}section{background:white;padding:24px;border-radius:12px;margin:20px 0}button,a{font:inherit}button{padding:14px;border:0;border-radius:8px;background:#125a4b;color:white;cursor:pointer}form{display:inline-block;margin:8px}a{color:#125a4b}</style><main><h1>Explorar SGF Tempo</h1><p>Entrada de avaliação local, sem palavra-passe. Podes trocar de função sempre que quiseres, sem pedir concessões.</p><section><h2>Aplicação integrada</h2><p>Clientes, serviços, documentação, facturas de teste, recebimentos simulados, oficina, custos e orçamento industrial.</p>';
|
||||
foreach (['proprietario' => 'Entrar como proprietário — todas as áreas desta instalação', 'preparador' => 'Preparar e gerir', 'validador' => 'Rever e validar', 'tecnico' => 'Registar trabalho de oficina', 'verificador' => 'Verificar trabalho de oficina'] as $role => $label) {
|
||||
$html .= '<form method="post" action="/avaliar/entrar"><input type="hidden" name="_token" value="'.$csrf.'"><input type="hidden" name="role" value="'.$role.'"><button>'.$label.'</button></form>';
|
||||
}
|
||||
$html .= '</section><section><h2>Percursos em instalações separadas</h2><p><a href="http://127.0.0.1:8003">Controlo orçamental de Compras</a></p><p><a href="http://127.0.0.1:8006/demo">Obrigações a fornecedores no SGF</a></p><p><a href="http://127.0.0.1:8005/demo">Compras autónomo</a></p></section><p>Dados sintéticos persistentes e partilhados. A troca de função conserva os exemplos e o histórico. Esta entrada não concede administração da instalação real nem elimina as regras dos processos.</p></main></html>';
|
||||
return response($html)->header('Cache-Control', 'no-store')->header('X-Robots-Tag', 'noindex, nofollow');
|
||||
});
|
||||
|
||||
Route::post('/avaliar/entrar', function (Request $request) use ($demoManifest) {
|
||||
$manifest = $demoManifest($request);
|
||||
$data = $request->validate(['role' => 'required|in:proprietario,preparador,validador,tecnico,verificador']);
|
||||
abort_if(array_diff(array_keys($request->all()), ['role', '_token']) !== [], 422);
|
||||
if ($data['role'] === 'proprietario') {
|
||||
$user = DB::transaction(function () use ($manifest) {
|
||||
// Serialise provisioning, including first entry. Never touch customer accounts.
|
||||
DB::select('select pg_advisory_xact_lock(?)', [80202910]);
|
||||
$owner = User::firstOrCreate(['email' => 'proprietario-avaliacao@example.test'],
|
||||
['name' => 'Proprietário — avaliação local', 'password' => bin2hex(random_bytes(32))]);
|
||||
$actors = array_values($manifest['product_demo']['actors']);
|
||||
$memberships = DB::table('company_memberships')->whereIn('user_id', $actors)->get(['tenant_id', 'company_id'])->unique('company_id');
|
||||
$added = 0;
|
||||
foreach ($memberships as $membership) {
|
||||
DB::table('company_memberships')->insertOrIgnore(['user_id' => $owner->id, 'tenant_id' => $membership->tenant_id, 'company_id' => $membership->company_id]);
|
||||
}
|
||||
$grants = DB::table('permission_grants')->whereIn('user_id', $actors)->get(['tenant_id', 'company_id', 'permission', 'unit_id']);
|
||||
foreach (['view', 'propose', 'approve'] as $action) {
|
||||
$grants->push((object) ['tenant_id' => $manifest['tenant'], 'company_id' => $manifest['company'], 'permission' => 'catalogue_accounting.'.$action, 'unit_id' => null]);
|
||||
}
|
||||
foreach ($grants as $grant) {
|
||||
$scope = ['user_id' => $owner->id, 'tenant_id' => $grant->tenant_id, 'company_id' => $grant->company_id, 'permission' => $grant->permission, 'unit_id' => $grant->unit_id];
|
||||
if (!DB::table('permission_grants')->where($scope)->exists()) {
|
||||
DB::table('permission_grants')->insert($scope + ['created_at' => now(), 'updated_at' => now()]);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
if ($added) {
|
||||
Audit::record('demo.owner_access_prepared', $owner->id, $manifest['tenant'], $manifest['company'], $manifest['unit'], details: ['synthetic' => true, 'grants_added' => $added]);
|
||||
}
|
||||
return $owner;
|
||||
});
|
||||
} else {
|
||||
$user = User::findOrFail($manifest['product_demo']['actors'][$data['role']]);
|
||||
}
|
||||
abort_unless(str_ends_with($user->email, '@example.test'), 403);
|
||||
app(CompanyContext::class)->selectFor($user->id, $manifest['tenant'], $manifest['company'], $manifest['unit']);
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
Auth::login($user, false);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->put('auth_version', (int) $user->auth_version);
|
||||
$request->session()->put('company_context', ['tenant_id' => (int) $manifest['tenant'], 'company_id' => (int) $manifest['company'], 'unit_id' => (int) $manifest['unit'], 'token' => bin2hex(random_bytes(24))]);
|
||||
Audit::record('demo.entered', $user->id, $manifest['tenant'], $manifest['company'], $manifest['unit'], details: ['role' => $data['role'], 'synthetic' => true]);
|
||||
return redirect('/');
|
||||
})->middleware('throttle:30,1');
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
const server=await createServer({server:{middlewareMode:true},appType:'custom'});
|
||||
try{
|
||||
const {stockSections,visibleStockSections,availableStockReports}=await server.ssrLoadModule('/resources/js/stock-menu.js');
|
||||
const {default:Workspace}=await server.ssrLoadModule('/resources/js/StockMenuWorkspace.jsx');
|
||||
const {navigationGroups}=await server.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const {default:Home}=await server.ssrLoadModule('/resources/js/WorkspaceHome.jsx');
|
||||
const companyCan=p=>p==='company.view';
|
||||
const context={token:'synthetic',company:{id:1,name:'Empresa sintética A'},unit:{id:7,name:'Unidade A'}};
|
||||
assert.deepEqual(stockSections.map(s=>s.label),['Painel','Existências','Entradas','Saídas','Transferências','Devoluções','Relatórios']);
|
||||
assert.deepEqual(visibleStockSections(()=>false),[]);
|
||||
assert.deepEqual(visibleStockSections(p=>p==='workshop.view'),[]);
|
||||
assert.deepEqual(availableStockReports().map(r=>r.id),['balances','movements','minimum']);
|
||||
assert(!availableStockReports({transit:true,returns:true,lots:true,inventory:true,history:true},false).some(r=>r.restricted));
|
||||
assert(availableStockReports({},true).some(r=>r.id==='valuation'));
|
||||
assert(!availableStockReports({transit:false,lots:false}).some(r=>r.feature));
|
||||
const groups=navigationGroups(companyCan,7),stock=groups.find(g=>g.id==='stock');
|
||||
assert.equal(stock.status,'Em preparação');assert(stock.items.every(i=>i.informational));
|
||||
const home=renderToStaticMarkup(React.createElement(Home,{context,can:companyCan,groups,navigate:()=>{}}));assert(home.includes('shortcut-status'));
|
||||
for(const section of stockSections){
|
||||
const html=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context,can:companyCan,navigate:()=>{}}));
|
||||
assert(html.includes('fonte de stock ainda não ligada'));assert(html.includes('Empresa sintética A'));assert(html.includes('Submenus de stock'));
|
||||
assert(!html.includes('<form'));assert(!html.includes('type="submit"'));assert(!html.includes('value="0"'));assert(!html.includes('0.000'));assert(!html.includes('http://127.0.0.1:8005'));
|
||||
if(section.id==='stock_reports'){assert(html.includes('Consulta indisponível'));assert(!html.includes('value="valuation"'));assert(!html.includes('value="expiry"'));}
|
||||
const other=renderToStaticMarkup(React.createElement(Workspace,{section:section.id,context:{...context,company:{id:2,name:'Empresa sintética B'}},can:companyCan,navigate:()=>{}}));
|
||||
assert(other.includes('Empresa sintética B'));assert(!other.includes('Empresa sintética A'));
|
||||
}
|
||||
const denied=renderToStaticMarkup(React.createElement(Workspace,{section:'stock_balances',context,can:()=>false,navigate:()=>{}}));
|
||||
assert(!denied.includes('Submenus de stock'));
|
||||
await fs.writeFile('docs/proofs/stock-menu.json',JSON.stringify({checked_at:new Date().toISOString(),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},null,2)+'\n');
|
||||
console.log('Stock menu checks passed: seven entries, source-unavailable states, no fabricated balances, no business forms, conditional reports/costs hidden and company context preserved. Operational integration remains pending.');
|
||||
}finally{await server.close();}
|
||||
Reference in New Issue
Block a user