commit4
This commit is contained in:
@@ -66,6 +66,7 @@ final class SupplierPayables
|
||||
{
|
||||
$d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all());
|
||||
$d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')];
|
||||
$d['payments_available'] = app()->environment('payable-testing') && DB::connection()->getSchemaBuilder()->hasTable('ps_payments') && $this->can($u, 'payments_view');
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Purchasing;
|
||||
|
||||
use Tempo\Finance\Payables\SettlementHttp;
|
||||
|
||||
final class SupplierPaymentsController extends SettlementHttp
|
||||
{
|
||||
protected const MODE = 'sgf';
|
||||
|
||||
protected const SERVICE = SupplierPayables::class;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# Página inicial e menus agrupados
|
||||
|
||||
**Reposição de visibilidade da Contabilidade, 29/10/2026:** a entrada não tinha sido eliminada: o agrupamento horizontal colocara o grupo accounting dentro de Financeiro. A pedido da Tempo, Contabilidade volta a assunto principal, com dropdown próprio, preservando os oito destinos e os mesmos direitos. A ordem passa a Início, Comercial, Compras, Stock, Oficinas, Financeiro, Contabilidade, Configuração. Sem alteração de dados ou concessões. O check existente passa a manter inventário independente dos onze grupos, dos oito destinos contabilísticos e dos assuntos principais; cobre company.view e os dois direitos contabilísticos isolados, além da ausência de direitos. O comando build executa esse check antes do Vite, interrompendo a compilação perante regressão; não depende de hooks npm opcionais. Novas alterações devem actualizar explicitamente o inventário apenas quando o âmbito aprovado mudar. Esta guarda detecta remoção/agrupamento indevido conhecido, não substitui revisão visual ou prova no destino publicado. Prova HTTP da instância 8002 verifica também Contabilidade separada para as cinco funções sintéticas existentes. Sem publicação ou checkpoint.
|
||||
|
||||
**Conferência da instância em execução, 29/10/2026:** após «continua a executar», scripts/navigation-live-check.mjs verificou as cinco funções sintéticas já existentes (proprietário, preparador, validador, técnico e verificador) por entrada/sessão/saída em 8002. Compras e Stock surgem separados nos cinco conjuntos efectivos de concessões; todos os destinos visíveis têm componente registado e não há IDs duplicados. A aplicação serve o bundle actual com purchasing_home, não apenas código-fonte local não compilado. Prova sanitizada em docs/proofs/navigation-live.json. Sem novas contas, alteração de concessões ou comandos de negócio. Conferência HTTP e renderização estática; não prova interactiva em navegador.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Pagamentos e afectações sintéticos — entrega técnica, 29/10/2026
|
||||
|
||||
Implementado o incremento seguinte às obrigações, nas duas composições, com uma única implementação financeira. Disponível apenas nas demonstrações isoladas: Compras <http://127.0.0.1:8005/demo> e SGF <http://127.0.0.1:8006/demo>. Seleccionar preparação, validação ou consulta; reconhecer primeiro a obrigação existente, com outra identidade, e seguir a ligação «Pagamentos e afectações». Não é uma autorização de utilização real nem aceitação da entrega.
|
||||
|
||||
## Responsabilidade e código
|
||||
|
||||
O código comum reside em `packages/payables/src/SupplierSettlements.php`, `SettlementSchema.php`, `SettlementHttp.php` e `ui/settlements.php`. Carregamento e apresentação em `bootstrap.php` e `src/Workspace.php`. `src/Payables.php` conserva a responsabilidade única pelo saldo: principal menos histórico confirmado existente e afectações novas confirmadas. Não se escrevem pagamentos externos legados para representar novamente o mesmo facto.
|
||||
|
||||
Os adaptadores são `app/Modules/Purchasing/SupplierPaymentsController.php` no SGF e `app/Http/Controllers/SupplierPaymentsController.php` em Compras, apoiados nos respectivos `SupplierPayables.php`. Contexto, pessoa, organização e direitos são revalidados no servidor pelos adaptadores existentes; o motor recebe contexto explícito. Rotas em `routes/web.php` de cada aplicação. A página anterior de obrigações apresenta a ligação quando disponível. No SGF, `resources/js/WorkspaceNavigation.jsx` e `app.jsx` incluem a entrada; nenhuma concessão real foi adicionada.
|
||||
|
||||
Não se equiparam tenant, empresa e unidade. Referências factuais são únicas dentro da empresa, mesmo entre unidades; a consulta e execução continuam limitadas pelo contexto autorizado. Não há dependência de vendas ou de um SGF remoto.
|
||||
|
||||
## Percurso e montantes
|
||||
|
||||
1. A obrigação reconhecida mantém 10 000 MZN de base, imposto documental de 1 600 e principal de **11 600 MZN**, sem recálculo fiscal.
|
||||
2. Preparação propõe um pagamento sintético de **4 000 MZN** com data factual e referências artificiais de facto/evidência `SIM-…`.
|
||||
3. Outra pessoa confirma com motivo e referência de evidência. O saldo da obrigação continua **11 600 MZN**; o pagamento confirmado tem **4 000 MZN por afectar**.
|
||||
4. Preparação propõe a afectação de 4 000 MZN à obrigação; outra pessoa confirma. Saldo **7 600 MZN**, por afectar zero. Origem, versão, decisões e histórico permanecem consultáveis.
|
||||
|
||||
O vencimento continua desconhecido, atraso indeterminado e dias de atraso sem valor. A data factual do pagamento não preenche o vencimento. Montantes são cadeias decimais convertidas exactamente em cêntimos inteiros, sem floats ou arredondamento novo. Não houve substituição ou actualização de dependências.
|
||||
|
||||
Pagamento proposto, confirmação e afectação são acontecimentos distintos. Rejeições não alteram dívida. Há afectações parciais, vários pagamentos por obrigação e um pagamento distribuído por vários documentos compatíveis. Excesso fica por afectar. Referências estáveis e conteúdo normalizado impedem duplicações por novas chaves, versões ou pedidos simultâneos; incompatibilidades geram conflito. Comandos, decisões, movimentos, auditoria e eventos/outbox são atómicos e duráveis. A revogação e a separação por actor e pessoa são verificadas também após espera pelo bloqueio.
|
||||
|
||||
## Provas e alcance
|
||||
|
||||
- `docs/proofs/supplier-settlements.json`: ambas as composições aprovadas, mesmo SHA-256 comum `156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93`. Factura realmente conferida pelo percurso existente de Compras; sequência 11 600 → confirmação sem abatimento → afectação → 7 600, com vencimento/dias nulos.
|
||||
- Repetição, referências/evidências duplicadas, conteúdo incompatível, insuficiência, rejeição, identidade/pessoa, isolamento, versão alterada, informação insuficiente, revogação, imutabilidade, falha injectada e recuperação aprovados. Doze corridas PostgreSQL entre processos, com esperas de bloqueio observadas; apenas um efeito por facto.
|
||||
- A prova multidocumento usa um adaptador de origem sintético explícito do mesmo núcleo: dois documentos liquidados, terceiro de outro fornecedor intacto, excesso de 400 MZN por afectar e duplicação entre unidades recusada. Não equivale a outro percurso completo de facturas na interface de cada host.
|
||||
- Regressões anteriores de obrigação e histórico aprovadas (`payables-http.json`, `payables-history.json`). Compras: **526 testes aprovados, 2 ignorados, 4 383 asserções**, SQLite em memória. Ignorados preexistentes: `users with two factor enabled are redirected to two factor challenge` e `security page is displayed`. Cobertura MFA permanece pendente; não são testes do efeito monetário, comprovado separadamente.
|
||||
- Builds de SGF e Compras, TypeScript de Compras, PHPStan da aplicação e das novas classes comuns e Pint aprovados. JavaScript da interface conferido sintacticamente.
|
||||
- `supplier-settlements-demo-access.json`: páginas/API e ligação das obrigações verificadas por HTTP/HTML nas três funções de cada demonstração. Sem comandos monetários nessas bases. **Não foi realizada revisão visual nem ensaio manual da equipa.** A prova do adaptador não substitui essa revisão.
|
||||
|
||||
Automação: `scripts/payables-demo.ps1 proof -Settlements`, `settlements-http-proof.mjs`, `settlements-probe.php`. Bases e identidades de prova descartáveis, separadas das demonstrações e bases habituais. A revisão do navegador continua pendente; não se contornou o bloqueio da ferramenta.
|
||||
|
||||
## Habilitação e preservação
|
||||
|
||||
`scripts/settlements-install.php` instala nove tabelas `ps_*`, com restrições e triggers imutáveis, apenas após guarda efectiva de base QA local. Em cada demonstração foram adicionados sete direitos sintéticos às três identidades existentes, sem criar contas, documentos ou pagamentos. `settlements-demo-state.php` e os recibos `supplier-settlements-demo-compras.json` / `supplier-settlements-demo-sgf.json` comprovam por hashes a preservação dos documentos, histórico, contas e acessos anteriores. A demonstração orçamental não foi alterada.
|
||||
|
||||
Estas tabelas **não constituem uma migração aplicada a desenvolvimento ou produção**. O instalador está limitado ao ensaio. Uma futura disponibilização fora de QA exige migração própria, contexto operacional, permissões, backup/restauro e tratamento aprovado dos históricos. Nenhuma base habitual, acesso real, dependência, publicação ou push foi alterado neste incremento.
|
||||
|
||||
## Limites e próximos passos
|
||||
|
||||
- Novos pagamentos sobre obrigações provenientes de importação histórica são recusados neste recorte. O circuito de confirmação/rejeição dos pendentes importados continua separado e preservado; não se incorporam liquidações antigas como dívida integral.
|
||||
- Uma ligação pagamento/obrigação é imutável e única. Aumentar posteriormente essa mesma ligação ou reverter uma confirmação exige um futuro percurso explícito de correcção; não está entregue. Outros pagamentos podem afectar a mesma obrigação.
|
||||
- Não há execução de banco/caixa, reconciliação, reembolsos, crédito automático, cobranças, documento fiscal/PDF, contabilidade, stock ou efeitos orçamentais adicionais. Outbox durável não significa ligação bancária ou contabilística entregue.
|
||||
- Fazer o ensaio visual curto nas duas demonstrações e recolher dificuldades da equipa. Depois decidir aceitação e eventual checkpoint exclusivamente local. Não foi criado checkpoint de encerramento.
|
||||
|
||||
Esta entrega complementa [obrigações a fornecedores](SUPPLIER_PAYABLE_DELIVERY.md); não substitui as aceitações e limitações anteriores.
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"checked_at": "2026-10-29T21:13:28.393Z",
|
||||
"host": "http://127.0.0.1:8002",
|
||||
"current_bundle_served": true,
|
||||
"results": [
|
||||
{
|
||||
"role": "proprietario",
|
||||
"topics": [
|
||||
"Início",
|
||||
"Comercial",
|
||||
"Compras",
|
||||
"Stock",
|
||||
"Oficinas",
|
||||
"Financeiro",
|
||||
"Contabilidade",
|
||||
"Configuração"
|
||||
],
|
||||
"destinations": 51,
|
||||
"purchasing_visible": true,
|
||||
"stock_separate": true,
|
||||
"accounting_separate": true,
|
||||
"all_destinations_registered": true
|
||||
},
|
||||
{
|
||||
"role": "preparador",
|
||||
"topics": [
|
||||
"Início",
|
||||
"Comercial",
|
||||
"Compras",
|
||||
"Stock",
|
||||
"Oficinas",
|
||||
"Financeiro",
|
||||
"Contabilidade",
|
||||
"Configuração"
|
||||
],
|
||||
"destinations": 50,
|
||||
"purchasing_visible": true,
|
||||
"stock_separate": true,
|
||||
"accounting_separate": true,
|
||||
"all_destinations_registered": true
|
||||
},
|
||||
{
|
||||
"role": "validador",
|
||||
"topics": [
|
||||
"Início",
|
||||
"Comercial",
|
||||
"Compras",
|
||||
"Stock",
|
||||
"Oficinas",
|
||||
"Financeiro",
|
||||
"Contabilidade",
|
||||
"Configuração"
|
||||
],
|
||||
"destinations": 49,
|
||||
"purchasing_visible": true,
|
||||
"stock_separate": true,
|
||||
"accounting_separate": true,
|
||||
"all_destinations_registered": true
|
||||
},
|
||||
{
|
||||
"role": "tecnico",
|
||||
"topics": [
|
||||
"Início",
|
||||
"Compras",
|
||||
"Stock",
|
||||
"Oficinas",
|
||||
"Contabilidade",
|
||||
"Configuração"
|
||||
],
|
||||
"destinations": 27,
|
||||
"purchasing_visible": true,
|
||||
"stock_separate": true,
|
||||
"accounting_separate": true,
|
||||
"all_destinations_registered": true
|
||||
},
|
||||
{
|
||||
"role": "verificador",
|
||||
"topics": [
|
||||
"Início",
|
||||
"Compras",
|
||||
"Stock",
|
||||
"Oficinas",
|
||||
"Contabilidade",
|
||||
"Configuração"
|
||||
],
|
||||
"destinations": 27,
|
||||
"purchasing_visible": true,
|
||||
"stock_separate": true,
|
||||
"accounting_separate": true,
|
||||
"all_destinations_registered": true
|
||||
}
|
||||
],
|
||||
"business_commands": 0,
|
||||
"accounts_created": 0,
|
||||
"permissions_changed": 0,
|
||||
"type": "HTTP/session plus static render; not interactive browser review",
|
||||
"visual_review": false
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "COMMON-PAYABLE-SOURCE-SIM-v1",
|
||||
"passed": true,
|
||||
"checked_at": "2026-10-29T12:47:50.740Z",
|
||||
"checked_at": "2026-10-29T21:06:44.192Z",
|
||||
"team_budget_preserved": true,
|
||||
"hosts": [
|
||||
{
|
||||
"host": "compras",
|
||||
"passed": true,
|
||||
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
|
||||
"source_revision": "4a34d4256cf35402f407d4cb8d1f4a26441f8aa2be1a7b30312fdb2d631a22eb",
|
||||
"core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9",
|
||||
"source_revision": "1d789ffd6432c59d771d86e417b99f380037a941ec417d0844fe81c234fe07c7",
|
||||
"recognised_obligations": 0,
|
||||
"checks": [
|
||||
"previous_settlement_blocks_recognition",
|
||||
@@ -20,8 +20,8 @@
|
||||
{
|
||||
"host": "sgf",
|
||||
"passed": true,
|
||||
"core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962",
|
||||
"source_revision": "4a34d4256cf35402f407d4cb8d1f4a26441f8aa2be1a7b30312fdb2d631a22eb",
|
||||
"core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9",
|
||||
"source_revision": "1d789ffd6432c59d771d86e417b99f380037a941ec417d0844fe81c234fe07c7",
|
||||
"recognised_obligations": 0,
|
||||
"checks": [
|
||||
"previous_settlement_blocks_recognition",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "COMMON-PAYABLE-SOURCE-SIM-v1",
|
||||
"passed": true,
|
||||
"checked_at": "2026-10-29T19:29:20.603Z",
|
||||
"checked_at": "2026-10-29T21:06:23.635Z",
|
||||
"team_budget_preserved": true,
|
||||
"hosts": [
|
||||
{
|
||||
"host": "compras",
|
||||
"passed": true,
|
||||
"core_sha256": "6c130ee1dda38e4fec4c55b974b0140e857dbd8bf4beb357906826f537d3a235",
|
||||
"source_revision": "9bbb4a50fa1743e68b1968cb9a4a32341a8d8911f69afe929efa0065be1e1f9c",
|
||||
"core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9",
|
||||
"source_revision": "6b3bbd64b35fc5d53cb45828731114d713788604859509b70d3343ca6e612a01",
|
||||
"principal_minor": 1160000,
|
||||
"due_on": null,
|
||||
"days_overdue": null,
|
||||
@@ -19,12 +19,12 @@
|
||||
{
|
||||
"status": 200,
|
||||
"state": "prepared",
|
||||
"pid": 12724
|
||||
"pid": 14440
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"state": "prepared",
|
||||
"pid": 17280
|
||||
"pid": 13316
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -33,11 +33,11 @@
|
||||
"results": [
|
||||
{
|
||||
"status": 403,
|
||||
"pid": 14704
|
||||
"pid": 12108
|
||||
},
|
||||
{
|
||||
"status": 403,
|
||||
"pid": 14100
|
||||
"pid": 14640
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -47,12 +47,12 @@
|
||||
{
|
||||
"status": 200,
|
||||
"state": "recognised",
|
||||
"pid": 13020
|
||||
"pid": 7468
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"state": "recognised",
|
||||
"pid": 12336
|
||||
"pid": 26732
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -80,8 +80,8 @@
|
||||
{
|
||||
"host": "sgf",
|
||||
"passed": true,
|
||||
"core_sha256": "6c130ee1dda38e4fec4c55b974b0140e857dbd8bf4beb357906826f537d3a235",
|
||||
"source_revision": "9bbb4a50fa1743e68b1968cb9a4a32341a8d8911f69afe929efa0065be1e1f9c",
|
||||
"core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9",
|
||||
"source_revision": "6b3bbd64b35fc5d53cb45828731114d713788604859509b70d3343ca6e612a01",
|
||||
"principal_minor": 1160000,
|
||||
"due_on": null,
|
||||
"days_overdue": null,
|
||||
@@ -92,12 +92,12 @@
|
||||
{
|
||||
"status": 200,
|
||||
"state": "prepared",
|
||||
"pid": 10808
|
||||
"pid": 5220
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"state": "prepared",
|
||||
"pid": 11504
|
||||
"pid": 25768
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -106,11 +106,11 @@
|
||||
"results": [
|
||||
{
|
||||
"status": 403,
|
||||
"pid": 7596
|
||||
"pid": 13440
|
||||
},
|
||||
{
|
||||
"status": 403,
|
||||
"pid": 21800
|
||||
"pid": 25336
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -120,12 +120,12 @@
|
||||
{
|
||||
"status": 200,
|
||||
"state": "recognised",
|
||||
"pid": 4572
|
||||
"pid": 16848
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"state": "recognised",
|
||||
"pid": 11468
|
||||
"pid": 13872
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"checked_at": "2026-10-29T21:11:14.340Z",
|
||||
"kind": "HTTP and HTML only; no visual review or monetary commands",
|
||||
"results": [
|
||||
{
|
||||
"host": "compras",
|
||||
"role": "preparacao",
|
||||
"status": 200,
|
||||
"payments": 0
|
||||
},
|
||||
{
|
||||
"host": "compras",
|
||||
"role": "validacao",
|
||||
"status": 200,
|
||||
"payments": 0
|
||||
},
|
||||
{
|
||||
"host": "compras",
|
||||
"role": "consulta",
|
||||
"status": 200,
|
||||
"payments": 0
|
||||
},
|
||||
{
|
||||
"host": "sgf",
|
||||
"role": "preparacao",
|
||||
"status": 200,
|
||||
"payments": 0
|
||||
},
|
||||
{
|
||||
"host": "sgf",
|
||||
"role": "validacao",
|
||||
"status": 200,
|
||||
"payments": 0
|
||||
},
|
||||
{
|
||||
"host": "sgf",
|
||||
"role": "consulta",
|
||||
"status": 200,
|
||||
"payments": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"host": "compras",
|
||||
"hashes_before": {
|
||||
"pa_sources": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_obligations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_history_imports": null,
|
||||
"pa_imported_payments": null,
|
||||
"pa_payment_decisions": null,
|
||||
"pa_imported_sources": null,
|
||||
"permission_grants": null,
|
||||
"invoices": "de991ffdccdf629bac13a679b22e00cd703906a381c2f1a70305ef9e478d71cf",
|
||||
"invoice_items": "e7cc1cd4807f1bc273f18941f3aec37cbdd293dcbfb52185fc8da9ae36ffb52a",
|
||||
"purchase_orders": "cdaaa2eda1fa8fc629da5f8b273ef9f90d4382c843e1afe7bffd78ba1a03d371",
|
||||
"goods_receipts": "5c64459b22785eefc4a0b91ad9d440dd37fbfa247e8be334b9490c0845109dc4",
|
||||
"goods_receipt_items": "9c502cbd9c3b5ed60c06a681826e27cbd3c6e5204e6c27a55357b903fa36ac19",
|
||||
"stock_movements": "edd57e41daab547bfbc413ef0b5fad3ba0603c6c7e14a3c40ade1858fc90017a",
|
||||
"budget_entries": "fa08dd19cb433da749ce7e3cc7ba61818fe5fa3b902f9f7c75f24958747877e0",
|
||||
"budget_consumptions": "03f17be4d36a70454c986471ed407a86567e7686d2099c291d32c4bc38a1e289",
|
||||
"external_payments": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"accounts_without_credentials": "3cfae4461cbb31833631da51d47e798c6470ab5e7a4b78c4cba2d54821e17d0e",
|
||||
"existing_access": "d68857b0c06da1c7eb4c50d681f77504307c17fd8121c0a7b31001e7eaac9ac0"
|
||||
},
|
||||
"counts_before": {
|
||||
"pa_sources": 0,
|
||||
"pa_proposals": 0,
|
||||
"pa_obligations": 0,
|
||||
"pa_events": 0,
|
||||
"pa_commands": 0,
|
||||
"pa_outbox": 0,
|
||||
"invoices": 1,
|
||||
"invoice_items": 1,
|
||||
"purchase_orders": 1,
|
||||
"goods_receipts": 1,
|
||||
"goods_receipt_items": 1,
|
||||
"stock_movements": 1,
|
||||
"budget_entries": 3,
|
||||
"budget_consumptions": 1,
|
||||
"external_payments": 0,
|
||||
"users": 12
|
||||
},
|
||||
"checked_at": "2026-10-29T21:07:08+00:00",
|
||||
"preserved": true,
|
||||
"schema_counts": {
|
||||
"ps_payments": 0,
|
||||
"ps_payment_decisions": 0,
|
||||
"ps_proposals": 0,
|
||||
"ps_allocation_aliases": 0,
|
||||
"ps_allocation_decisions": 0,
|
||||
"ps_allocations": 0,
|
||||
"ps_commands": 0,
|
||||
"ps_events": 0,
|
||||
"ps_outbox": 0
|
||||
},
|
||||
"checked_after": "2026-10-29T21:10:40+00:00"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"host": "sgf",
|
||||
"hashes_before": {
|
||||
"pa_sources": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_obligations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
|
||||
"pa_history_imports": null,
|
||||
"pa_imported_payments": null,
|
||||
"pa_payment_decisions": null,
|
||||
"pa_imported_sources": "237c82de39d5f5d75b6c8d5f9e5aa81d4fd692e216eec386e27a96d9e2884261",
|
||||
"permission_grants": "00eca880a0d034c0016ae456fdc6260963ba4260ae3492e924a17833064fd9a6",
|
||||
"accounts_without_credentials": "fa565c3aad2a788a3f9f523ced5b6fd0be89ec09b190b1e1d3a57358c5546b84",
|
||||
"existing_access": "41bc65cf9459f806cc2c84c6b583debcf27835456f556ae20ba78915718620af"
|
||||
},
|
||||
"counts_before": {
|
||||
"pa_sources": 0,
|
||||
"pa_proposals": 0,
|
||||
"pa_obligations": 0,
|
||||
"pa_events": 0,
|
||||
"pa_commands": 0,
|
||||
"pa_outbox": 0,
|
||||
"pa_imported_sources": 1,
|
||||
"permission_grants": 9,
|
||||
"users": 3
|
||||
},
|
||||
"checked_at": "2026-10-29T21:07:09+00:00",
|
||||
"preserved": true,
|
||||
"schema_counts": {
|
||||
"ps_payments": 0,
|
||||
"ps_payment_decisions": 0,
|
||||
"ps_proposals": 0,
|
||||
"ps_allocation_aliases": 0,
|
||||
"ps_allocation_decisions": 0,
|
||||
"ps_allocations": 0,
|
||||
"ps_commands": 0,
|
||||
"ps_events": 0,
|
||||
"ps_outbox": 0
|
||||
},
|
||||
"checked_after": "2026-10-29T21:10:41+00:00"
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
{
|
||||
"version": "COMMON-SUPPLIER-SETTLEMENT-SIM-v1",
|
||||
"checked_at": "2026-10-29T21:08:21.464Z",
|
||||
"hosts": [
|
||||
{
|
||||
"host": "compras",
|
||||
"passed": true,
|
||||
"core_sha256": "156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93",
|
||||
"principal_minor": 1160000,
|
||||
"balance_after_first_allocation_minor": 760000,
|
||||
"due_on": null,
|
||||
"days_overdue": null,
|
||||
"multi_document_core": {
|
||||
"balances": [
|
||||
0,
|
||||
0,
|
||||
100000
|
||||
],
|
||||
"unallocated_minor": 40000,
|
||||
"foreign_supplier_refused": true,
|
||||
"other_unit_reference_refused": true,
|
||||
"kind": "core explicit synthetic source adapter; not another host invoice workflow"
|
||||
},
|
||||
"races": [
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"status": 403
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"payment_id": 1,
|
||||
"decision_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"payment_id": 1,
|
||||
"decision_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"status": 403
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"decision_id": 2,
|
||||
"replayed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"decision_id": 2,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 409,
|
||||
"message": "Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa."
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 3,
|
||||
"decision_id": 3,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": [
|
||||
"payment_confirmation_does_not_reduce_debt",
|
||||
"explicit_allocations_only",
|
||||
"new_request_keys_recover_same_fact",
|
||||
"duplicate_evidence_conflicts",
|
||||
"partial_and_excess",
|
||||
"overallocation_refused",
|
||||
"rejected_payment_or_allocation_has_no_effect",
|
||||
"separate_actor_and_person",
|
||||
"revocation_while_waiting",
|
||||
"atomic_fault_rollback_and_retry",
|
||||
"source_revision_conflict",
|
||||
"cross_company_refused",
|
||||
"cross_supplier_refused",
|
||||
"history_not_ignored",
|
||||
"immutable_confirmed_facts",
|
||||
"legacy_balance_uses_common_position",
|
||||
"unchanged_stock_budget_invoice_legacy_payments",
|
||||
"csrf_and_context",
|
||||
"logout_reentry_history",
|
||||
"durable_outbox"
|
||||
],
|
||||
"visual_review": false
|
||||
},
|
||||
{
|
||||
"host": "sgf",
|
||||
"passed": true,
|
||||
"core_sha256": "156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93",
|
||||
"principal_minor": 1160000,
|
||||
"balance_after_first_allocation_minor": 760000,
|
||||
"due_on": null,
|
||||
"days_overdue": null,
|
||||
"multi_document_core": {
|
||||
"balances": [
|
||||
0,
|
||||
0,
|
||||
100000
|
||||
],
|
||||
"unallocated_minor": 40000,
|
||||
"foreign_supplier_refused": true,
|
||||
"other_unit_reference_refused": true,
|
||||
"kind": "core explicit synthetic source adapter; not another host invoice workflow"
|
||||
},
|
||||
"races": [
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"status": 403
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"payment_id": 1,
|
||||
"decision_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"payment_id": 1,
|
||||
"decision_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 403
|
||||
},
|
||||
{
|
||||
"status": 403
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"decision_id": 2,
|
||||
"replayed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 1,
|
||||
"decision_id": 2,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"waits": 2,
|
||||
"results": [
|
||||
{
|
||||
"status": 409,
|
||||
"message": "Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa."
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"result": {
|
||||
"proposal_id": 3,
|
||||
"decision_id": 3,
|
||||
"replayed": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": [
|
||||
"payment_confirmation_does_not_reduce_debt",
|
||||
"explicit_allocations_only",
|
||||
"new_request_keys_recover_same_fact",
|
||||
"duplicate_evidence_conflicts",
|
||||
"partial_and_excess",
|
||||
"overallocation_refused",
|
||||
"rejected_payment_or_allocation_has_no_effect",
|
||||
"separate_actor_and_person",
|
||||
"revocation_while_waiting",
|
||||
"atomic_fault_rollback_and_retry",
|
||||
"source_revision_conflict",
|
||||
"cross_company_refused",
|
||||
"cross_supplier_refused",
|
||||
"history_not_ignored",
|
||||
"immutable_confirmed_facts",
|
||||
"legacy_balance_uses_common_position",
|
||||
"unchanged_stock_budget_invoice_legacy_payments",
|
||||
"csrf_and_context",
|
||||
"logout_reentry_history",
|
||||
"durable_outbox"
|
||||
],
|
||||
"visual_review": false
|
||||
}
|
||||
],
|
||||
"bank_execution": false,
|
||||
"accounting": false,
|
||||
"real_migration": false
|
||||
}
|
||||
+3
-2
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||
"build": "vite build"
|
||||
"check:navigation": "node scripts/home-navigation-check.mjs",
|
||||
"build": "node scripts/home-navigation-check.mjs && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.9",
|
||||
@@ -15,4 +16,4 @@
|
||||
"vite": "8.3.5",
|
||||
"@vitejs/plugin-react": "6.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,6 @@ require_once __DIR__.'/src/HistoricalPayments.php';
|
||||
require_once __DIR__.'/src/HistorySchema.php';
|
||||
require_once __DIR__.'/src/PaymentDecisions.php';
|
||||
require_once __DIR__.'/src/PaymentDecisionSchema.php';
|
||||
require_once __DIR__.'/src/SupplierSettlements.php';
|
||||
require_once __DIR__.'/src/SettlementSchema.php';
|
||||
require_once __DIR__.'/src/SettlementHttp.php';
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
|
||||
/** One payable per document identity. No session, money rounding, settlement or budget effects. */
|
||||
/** One payable per document identity. No session, money rounding or budget effects. */
|
||||
final class Payables
|
||||
{
|
||||
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
|
||||
@@ -160,14 +160,42 @@ final class Payables
|
||||
}
|
||||
}
|
||||
$confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0);
|
||||
$allocated = SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id);
|
||||
$balance = (int) $obligation->principal_minor - $confirmed - $allocated;
|
||||
if ($balance < 0) {
|
||||
$compatible = false;
|
||||
}
|
||||
|
||||
return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $obligation->principal_minor - $confirmed : null,
|
||||
return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? $balance : null,
|
||||
'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed,
|
||||
'allocated_payments_minor' => $allocated, 'payment_state' => ! $compatible ? 'indeterminate' : ($balance === 0 ? 'settled' : ($balance === (int) $obligation->principal_minor ? 'unpaid' : 'partial')),
|
||||
'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 settlements(): SupplierSettlements
|
||||
{
|
||||
return new SupplierSettlements($this->db, $this->actor, $this->person, $this->scope, $this->authorize, function (int $id): array {
|
||||
$o = $this->q('obligations')->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação fora do contexto.');
|
||||
$row = $this->q('sources')->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.');
|
||||
$source = $this->source((int) $row->local_source_id);
|
||||
if (self::identity($source) !== $row->source_key) {
|
||||
throw new \DomainException('Identidade documental alterada.');
|
||||
}
|
||||
$position = $this->position($o, $source);
|
||||
if ($position['source_conflict'] || $position['balance_minor'] === null) {
|
||||
throw new \DomainException('Origem ou histórico divergente; afectação suspensa.');
|
||||
}
|
||||
if ($position['history_imported']) {
|
||||
throw new \DomainException('Novos pagamentos sobre história importada exigem recorte de compatibilidade próprio; nenhum histórico ignorado.');
|
||||
}
|
||||
|
||||
return $position + ['supplier_key' => hash('sha256', self::canonical([$source['instance'], $source['company'], $source['supplier_id']])),
|
||||
'supplier_name' => $source['supplier_name']];
|
||||
});
|
||||
}
|
||||
|
||||
public function positionForSource(int $local): ?array
|
||||
{
|
||||
return $this->db->transaction(function () use ($local) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Shared presentation/controller; host supplies its existing organisation/permission adapter. */
|
||||
abstract class SettlementHttp extends Controller
|
||||
{
|
||||
protected const MODE = '';
|
||||
|
||||
protected const SERVICE = '';
|
||||
|
||||
protected function guard(): void
|
||||
{
|
||||
$db = DB::connection();
|
||||
$prefix = static::MODE === 'compras' ? 'compras_receipts_test_' : 'payable_sgf_';
|
||||
abort_unless(app()->environment('payable-testing') && $db->getDriverName() === 'pgsql' && $db->getConfig('host') === '127.0.0.1'
|
||||
&& preg_match('/^'.$prefix.'[a-f0-9]{12}$/D', $db->getDatabaseName())
|
||||
&& $db->selectOne('select current_database() as d')->d === $db->getDatabaseName(), 403, 'Pagamentos apenas no ensaio isolado.');
|
||||
foreach (SettlementSchema::TABLES as $table) {
|
||||
abort_unless($db->getSchemaBuilder()->hasTable($table), 403, 'Ensaio de pagamentos ainda não instalado nesta base.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function data(Request $r): array
|
||||
{
|
||||
$this->guard();
|
||||
|
||||
return DB::transaction(function () use ($r): array {
|
||||
$s = app(static::SERVICE);
|
||||
$user = $r->user();
|
||||
$settlements = $s->engine($user)->settlements()->listing();
|
||||
$base = $s->listing($user);
|
||||
foreach ($base['obligations'] as &$o) {
|
||||
$proposal = $base['proposals']->first(fn ($p) => (int) $p->id === (int) $o['proposal_id']);
|
||||
$o['origin_snapshot'] = $proposal ? json_decode($proposal->snapshot, true, 64, JSON_THROW_ON_ERROR) : null;
|
||||
}
|
||||
unset($o);
|
||||
$permissions = [];
|
||||
foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'] as $action) {
|
||||
$permissions[$action] = $s->can($user, $action);
|
||||
}
|
||||
|
||||
return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'],
|
||||
'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'],
|
||||
'permissions' => $permissions, 'current_actor' => $user->id];
|
||||
});
|
||||
}
|
||||
|
||||
public function index(Request $r): JsonResponse
|
||||
{
|
||||
return response()->json($this->data($r))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function page(Request $r): Response
|
||||
{
|
||||
$data = $this->data($r);
|
||||
$settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $r->user()->name,
|
||||
'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments',
|
||||
'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables'];
|
||||
|
||||
return response(Workspace::settlements($data, $settings))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function command(Request $r, string $action): JsonResponse
|
||||
{
|
||||
$this->guard();
|
||||
try {
|
||||
return response()->json(app(static::SERVICE)->engine($r->user())->settlements()->command($action, $r->all(), (string) $r->header('Idempotency-Key')));
|
||||
} catch (\DomainException $e) {
|
||||
abort(409, $e->getMessage());
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
abort(422, $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Additive schema. Called explicitly in isolated QA only, never by host migrations. */
|
||||
final class SettlementSchema
|
||||
{
|
||||
public const TABLES = ['ps_payments', 'ps_payment_decisions', 'ps_proposals', 'ps_allocation_aliases', 'ps_allocation_decisions', 'ps_allocations', 'ps_commands', 'ps_events', 'ps_outbox'];
|
||||
|
||||
public static function install(): void
|
||||
{
|
||||
$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('Settlement schema requires isolated QA.');
|
||||
}
|
||||
if (array_filter(self::TABLES, fn ($t) => Schema::hasTable($t))) {
|
||||
throw new \LogicException('Settlement schema already exists; no automatic repair.');
|
||||
}
|
||||
$scope = static function (Blueprint $t): void {
|
||||
foreach (['tenant_id', 'company_id', 'unit_id'] as $k) {
|
||||
$t->unsignedBigInteger($k);
|
||||
}
|
||||
};
|
||||
DB::transaction(function () use ($scope): void {
|
||||
Schema::create('ps_payments', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('obligation_id')->constrained('pa_obligations');
|
||||
$t->string('fact_key', 64)->unique();
|
||||
$t->string('evidence_key', 64)->unique();
|
||||
$t->string('supplier_key', 64);
|
||||
$t->string('currency', 3);
|
||||
$t->bigInteger('amount_minor');
|
||||
$t->date('paid_on');
|
||||
$t->string('fact_reference');
|
||||
$t->string('evidence_reference');
|
||||
$t->string('payload_hash', 64);
|
||||
$t->jsonb('snapshot');
|
||||
$t->unsignedBigInteger('prepared_by');
|
||||
$t->string('prepared_person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_payment_decisions', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->unique()->constrained('ps_payments');
|
||||
$t->string('decision');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('person');
|
||||
$t->string('evidence_reference');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_proposals', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->constrained('ps_payments');
|
||||
$t->string('reference_key', 64)->unique();
|
||||
$t->string('reference');
|
||||
$t->string('plan_hash', 64);
|
||||
$t->jsonb('plan');
|
||||
$t->unsignedBigInteger('prepared_by');
|
||||
$t->string('prepared_person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['payment_id', 'plan_hash']);
|
||||
});
|
||||
Schema::create('ps_allocation_decisions', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('proposal_id')->unique()->constrained('ps_proposals');
|
||||
$t->string('decision');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('person');
|
||||
$t->text('reason');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_allocation_aliases', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->string('reference_key', 64)->unique();
|
||||
$t->string('reference');
|
||||
$t->foreignId('proposal_id')->constrained('ps_proposals');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_allocations', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->constrained('ps_payments');
|
||||
$t->foreignId('obligation_id')->constrained('pa_obligations');
|
||||
$t->foreignId('decision_id')->constrained('ps_allocation_decisions');
|
||||
$t->bigInteger('amount_minor');
|
||||
$t->string('source_revision', 64);
|
||||
$t->timestamp('created_at');
|
||||
$t->unique(['payment_id', 'obligation_id']);
|
||||
});
|
||||
Schema::create('ps_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('ps_events', function (Blueprint $t) use ($scope) {
|
||||
$t->id();
|
||||
$scope($t);
|
||||
$t->foreignId('payment_id')->constrained('ps_payments');
|
||||
$t->unsignedBigInteger('actor_id');
|
||||
$t->string('person');
|
||||
$t->string('kind');
|
||||
$t->jsonb('payload');
|
||||
$t->timestamp('created_at');
|
||||
});
|
||||
Schema::create('ps_outbox', function (Blueprint $t) {
|
||||
$t->foreignId('event_id')->primary()->constrained('ps_events');
|
||||
$t->string('state');
|
||||
});
|
||||
foreach (self::TABLES as $table) {
|
||||
DB::unprepared("CREATE TRIGGER ps_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
|
||||
}
|
||||
foreach (['ps_payments', 'ps_allocations'] as $table) {
|
||||
DB::unprepared("ALTER TABLE {$table} ADD CONSTRAINT {$table}_positive CHECK (amount_minor > 0)");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace Tempo\Finance\Payables;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
/** Synthetic payment facts and allocations. No session, bank execution or accounting. */
|
||||
final class SupplierSettlements
|
||||
{
|
||||
/**
|
||||
* @param array<string,int> $scope
|
||||
* @param \Closure(string):void $authorize
|
||||
* @param \Closure(int):array<string,mixed> $obligation
|
||||
*/
|
||||
public function __construct(private ConnectionInterface $db, private int $actor, private string $person,
|
||||
private array $scope, private \Closure $authorize, private \Closure $obligation) {}
|
||||
|
||||
private function q(string $table): Builder
|
||||
{
|
||||
return $this->db->table('ps_'.$table)->where($this->scope);
|
||||
}
|
||||
|
||||
private function stamp(): string
|
||||
{
|
||||
return date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/** @param array<array-key,mixed> $v */
|
||||
private function hash(array $v): string
|
||||
{
|
||||
return hash('sha256', Payables::canonical($v));
|
||||
}
|
||||
|
||||
private function key(string $ref): string
|
||||
{
|
||||
// One payment fact per organisation, even if the request arrives through another unit.
|
||||
return $this->hash([array_intersect_key($this->scope, array_flip(['tenant_id', 'company_id'])), $ref]);
|
||||
}
|
||||
|
||||
private function referenceScope(string $table, string $column, string $key): void
|
||||
{
|
||||
$row = $this->db->table('ps_'.$table)->where($column, $key)->first(array_keys($this->scope));
|
||||
if ($row) {
|
||||
foreach ($this->scope as $field => $value) {
|
||||
if ((int) $row->$field !== $value) {
|
||||
throw new \DomainException('Referência já associada noutro contexto da organização; nenhuma duplicação permitida.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function minor(mixed $value): int
|
||||
{
|
||||
if (! is_string($value) || ! preg_match('/^(0|[1-9][0-9]{0,11})(?:\.([0-9]{1,2}))?$/D', $value, $m)) {
|
||||
throw new \InvalidArgumentException('Montante decimal positivo, com até duas casas; sem arredondamento.');
|
||||
}
|
||||
$minor = (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0');
|
||||
if ($minor < 1) {
|
||||
throw new \InvalidArgumentException('O montante deve ser positivo.');
|
||||
}
|
||||
|
||||
return $minor;
|
||||
}
|
||||
|
||||
private function reference(mixed $v): string
|
||||
{
|
||||
if (! is_string($v) || ! preg_match('/^SIM-[A-Z0-9][A-Z0-9._-]{2,95}$/D', $v)) {
|
||||
throw new \InvalidArgumentException('Referência sintética estável SIM-… obrigatória.');
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
private function reason(mixed $v): string
|
||||
{
|
||||
if (! is_string($v) || mb_strlen(trim($v)) < 3 || mb_strlen($v) > 2000) {
|
||||
throw new \InvalidArgumentException('Fundamento obrigatório, entre 3 e 2000 caracteres.');
|
||||
}
|
||||
|
||||
return trim($v);
|
||||
}
|
||||
|
||||
private function id(mixed $v): int
|
||||
{
|
||||
if (! is_int($v) || $v < 1) {
|
||||
throw new \InvalidArgumentException('Identificador inteiro positivo obrigatório.');
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param list<string> $keys
|
||||
*/
|
||||
private function exact(array $data, array $keys): void
|
||||
{
|
||||
if (array_diff(array_keys($data), $keys) || array_diff($keys, array_keys($data))) {
|
||||
throw new \InvalidArgumentException('Campos ausentes ou não permitidos.');
|
||||
}
|
||||
}
|
||||
|
||||
private function payment(int $id): \stdClass
|
||||
{
|
||||
$row = $this->q('payments')->where('id', $id)->lockForUpdate()->first();
|
||||
if (! $row instanceof \stdClass) {
|
||||
throw new \DomainException('Pagamento fora do contexto.');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function independent(\stdClass $prepared): void
|
||||
{
|
||||
if ((int) $prepared->prepared_by === $this->actor || $prepared->prepared_person === $this->person) {
|
||||
throw new \DomainException('Confirmação exige outra identidade e outra pessoa sintética.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
private function event(int $payment, string $kind, array $payload): void
|
||||
{
|
||||
$id = $this->db->table('ps_events')->insertGetId($this->scope + ['payment_id' => $payment, 'actor_id' => $this->actor,
|
||||
'person' => $this->person, 'kind' => $kind, 'payload' => Payables::canonical($payload), 'created_at' => $this->stamp()]);
|
||||
$this->db->table('ps_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_bank_or_accounting']);
|
||||
}
|
||||
|
||||
/** @param array<string,int> $scope */
|
||||
public static function allocated(ConnectionInterface $db, array $scope, int $obligation): int
|
||||
{
|
||||
if (! method_exists($db, 'getSchemaBuilder')) {
|
||||
throw new \LogicException('A ligação exige consulta explícita do esquema financeiro.');
|
||||
}
|
||||
if (! $db->getSchemaBuilder()->hasTable('ps_allocations')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor');
|
||||
}
|
||||
|
||||
private function unallocated(\stdClass $payment): int
|
||||
{
|
||||
return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @return array<string,int|bool>
|
||||
*/
|
||||
public function command(string $action, array $data, string $key): array
|
||||
{
|
||||
$permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm'];
|
||||
if (! isset($permissions[$action]) || ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $key)) {
|
||||
throw new \InvalidArgumentException('Acção ou chave inválida.');
|
||||
}
|
||||
|
||||
return $this->db->transaction(function () use ($action, $data, $key, $permissions): array {
|
||||
($this->authorize)('payments_view');
|
||||
($this->authorize)($permissions[$action]); // Host locks organisation and revalidates actor/grants before replay too.
|
||||
$hash = $this->hash([$action, $data]);
|
||||
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
|
||||
if ($old && $old->request_hash !== $hash) {
|
||||
throw new \DomainException('Chave repetida com conteúdo diferente.');
|
||||
}
|
||||
$result = match ($action) {
|
||||
'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data),
|
||||
'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($data),
|
||||
};
|
||||
if ($old && Payables::canonical(json_decode($old->result, true, 32, JSON_THROW_ON_ERROR)) !== Payables::canonical($result)) {
|
||||
throw new \DomainException('Resultado anterior incompatível; não repetir o efeito.');
|
||||
}
|
||||
if (! $old) {
|
||||
$this->db->table('ps_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key,
|
||||
'request_hash' => $hash, 'result' => Payables::canonical($result), 'created_at' => $this->stamp()]);
|
||||
}
|
||||
|
||||
return $result + ['replayed' => $old !== null];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function preparePayment(array $d): array
|
||||
{
|
||||
$this->exact($d, ['obligation_id', 'source_revision', 'amount', 'currency', 'paid_on', 'fact_reference', 'evidence_reference', 'reason']);
|
||||
$o = ($this->obligation)($this->id($d['obligation_id']));
|
||||
if ($o['source_revision'] !== $d['source_revision'] || $d['currency'] !== 'MZN' || $o['currency'] !== $d['currency']) {
|
||||
throw new \DomainException('Versão ou moeda incompatível.');
|
||||
}
|
||||
if (! is_string($d['paid_on']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D', $d['paid_on'], $date)
|
||||
|| ! checkdate((int) $date[2], (int) $date[3], (int) $date[1]) || $d['paid_on'] > date('Y-m-d')) {
|
||||
throw new \InvalidArgumentException('Data factual de pagamento válida obrigatória, sem data futura.');
|
||||
}
|
||||
$fact = $this->reference($d['fact_reference']);
|
||||
$evidence = $this->reference($d['evidence_reference']);
|
||||
$reason = $this->reason($d['reason']);
|
||||
$snapshot = ['obligation_id' => $o['id'], 'source_revision' => $o['source_revision'], 'supplier_key' => $o['supplier_key'],
|
||||
'amount_minor' => self::minor($d['amount']), 'currency' => $d['currency'], 'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'reason' => $reason, 'synthetic_only' => true];
|
||||
$hash = $this->hash($snapshot);
|
||||
$this->referenceScope('payments', 'fact_key', $this->key($fact));
|
||||
$this->referenceScope('payments', 'evidence_key', $this->key($evidence));
|
||||
$existing = $this->q('payments')->where(fn ($q) => $q->where('fact_key', $this->key($fact))->orWhere('evidence_key', $this->key($evidence)))->get();
|
||||
if ($existing->isNotEmpty()) {
|
||||
if ($existing->count() !== 1 || $existing->first()->payload_hash !== $hash) {
|
||||
throw new \DomainException('Referência ou evidência já usada com conteúdo incompatível.');
|
||||
}
|
||||
|
||||
return ['payment_id' => (int) $existing->first()->id];
|
||||
}
|
||||
$id = $this->db->table('ps_payments')->insertGetId($this->scope + ['obligation_id' => $o['id'], 'fact_key' => $this->key($fact),
|
||||
'evidence_key' => $this->key($evidence), 'supplier_key' => $o['supplier_key'], 'currency' => $d['currency'], 'amount_minor' => $snapshot['amount_minor'],
|
||||
'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'payload_hash' => $hash, 'snapshot' => Payables::canonical($snapshot),
|
||||
'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
|
||||
$this->event($id, 'supplier_payment.proposed', ['payment_id' => $id, 'amount_minor' => $snapshot['amount_minor']]);
|
||||
|
||||
return ['payment_id' => $id];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function decidePayment(array $d): array
|
||||
{
|
||||
$this->exact($d, ['payment_id', 'decision', 'evidence_reference', 'reason']);
|
||||
if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) {
|
||||
throw new \InvalidArgumentException('Decisão inválida.');
|
||||
}
|
||||
$p = $this->payment($this->id($d['payment_id']));
|
||||
$this->independent($p);
|
||||
$o = ($this->obligation)((int) $p->obligation_id);
|
||||
$snapshot = json_decode($p->snapshot, true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($o['source_revision'] !== $snapshot['source_revision'] || $o['supplier_key'] !== $p->supplier_key) {
|
||||
throw new \DomainException('Origem do pagamento alterada; revisão necessária.');
|
||||
}
|
||||
$evidence = $this->reference($d['evidence_reference']);
|
||||
$reason = $this->reason($d['reason']);
|
||||
$old = $this->q('payment_decisions')->where('payment_id', $p->id)->first();
|
||||
if ($old) {
|
||||
if ($old->decision !== $d['decision'] || $old->evidence_reference !== $evidence || $old->reason !== $reason) {
|
||||
throw new \DomainException('Decisão final já registada; correcção exige fluxo próprio.');
|
||||
}
|
||||
|
||||
return ['payment_id' => (int) $p->id, 'decision_id' => (int) $old->id];
|
||||
}
|
||||
$id = $this->db->table('ps_payment_decisions')->insertGetId($this->scope + ['payment_id' => $p->id, 'decision' => $d['decision'],
|
||||
'evidence_reference' => $evidence, 'reason' => $reason, 'actor_id' => $this->actor, 'person' => $this->person, 'created_at' => $this->stamp()]);
|
||||
$this->event((int) $p->id, 'supplier_payment.'.$d['decision'], ['payment_id' => $p->id, 'decision_id' => $id]);
|
||||
|
||||
return ['payment_id' => (int) $p->id, 'decision_id' => $id];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function prepareAllocation(array $d): array
|
||||
{
|
||||
$this->exact($d, ['payment_id', 'reference', 'allocations', 'reason']);
|
||||
$p = $this->payment($this->id($d['payment_id']));
|
||||
if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) {
|
||||
throw new \DomainException('Pagamento ainda não confirmado.');
|
||||
}
|
||||
$reference = $this->reference($d['reference']);
|
||||
$reason = $this->reason($d['reason']);
|
||||
if (! is_array($d['allocations']) || ! array_is_list($d['allocations']) || count($d['allocations']) < 1 || count($d['allocations']) > 50) {
|
||||
throw new \InvalidArgumentException('Indique entre uma e cinquenta afectações.');
|
||||
}
|
||||
$plan = [];
|
||||
foreach ($d['allocations'] as $line) {
|
||||
if (! is_array($line)) {
|
||||
throw new \InvalidArgumentException('Linha de afectação inválida.');
|
||||
}
|
||||
$this->exact($line, ['obligation_id', 'source_revision', 'amount']);
|
||||
$id = $this->id($line['obligation_id']);
|
||||
if (isset($plan[$id])) {
|
||||
throw new \InvalidArgumentException('Obrigação repetida na mesma proposta.');
|
||||
}
|
||||
$plan[$id] = ['obligation_id' => $id, 'source_revision' => $line['source_revision'], 'amount_minor' => self::minor($line['amount'])];
|
||||
}
|
||||
ksort($plan);
|
||||
$plan = array_values($plan);
|
||||
$hash = $this->hash($plan);
|
||||
$this->referenceScope('proposals', 'reference_key', $this->key($reference));
|
||||
$this->referenceScope('allocation_aliases', 'reference_key', $this->key($reference));
|
||||
// Semantic recovery precedes balance checks: an already-applied plan remains recoverable.
|
||||
$alias = $this->q('allocation_aliases')->where('reference_key', $this->key($reference))->value('proposal_id');
|
||||
$old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0)->orWhere(fn ($q) => $q->where('payment_id', $p->id)->where('plan_hash', $hash)))->get();
|
||||
if ($old->isNotEmpty()) {
|
||||
if ($old->count() !== 1 || (int) $old->first()->payment_id !== (int) $p->id || $old->first()->plan_hash !== $hash) {
|
||||
throw new \DomainException('Referência de afectação incompatível.');
|
||||
}
|
||||
$this->checkPlan($p, $plan, false);
|
||||
$this->db->table('ps_allocation_aliases')->insertOrIgnore($this->scope + ['reference_key' => $this->key($reference), 'reference' => $reference,
|
||||
'proposal_id' => $old->first()->id, 'actor_id' => $this->actor, 'created_at' => $this->stamp()]);
|
||||
|
||||
return ['proposal_id' => (int) $old->first()->id];
|
||||
}
|
||||
$this->checkPlan($p, $plan, true);
|
||||
$id = $this->db->table('ps_proposals')->insertGetId($this->scope + ['payment_id' => $p->id, 'reference_key' => $this->key($reference),
|
||||
'reference' => $reference, 'plan_hash' => $hash, 'plan' => Payables::canonical($plan), 'prepared_by' => $this->actor,
|
||||
'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
|
||||
$this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]);
|
||||
|
||||
return ['proposal_id' => $id];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $plan */
|
||||
private function checkPlan(\stdClass $p, array $plan, bool $checkBalances): void
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($plan as $line) {
|
||||
$o = ($this->obligation)($line['obligation_id']);
|
||||
if ($o['supplier_key'] !== $p->supplier_key || $o['currency'] !== $p->currency || $o['source_revision'] !== $line['source_revision']) {
|
||||
throw new \DomainException('Fornecedor, moeda ou versão incompatível na afectação.');
|
||||
}
|
||||
if ($line['amount_minor'] > PHP_INT_MAX - $total) {
|
||||
throw new \DomainException('Montante fora dos limites.');
|
||||
}
|
||||
$total += $line['amount_minor'];
|
||||
if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']])->exists())) {
|
||||
throw new \DomainException('Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa.');
|
||||
}
|
||||
}
|
||||
if ($checkBalances && $total > $this->unallocated($p)) {
|
||||
throw new \DomainException('Valor proposto superior ao pagamento ainda por afectar.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $d
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function decideAllocation(array $d): array
|
||||
{
|
||||
$this->exact($d, ['proposal_id', 'decision', 'reason']);
|
||||
if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) {
|
||||
throw new \InvalidArgumentException('Decisão inválida.');
|
||||
}
|
||||
$proposal = $this->q('proposals')->where('id', $this->id($d['proposal_id']))->first() ?? throw new \DomainException('Proposta fora do contexto.');
|
||||
$p = $this->payment((int) $proposal->payment_id);
|
||||
$this->independent($proposal);
|
||||
if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) {
|
||||
throw new \DomainException('Pagamento não confirmado.');
|
||||
}
|
||||
$reason = $this->reason($d['reason']);
|
||||
$plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR);
|
||||
$old = $this->q('allocation_decisions')->where('proposal_id', $proposal->id)->first();
|
||||
$this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed');
|
||||
if ($old) {
|
||||
if ($old->decision !== $d['decision'] || $old->reason !== $reason) {
|
||||
throw new \DomainException('Decisão de afectação já final; exige correcção própria.');
|
||||
}
|
||||
|
||||
return ['proposal_id' => (int) $proposal->id, 'decision_id' => (int) $old->id];
|
||||
}
|
||||
$id = $this->db->table('ps_allocation_decisions')->insertGetId($this->scope + ['proposal_id' => $proposal->id, 'decision' => $d['decision'],
|
||||
'actor_id' => $this->actor, 'person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]);
|
||||
if ($d['decision'] === 'confirmed') {
|
||||
foreach ($plan as $line) {
|
||||
$this->db->table('ps_allocations')->insert($this->scope + $line + ['payment_id' => $p->id, 'decision_id' => $id, 'created_at' => $this->stamp()]);
|
||||
}
|
||||
}
|
||||
$this->event((int) $p->id, 'supplier_allocation.'.$d['decision'], ['proposal_id' => $proposal->id, 'decision_id' => $id, 'plan_hash' => $proposal->plan_hash]);
|
||||
|
||||
return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function listing(): array
|
||||
{
|
||||
return $this->db->transaction(function (): array {
|
||||
($this->authorize)('payments_view');
|
||||
$payments = $this->q('payments')->orderBy('id')->get()->map(function ($p): array {
|
||||
$decision = $this->q('payment_decisions')->where('payment_id', $p->id)->first();
|
||||
|
||||
return (array) $p + ['decision' => $decision, 'state' => $decision->decision ?? 'proposed',
|
||||
'unallocated_minor' => $decision?->decision === 'confirmed' ? $this->unallocated($p) : null, 'reconciliation' => 'not_implemented'];
|
||||
})->all();
|
||||
$proposals = $this->q('proposals')->orderBy('id')->get()->map(fn ($p) => (array) $p + ['decision' => $this->q('allocation_decisions')->where('proposal_id', $p->id)->first()])->all();
|
||||
|
||||
return ['synthetic_only' => true, 'payments' => $payments, 'proposals' => $proposals, 'allocations' => $this->q('allocations')->orderBy('id')->get()->all(),
|
||||
'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,14 @@ namespace Tempo\Finance\Payables;
|
||||
|
||||
final class Workspace
|
||||
{
|
||||
public static function settlements(array $data, array $settings): string
|
||||
{
|
||||
ob_start();
|
||||
require __DIR__.'/../ui/settlements.php';
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
public static function render(array $data, array $settings): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!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><?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>
|
||||
<?php if($data['payments_available']??false): ?><p><a href="/supplier-payments">Pagamentos e afectações sintéticas — sem execução bancária</a></p><?php endif ?><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>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
$e = static fn ($v) => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$money = static fn ($v) => $v === null ? 'Indeterminado' : number_format(intdiv((int) $v, 100), 0, '.', ' ').'.'.str_pad((string) abs((int) $v % 100), 2, '0', STR_PAD_LEFT).' MZN';
|
||||
$eligible = array_values(array_filter($data['obligations'], static fn ($o) => ! $o['source_conflict'] && ! $o['history_imported']));
|
||||
$confirmed = array_values(array_filter($data['payments'], static fn ($p) => $p['state'] === 'confirmed'));
|
||||
$options = static function () use ($eligible, $e, $money): void {
|
||||
foreach ($eligible as $o) echo '<option value="'.$e($o['id']).'" data-revision="'.$e($o['source_revision']).'">#'.$e($o['id']).' · '.$e($o['origin_snapshot']['supplier_name'] ?? 'Fornecedor').' · '.$e($money($o['balance_minor'])).'</option>';
|
||||
};
|
||||
?>
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pagamentos e afectações a fornecedores — ensaio</title><style>
|
||||
body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-width:1100px;margin:auto;padding:24px}section,header{background:white;padding:24px;border:1px solid #dbe4df;border-radius:12px;margin:18px 0}h1{font-size:28px}h2{font-size:22px}p,li{line-height:1.6}label{display:block;margin:14px 0}input,textarea,select,button{font:inherit;padding:10px;max-width:100%}input,textarea,select{display:block;width:min(100%,600px);box-sizing:border-box}button{background:#125a4b;color:white;border:0;border-radius:7px;margin:6px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}.notice{background:#fff3cf;padding:16px}a{color:#125a4b}.scroll{overflow:auto}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:12px;border-bottom:1px solid #ddd}pre{white-space:pre-wrap;overflow-wrap:anywhere}.line{border:1px solid #ddd;padding:16px;margin:12px 0}#error{color:#8d251e}article{border-top:1px solid #ddd;padding:16px 0}
|
||||
</style></head><body><main>
|
||||
<header><a href="<?= $e($settings['obligations_url']) ?>">← Obrigações a fornecedores</a> · <a href="/demo">Trocar função ou sair</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Pagamentos e afectações a fornecedores</h1><p class="notice"><strong>ENSAIO SINTÉTICO — SEM MOVIMENTAÇÃO DE DINHEIRO.</strong> Propor não confirma; confirmar um pagamento não abate automaticamente uma dívida. Só uma afectação confirmada reduz o saldo. Não há execução bancária, reconciliação, stock, orçamento adicional ou lançamento contabilístico.</p><p>As referências de pagamento e evidência são artificiais e começam por SIM-. Não introduza dados bancários ou comprovativos reais.</p></header>
|
||||
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
|
||||
<section><h2>Obrigações e saldo canónico</h2><div class="scroll"><table><thead><tr><th>Origem / fornecedor</th><th>Principal</th><th>Afectado neste percurso</th><th>Saldo</th><th>Vencimento</th></tr></thead><tbody><?php foreach ($data['obligations'] as $o): ?><tr><td>#<?= $e($o['id']) ?> · <?= $e($o['origin_snapshot']['number'] ?? '') ?><br><?= $e($o['origin_snapshot']['supplier_name'] ?? '') ?><details><summary>Versão e decisão</summary><pre><?= $e(json_encode($o, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details></td><td><?= $money($o['principal_minor']) ?></td><td><?= $money($o['allocated_payments_minor']) ?></td><td><?= $money($o['balance_minor']) ?><?= $o['source_conflict'] ? ' — origem divergente' : '' ?></td><td>Pendente<br>Atraso indeterminado<br>Dias de atraso: sem valor</td></tr><?php endforeach ?></tbody></table></div><?php if (! $data['obligations']): ?><p>Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.</p><?php endif ?></section>
|
||||
<?php if ($data['permissions']['payment_prepare'] && $eligible): ?><section><h2>1. Propor pagamento sintético</h2><form data-action="payment-prepare"><label>Obrigação de referência / fornecedor<select name="obligation_id" data-origin><?php $options(); ?></select></label><input type="hidden" name="source_revision"><label>Montante do pagamento (MZN)<input name="amount" required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><input type="hidden" name="currency" value="MZN"><label>Data factual do pagamento simulado<input type="date" name="paid_on" required></label><label>Referência estável do facto<input name="fact_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-PAY-001"></label><label>Evidência artificial de origem<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-EVID-001"></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor sem afectar dívida</button></form></section><?php endif ?>
|
||||
<section><h2>2. Pagamentos propostos e decisões</h2><?php foreach ($data['payments'] as $p): ?><article><h3>Pagamento #<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?></h3><p><?= $money($p['amount_minor']) ?> · Data simulada <?= $e($p['paid_on']) ?> · Estado: <?= $e(['proposed'=>'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?></p><p>Por afectar: <?= $money($p['unallocated_minor']) ?> · Reconciliação bancária: não implementada.</p><details><summary>Preparação e evidência preservadas</summary><pre><?= $e($p['snapshot']) ?></pre><?php if ($p['decision']): ?><pre><?= $e(json_encode($p['decision'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre><?php endif ?></details>
|
||||
<?php if ($data['permissions']['payment_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="payment-decide"><input type="hidden" name="payment_id" value="<?= $e($p['id']) ?>"><label>Decisão explícita<select name="decision"><option value="confirmed">Confirmar com evidência sintética</option><option value="rejected">Rejeitar</option></select></label><label>Evidência artificial da conferência<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-CONF-001"></label><label>Fundamento da conferência<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Registar decisão independente</button></form><?php endif ?></article><?php endforeach ?><?php if (! $data['payments']): ?><p>Sem pagamentos propostos neste contexto.</p><?php endif ?></section>
|
||||
<?php if ($data['permissions']['allocation_prepare'] && $confirmed && $eligible): ?><section><h2>3. Propor afectações</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id"><?php foreach ($confirmed as $p): ?><option value="<?= $e($p['id']) ?>">#<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?> · Por afectar <?= $money($p['unallocated_minor']) ?></option><?php endforeach ?></select></label><label>Referência estável da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-ALLOC-001"></label><div data-lines><div class="line" data-line><label>Obrigação<select data-obligation><?php $options(); ?></select></label><label>Valor a afectar (MZN)<input data-amount required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><button type="button" data-remove>Remover linha</button></div></div><button type="button" data-add>Adicionar outra obrigação</button><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><p>Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.</p><button>Submeter afectações sem abater saldo</button></form></section><?php endif ?>
|
||||
<section><h2>4. Propostas de afectação</h2><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p>
|
||||
<?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?php endforeach ?></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>Correcções, reversões, reconciliação e novos pagamentos sobre documentos com histórico importado ficam para âmbito próprio. Os originais e as decisões confirmadas não são editados ou apagados.</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();
|
||||
for(const selector of document.querySelectorAll('[data-origin]')){const sync=()=>selector.form.elements.source_revision.value=selector.selectedOptions[0]?.dataset.revision??'';selector.onchange=sync;sync();}
|
||||
for(const form of document.querySelectorAll('[data-action]')){
|
||||
const add=form.querySelector('[data-add]');if(add){const lines=form.querySelector('[data-lines]');const template=lines.firstElementChild.cloneNode(true);add.onclick=()=>{if(lines.children.length<50)lines.append(template.cloneNode(true));};lines.onclick=e=>{if(e.target.matches('[data-remove]')&&lines.children.length>1)e.target.closest('[data-line]').remove();};}
|
||||
form.onsubmit=async e=>{e.preventDefault();let body=Object.fromEntries(new FormData(form));for(const key of ['obligation_id','payment_id','proposal_id'])if(key in body)body[key]=Number(body[key]);if(form.dataset.action==='allocation-prepare')body.allocations=[...form.querySelectorAll('[data-line]')].map(line=>{const select=line.querySelector('[data-obligation]');return {obligation_id:Number(select.value),source_revision:select.selectedOptions[0]?.dataset.revision??'',amount:line.querySelector('[data-amount]').value};});const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true);document.querySelector('#error').textContent='';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:signature});const d=await r.json();if(!r.ok){document.querySelector('#error').textContent=d.message??'Não foi possível concluir; consulte o histórico.';return;}location.reload();}catch{document.querySelector('#error').textContent='Não foi possível confirmar o resultado. Actualize a consulta e confira o histórico antes de repetir.';}finally{buttons.forEach(b=>b.disabled=false);}};
|
||||
}
|
||||
</script></body></html>
|
||||
@@ -8,7 +8,7 @@ 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:[{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:'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),item('supplier_payments','Pagamentos e afectações — 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:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))},
|
||||
@@ -28,7 +28,8 @@ export function navigationTopics(groups) {
|
||||
{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:'finance',label:'Financeiro',groups:['receivables','costs']},
|
||||
{id:'accounting',label:'Contabilidade',groups:['accounting']},
|
||||
{id:'settings',label:'Configuração',groups:['billing_support','admin']},
|
||||
];
|
||||
const known=new Set(layout.flatMap(t=>t.groups));
|
||||
|
||||
@@ -97,6 +97,7 @@ function App() {
|
||||
{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 === 'supplier_payments' && <section className="panel"><h1>Pagamentos e afectações a fornecedores</h1><p>Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.</p><a href="/supplier-payments">Abrir pagamentos e afectações sintéticas</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_invoice_setup' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
|
||||
@@ -28,6 +28,9 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void
|
||||
Route::post('/api/context', [IdentityController::class, 'select']);
|
||||
Route::middleware(RequireCompanyContext::class)->group(function (): void {
|
||||
Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']);
|
||||
Route::get('/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'page']);
|
||||
Route::get('/api/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'index']);
|
||||
Route::post('/api/supplier-payments/{action}', [\App\Modules\Purchasing\SupplierPaymentsController::class,'command'])->whereIn('action',['payment-prepare','payment-decide','allocation-prepare','allocation-decide']);
|
||||
Route::get('/api/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'index']);
|
||||
Route::post('/api/supplier-payables/{action}', [\App\Modules\Purchasing\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate']);
|
||||
Route::get('/api/purchasing-budget', [\App\Modules\Purchasing\BudgetController::class, 'index']);
|
||||
|
||||
@@ -26,7 +26,17 @@ try {
|
||||
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']);
|
||||
assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Contabilidade','Configuração']);
|
||||
// Independent inventory: comparing the generated menu with itself cannot detect a removed module.
|
||||
assert.deepEqual(allGroups.map(g=>g.id).sort(),['start','purchasing','stock','services','billing','billing_support','receivables','workshop','costs','accounting','admin'].sort());
|
||||
const accountingTopic=topics.find(t=>t.id==='accounting');
|
||||
assert.deepEqual(accountingTopic.sections.map(g=>g.id),['accounting']);
|
||||
assert.deepEqual(accountingTopic.sections[0].items.map(i=>i.id),['accounting_panel','accounting_daily','accounting_review','accounting_analysis','accounting_closing','accounting_configuration','catalogue_accounting','service_fiscal']);
|
||||
for(const grants of [[['company.view',null]],[['catalogue_accounting.view',null]],[['service_fiscal.view',null]]]) {
|
||||
const authorised=navigationTopics(check(grants).groups);
|
||||
assert(authorised.some(t=>t.id==='accounting'&&t.label==='Contabilidade'));
|
||||
}
|
||||
assert(!navigationTopics(noAccess.groups).some(t=>t.id==='accounting'));
|
||||
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:()=>{}}));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Existing synthetic host only: entry/session/logout and served assets; no business commands.
|
||||
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 base='http://127.0.0.1:8002';
|
||||
const vite=await createServer({server:{middlewareMode:true},appType:'custom'});
|
||||
try{
|
||||
const {navigationGroups,navigationTopics,default:Navigation}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx');
|
||||
const {serviceAreas}=await vite.ssrLoadModule('/resources/js/ServicesWorkspace.jsx');
|
||||
const registry=new Set(Object.keys(serviceAreas));
|
||||
const source=await fs.readFile('resources/js/app.jsx','utf8');
|
||||
for(const m of source.matchAll(/page\s*===\s*['"]([^'"]+)['"]/g))registry.add(m[1]);
|
||||
for(const [file,field] of [['billing-menu.js','billingSections'],['workshop-menu.js','workshopSections'],['stock-menu.js','stockSections'],['accounting-menu.js','accountingSections']]){
|
||||
const module=await vite.ssrLoadModule('/resources/js/'+file);for(const item of module[field])registry.add(item.id);
|
||||
}
|
||||
const manifest=JSON.parse(await fs.readFile('public/build/manifest.json','utf8'))['resources/js/app.jsx'];
|
||||
const root=await fetch(base+'/',{signal:AbortSignal.timeout(15000),headers:{Connection:'close'}});assert.equal(root.status,200);
|
||||
assert((await root.text()).includes(manifest.file));
|
||||
const asset=await fetch(base+'/build/'+manifest.file,{signal:AbortSignal.timeout(15000),headers:{Connection:'close'}});assert.equal(asset.status,200);
|
||||
assert((await asset.text()).includes('purchasing_home'));
|
||||
const results=[];
|
||||
for(const role of ['proprietario','preparador','validador','tecnico','verificador']){
|
||||
const cookies=new Map();
|
||||
async function request(path,method='GET',body){
|
||||
const r=await fetch(base+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));}
|
||||
return {status:r.status,text:await r.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,_token:csrf})).status,302,role+' entry');
|
||||
const session=JSON.parse((await request('/api/session')).text);assert(session.user&&session.context);
|
||||
const can=(p,u=null)=>session.context.grants.some(g=>g.permission===p&&g.unit_id===u);
|
||||
const groups=navigationGroups(can,session.context.unit?.id??null),topics=navigationTopics(groups);
|
||||
const destinations=groups.flatMap(g=>g.items.map(i=>i.id));
|
||||
for(const id of destinations)assert(registry.has(id),role+' unregistered destination '+id);
|
||||
assert.equal(new Set(destinations).size,destinations.length);
|
||||
assert(topics.some(t=>t.id==='purchasing'),role+' purchasing visible');
|
||||
assert(topics.some(t=>t.id==='stock'),role+' stock separately visible');
|
||||
assert(topics.some(t=>t.id==='accounting'&&t.label==='Contabilidade'),role+' accounting separately visible');
|
||||
const html=renderToStaticMarkup(React.createElement(Navigation,{groups,page:'purchasing_home',navigate:()=>{},user:{name:'Conta sintética',email:'avaliacao@example.test'},busy:false,logout:()=>{}}));
|
||||
assert(html.includes('topic-purchasing'));assert(html.includes('topic-stock'));assert(html.includes('topic-accounting'));assert(html.includes('Área de Compras'));
|
||||
const logout=await fetch(base+'/api/logout',{signal:AbortSignal.timeout(15000),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);
|
||||
results.push({role,topics:topics.map(t=>t.label),destinations:destinations.length,purchasing_visible:true,stock_separate:true,accounting_separate:true,all_destinations_registered:true});
|
||||
}
|
||||
await fs.writeFile('docs/proofs/navigation-live.json',JSON.stringify({checked_at:new Date().toISOString(),host:base,current_bundle_served:true,results,business_commands:0,accounts_created:0,permissions_changed:0,type:'HTTP/session plus static render; not interactive browser review',visual_review:false},null,2)+'\n');
|
||||
console.log('Live navigation passed for five existing synthetic roles: Compras and Stock separate, all destinations registered, current bundle served. No business commands or access changes.');
|
||||
}finally{await vite.close();}
|
||||
@@ -1,4 +1,4 @@
|
||||
param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly)
|
||||
param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly,[switch]$Settlements)
|
||||
$ErrorActionPreference='Stop'
|
||||
$payRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
if($payRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path'}
|
||||
@@ -31,7 +31,7 @@ try{
|
||||
if(!$payProof){@{pid=$payProcess.Id;started=$payProcess.StartTime.ToUniversalTime().ToString('o');executable=$payWebPhp;port=$payPort}|ConvertTo-Json|Set-Content -LiteralPath "$payRun\payables-$payMode.json" -Encoding UTF8}
|
||||
$payReady=$false;for($payAttempt=0;$payAttempt -lt 30;$payAttempt++){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){$payReady=$true;break};Start-Sleep -Milliseconds 100};if(!$payReady){throw 'Payables server failed to start'}
|
||||
}
|
||||
if($payProof){Push-Location $payRoot;try{if($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}}
|
||||
if($payProof){Push-Location $payRoot;try{if($Settlements){foreach($settlementMode in @('compras','sgf')){& $payPhp scripts/settlements-install.php $settlementMode proof;if($LASTEXITCODE){throw 'Settlement schema failed'}};& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/settlements-http-proof.mjs}elseif($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}}
|
||||
else{Write-Output 'Synthetic payables: Compras http://127.0.0.1:8005/obrigacoes-fornecedores ; SGF http://127.0.0.1:8006. Budget demonstration preserved.'}
|
||||
}finally{
|
||||
Remove-Item Env:PAYABLE_SETTINGS_FILE -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const source=await fs.readFile('scripts/settlements-http-proof.mjs','utf8');
|
||||
const definition=source.slice(source.indexOf(' function client()'),source.indexOf(' const p=client()'));
|
||||
const results=[];
|
||||
for(const mode of ['compras','sgf']) {
|
||||
const base=`http://127.0.0.1:${mode==='compras'?8005:8006}`;
|
||||
const client=eval('('+definition.trim().replace(/^function client/, 'function')+')');
|
||||
for(const role of ['preparacao','validacao','consulta']) {
|
||||
const c=client();await c.login(role);
|
||||
const html=await c.request('/supplier-payments');assert.equal(html.status,200);
|
||||
const data=await c.list();assert.equal(data.payments.length,0);
|
||||
const old=await c.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert.equal(old.status,200);assert.ok(old.text.includes('/supplier-payments'));
|
||||
results.push({host:mode,role,status:html.status,payments:data.payments.length});
|
||||
await c.request('/logout','POST',{});
|
||||
}
|
||||
}
|
||||
await fs.writeFile('docs/proofs/supplier-settlements-demo-access.json',JSON.stringify({checked_at:new Date().toISOString(),kind:'HTTP and HTML only; no visual review or monetary commands',results},null,2)+'\n');
|
||||
console.log('Both existing demonstrations: all three roles, payments page/API and obligation link passed; no monetary commands.');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
// Hash-only preservation check of existing synthetic demonstration data. No credential columns.
|
||||
$phase = $argv[1] ?? '';
|
||||
if (! in_array($phase, ['before', 'after'], true)) throw new LogicException('Choose before/after.');
|
||||
if (! isset($argv[2])) {
|
||||
foreach (['compras', 'sgf'] as $mode) {
|
||||
$php = $mode === 'compras' ? 'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe' : 'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
|
||||
passthru(escapeshellarg($php).' '.escapeshellarg(__FILE__).' '.$phase.' '.$mode, $status);
|
||||
if ($status) exit($status);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
$mode = $argv[2]; if (! in_array($mode, ['compras', 'sgf'], true)) throw new LogicException('Unknown synthetic host.');
|
||||
$s = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach ($s as $k => $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; }
|
||||
require $s['HOST_ROOT'].'/vendor/autoload.php'; $app = require $s['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\Payables;
|
||||
$db = DB::connection();
|
||||
if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->getConfig('host') !== '127.0.0.1'
|
||||
|| $db->selectOne('select current_database() as d')->d !== $s['DB_DATABASE']) throw new LogicException('Synthetic database guard.');
|
||||
$newActions = ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'];
|
||||
$tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_commands', 'pa_outbox', 'pa_history_imports', 'pa_imported_payments', 'pa_payment_decisions', 'pa_imported_sources', 'permission_grants'];
|
||||
if ($mode === 'compras') $tables = [...$tables, 'invoices', 'invoice_items', 'purchase_orders', 'goods_receipts', 'goods_receipt_items', 'stock_movements', 'budget_entries', 'budget_consumptions', 'external_payments'];
|
||||
$hashes = []; $counts = [];
|
||||
DB::transaction(function () use ($db, $tables, $newActions, &$hashes, &$counts): void {
|
||||
$db->statement('SET TRANSACTION READ ONLY');
|
||||
foreach ($tables as $table) {
|
||||
if (! $db->getSchemaBuilder()->hasTable($table)) { $hashes[$table] = null; continue; }
|
||||
$rows = $db->table($table)->orderBy($table === 'pa_outbox' ? 'event_id' : 'id')->get()->map(fn ($r) => (array) $r)->all();
|
||||
$hashes[$table] = hash('sha256', Payables::canonical($rows)); $counts[$table] = count($rows);
|
||||
}
|
||||
$users = $db->table('users')->orderBy('id')->get(['id', 'name', 'email'])->map(fn ($r) => (array) $r)->all();
|
||||
$hashes['accounts_without_credentials'] = hash('sha256', Payables::canonical($users)); $counts['users'] = count($users);
|
||||
$access = $db->table('pa_access')->whereNotIn('action', $newActions)->orderBy('id')->get()->map(fn ($r) => (array) $r)->all();
|
||||
$hashes['existing_access'] = hash('sha256', Payables::canonical($access));
|
||||
});
|
||||
$path = __DIR__.'/../docs/proofs/supplier-settlements-demo-'.$mode.'.json';
|
||||
if ($phase === 'before') {
|
||||
file_put_contents($path, json_encode(['host' => $mode, 'hashes_before' => $hashes, 'counts_before' => $counts, 'checked_at' => gmdate('c')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo $mode." synthetic baseline recorded.\n";
|
||||
} else {
|
||||
$before = json_decode(file_get_contents($path), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($before['hashes_before'] !== $hashes || $before['counts_before'] !== $counts) throw new LogicException('Existing synthetic data changed; investigate without reset.');
|
||||
$ps = []; foreach (\Tempo\Finance\Payables\SettlementSchema::TABLES as $t) $ps[$t] = $db->table($t)->count();
|
||||
$result = $before + ['preserved' => true, 'schema_counts' => $ps, 'checked_after' => gmdate('c')];
|
||||
file_put_contents($path, json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo $mode." original documents, history, accounts and access preserved; synthetic settlements installed.\n";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {spawn} from 'node:child_process';
|
||||
|
||||
async function probe(mode,job,input={},legacy=false){
|
||||
return new Promise((resolve,reject)=>{
|
||||
const php=mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe';
|
||||
const p=spawn(php,[legacy?'scripts/payables-probe.php':'scripts/settlements-probe.php',mode,job],{cwd:process.cwd(),windowsHide:true});let out='',err='';
|
||||
p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw Error('Synthetic probe failed '+job+': '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input));
|
||||
});
|
||||
}
|
||||
const hosts=[];
|
||||
for(const mode of ['compras','sgf']){
|
||||
console.log('Checking supplier settlements in '+mode+'…');
|
||||
const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-proof-${mode}-access.json`,'utf8'));
|
||||
const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`;
|
||||
function client(){const cookies=new Map();let csrf='',context='';return {
|
||||
async request(path,method='GET',body,opts={}){
|
||||
const r=await fetch(base+path,{signal:AbortSignal.timeout(20000),method,redirect:'manual',headers:{Connection:'close',Accept:'application/json','Content-Type':opts.form?'application/x-www-form-urlencoded':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':opts.noCsrf?'':csrf,'X-XSRF-TOKEN':opts.noCsrf?'':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':opts.stale?'stale':context,...(opts.key?{'Idempotency-Key':opts.key}:{})},...(body===undefined?{}:{body:opts.form?new URLSearchParams(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{}
|
||||
csrf=text.match(/name="_token" value="([^"]+)"/)?.[1]??data.csrf??csrf;if(data.context?.token)context=data.context.token;
|
||||
return {status:r.status,data,text};
|
||||
},
|
||||
async login(role){assert.equal((await this.request('/demo')).status,200);assert.equal((await this.request('/demo/entrar','POST',{role,_token:csrf},{form:true})).status,302);await this.request('/demo');if(mode==='sgf')await this.request('/api/session');},
|
||||
async list(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200,r.text.slice(0,200));return r.data;},
|
||||
async send(action,body,status=200,key=randomUUID(),extra={}){const r=await this.request('/api/supplier-payments/'+action,'POST',body,{key,...extra});assert.equal(r.status,status,mode+' '+action+' '+r.text.slice(0,250));return r.data;},
|
||||
};}
|
||||
const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta');
|
||||
assert.equal((await client().request('/api/supplier-payments')).status,401);
|
||||
const before=await probe(mode,'inspect',{},true);
|
||||
const proposal=await p.request('/api/supplier-payables/prepare','POST',{id:fixture.source_id,reason:'Synthetic payable source'},{key:randomUUID()});assert.equal(proposal.status,200);
|
||||
const recognised=await v.request('/api/supplier-payables/validate','POST',{id:proposal.data.proposal_id,reason:'Independent synthetic recognition'},{key:randomUUID()});assert.equal(recognised.status,200);
|
||||
let data=await q.list();const o=data.obligations[0];assert.equal(o.principal_minor,1160000);assert.equal(o.balance_minor,1160000);
|
||||
const origin=JSON.parse((await q.request('/api/supplier-payables')).text).documents[0];assert.equal(origin.subtotal_minor,1000000);assert.equal(origin.tax_minor,160000);assert.equal(origin.total_minor,1160000);
|
||||
const today=new Date().toISOString().slice(0,10);
|
||||
const paymentBody={obligation_id:o.id,source_revision:o.source_revision,amount:'4000.00',currency:'MZN',paid_on:today,fact_reference:'SIM-PAY-001',evidence_reference:'SIM-EVID-001',reason:'Synthetic payment fact; no bank execution'};
|
||||
const key=randomUUID(),first=await p.send('payment-prepare',paymentBody,200,key);
|
||||
assert.equal((await p.send('payment-prepare',paymentBody,200,key)).replayed,true);
|
||||
assert.equal((await p.send('payment-prepare',paymentBody)).payment_id,first.payment_id);
|
||||
await p.send('payment-prepare',{...paymentBody,amount:'4000.01'},409);
|
||||
await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-OTHER-REF'},409);
|
||||
await p.send('payment-prepare',{...paymentBody,reason:'Changed key content'},409,key);
|
||||
await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-ZERO',evidence_reference:'SIM-ZERO-EVID',amount:'0.00'},422);
|
||||
await p.send('payment-prepare',{...paymentBody,amount:'4000.001'},422);
|
||||
await p.send('payment-prepare',{...paymentBody,currency:'USD'},409);
|
||||
const incomplete={...paymentBody};delete incomplete.paid_on;await p.send('payment-prepare',incomplete,422);
|
||||
await q.send('payment-prepare',paymentBody,403);
|
||||
const decision={payment_id:first.payment_id,decision:'confirmed',evidence_reference:'SIM-CONF-001',reason:'Independent synthetic evidence review'};
|
||||
await probe(mode,'grant',{role:'preparacao',action:'payment_confirm',enabled:true});await p.send('payment-decide',decision,409);await probe(mode,'grant',{role:'preparacao',action:'payment_confirm',enabled:false});
|
||||
await probe(mode,'person',{same:true});await v.send('payment-decide',decision,409);await probe(mode,'person',{same:false});
|
||||
const revoked=await probe(mode,'race',{role:'validacao',action:'payment-decide',body:decision,revoke:'payment_confirm'});assert.equal(revoked.waits,2);assert.deepEqual(revoked.results.map(r=>r.status),[403,403]);
|
||||
await probe(mode,'grant',{role:'validacao',action:'payment_confirm',enabled:true});
|
||||
const paymentRace=await probe(mode,'race',{role:'validacao',action:'payment-decide',body:decision});assert.deepEqual(paymentRace.results.map(r=>r.status),[200,200]);assert.equal(paymentRace.results[0].result.decision_id,paymentRace.results[1].result.decision_id);
|
||||
data=await q.list();assert.equal(data.obligations[0].balance_minor,1160000);assert.equal(data.payments[0].state,'confirmed');assert.equal(data.payments[0].unallocated_minor,400000);
|
||||
await v.send('payment-decide',{...decision,decision:'rejected'},409);
|
||||
const plan={payment_id:first.payment_id,reference:'SIM-ALLOC-001',reason:'Explicit partial allocation',allocations:[{obligation_id:o.id,source_revision:o.source_revision,amount:'4000.00'}]};
|
||||
const planRace=await probe(mode,'race',{role:'preparacao',action:'allocation-prepare',body:plan});assert.deepEqual(planRace.results.map(r=>r.status),[200,200]);const proposalId=planRace.results[0].result.proposal_id;assert.equal(planRace.results[1].result.proposal_id,proposalId);
|
||||
await q.send('allocation-prepare',plan,403);
|
||||
const allocationDecision={proposal_id:proposalId,decision:'confirmed',reason:'Independent partial allocation review'};
|
||||
const allocationRevoke=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:allocationDecision,revoke:'allocation_confirm'});assert.deepEqual(allocationRevoke.results.map(r=>r.status),[403,403]);
|
||||
await probe(mode,'grant',{role:'validacao',action:'allocation_confirm',enabled:true});
|
||||
const failedKey=randomUUID(),rollbackBefore=await probe(mode,'inspect');
|
||||
await probe(mode,'fault',{enabled:true});await v.send('allocation-decide',allocationDecision,500,failedKey);await probe(mode,'fault',{enabled:false});
|
||||
const rollbackAfter=await probe(mode,'inspect');assert.deepEqual(rollbackAfter.counts,rollbackBefore.counts);
|
||||
const allocationRace=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:allocationDecision});assert.deepEqual(allocationRace.results.map(r=>r.status),[200,200]);assert.equal(allocationRace.results[0].result.decision_id,allocationRace.results[1].result.decision_id);
|
||||
await v.send('allocation-decide',allocationDecision,200,failedKey);assert.equal((await v.send('allocation-decide',allocationDecision,200,failedKey)).replayed,true);
|
||||
assert.equal((await p.send('allocation-prepare',{...plan,reference:'SIM-ALIAS-001'})).proposal_id,proposalId);
|
||||
await p.send('allocation-prepare',{...plan,reference:'SIM-ALIAS-001',allocations:[{...plan.allocations[0],amount:'3999.00'}]},409);
|
||||
data=await q.list();assert.equal(data.allocations.length,1);assert.equal(data.obligations[0].balance_minor,760000);assert.equal(data.obligations[0].principal_minor,1160000);assert.equal(data.obligations[0].payment_state,'partial');assert.equal(data.payments[0].unallocated_minor,0);
|
||||
assert.equal(data.obligations[0].due_on,null);assert.equal(data.obligations[0].days_overdue,null);
|
||||
const afterFirst=await probe(mode,'inspect',{},true);assert.deepEqual(afterFirst.domain_hashes,before.domain_hashes);if(mode==='compras')assert.equal(afterFirst.legacy_balance,760000);
|
||||
const html=await q.request('/supplier-payments');assert.equal(html.status,200);assert(html.text.includes('7 600.00 MZN'));assert(!html.text.includes('<form data-action='));assert(html.text.includes('Dias de atraso: sem valor'));
|
||||
const prepHtml=await p.request('/supplier-payments');assert(prepHtml.text.includes('data-action="payment-prepare"'));assert(prepHtml.text.includes('data-action="allocation-prepare"'));
|
||||
assert.equal((await probe(mode,'foreign',{body:decision})).refused,true);
|
||||
const immutable=await probe(mode,'immutable');assert.equal(immutable.refused,3);
|
||||
const more=[];
|
||||
for(const [ref,amount] of [['SIM-PAY-002','9000.00'],['SIM-PAY-003','5000.00']]){
|
||||
const prepared=await p.send('payment-prepare',{...paymentBody,fact_reference:ref,evidence_reference:ref+'-EVID',amount});more.push(prepared.payment_id);
|
||||
await v.send('payment-decide',{payment_id:prepared.payment_id,decision:'confirmed',evidence_reference:ref+'-CONF',reason:'Independent synthetic confirmation'});
|
||||
}
|
||||
await p.send('allocation-prepare',{...plan,payment_id:more[0],reference:'SIM-EXCESS-PLAN',allocations:[{...plan.allocations[0],amount:'8000.00'}]},409);
|
||||
const competing=[];
|
||||
for(let i=0;i<2;i++){const r=await p.send('allocation-prepare',{...plan,payment_id:more[i],reference:'SIM-COMPETING-'+i,allocations:[{...plan.allocations[0],amount:'5000.00'}]});competing.push({role:'validacao',action:'allocation-decide',body:{proposal_id:r.proposal_id,decision:'confirmed',reason:'Concurrent limited balance'}});}
|
||||
const overRace=await probe(mode,'race',{commands:competing});assert.equal(overRace.waits,2);assert.deepEqual(overRace.results.map(r=>r.status).sort(),[200,409]);
|
||||
data=await q.list();assert.equal(data.obligations[0].balance_minor,260000);assert.equal(data.allocations.length,2);assert(data.payments.some(r=>r.unallocated_minor>0));
|
||||
const losing=overRace.results.findIndex(r=>r.status===409);
|
||||
const rejectedPlan={...competing[losing].body,decision:'rejected',reason:'Balance changed; reject the unapplied proposal'};
|
||||
await v.send('allocation-decide',rejectedPlan);await v.send('allocation-decide',competing[losing].body,409);
|
||||
const rejectedPayment=await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-PAY-REJECT',evidence_reference:'SIM-PAY-REJECT-EVID',amount:'100.00'});
|
||||
const reject={payment_id:rejectedPayment.payment_id,decision:'rejected',evidence_reference:'SIM-REJECT-CONF',reason:'Evidence does not sustain confirmation'};
|
||||
const rejection=await v.send('payment-decide',reject);assert.equal((await v.send('payment-decide',reject)).decision_id,rejection.decision_id);
|
||||
await p.send('allocation-prepare',{...plan,payment_id:rejectedPayment.payment_id,reference:'SIM-REJECTED-ALLOC'},409);
|
||||
data=await q.list();assert.equal(data.obligations[0].balance_minor,260000);assert.equal(data.payments.find(r=>r.id===rejectedPayment.payment_id).unallocated_minor,null);
|
||||
await probe(mode,'source',{override:{total_minor:1160001}},true);await v.send('payment-decide',decision,409);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source',{},true);
|
||||
const multi=await probe(mode,'multi');assert.deepEqual(multi.balances,[0,0,100000]);assert.equal(multi.unallocated_minor,40000);assert.equal(multi.foreign_supplier_refused,true);assert.equal(multi.other_unit_reference_refused,true);
|
||||
const counts=await probe(mode,'inspect');assert.equal(counts.counts.ps_events,counts.counts.ps_outbox);
|
||||
const effectsBeforeHistory=await probe(mode,'inspect');await probe(mode,'history',{},true);
|
||||
await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-HISTORY-REFUSED',evidence_reference:'SIM-HISTORY-EVID'},409);
|
||||
assert.deepEqual((await probe(mode,'inspect')).counts,effectsBeforeHistory.counts);
|
||||
await v.send('payment-decide',decision,419,randomUUID(),{noCsrf:true});if(mode==='sgf')await v.send('payment-decide',decision,409,randomUUID(),{stale:true});
|
||||
const fresh=client();await fresh.login('consulta');const recovered=await fresh.list();assert.equal(recovered.payments.length,4);assert.equal(recovered.allocations.length,2);assert.equal(recovered.obligations[0].balance_minor,null);
|
||||
hosts.push({host:mode,passed:true,core_sha256:counts.core_sha256,principal_minor:1160000,balance_after_first_allocation_minor:760000,due_on:null,days_overdue:null,multi_document_core:multi,
|
||||
races:[revoked,paymentRace,planRace,allocationRevoke,allocationRace,overRace],checks:['payment_confirmation_does_not_reduce_debt','explicit_allocations_only','new_request_keys_recover_same_fact','duplicate_evidence_conflicts','partial_and_excess','overallocation_refused','rejected_payment_or_allocation_has_no_effect','separate_actor_and_person','revocation_while_waiting','atomic_fault_rollback_and_retry','source_revision_conflict','cross_company_refused','cross_supplier_refused','history_not_ignored','immutable_confirmed_facts','legacy_balance_uses_common_position','unchanged_stock_budget_invoice_legacy_payments','csrf_and_context','logout_reentry_history','durable_outbox'],visual_review:false});
|
||||
}
|
||||
assert.equal(hosts[0].core_sha256,hosts[1].core_sha256);
|
||||
await fs.writeFile('docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n');
|
||||
console.log('Both hosts passed: explicit supplier settlements, canonical balance, duplicate/concurrent requests, failure recovery and preserved source/stock/budget/history.');
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
// Explicit additive installation into the two named synthetic hosts; no real settings changed.
|
||||
$mode = $argv[1] ?? ''; $tag = $argv[2] ?? '';
|
||||
if (! in_array($mode, ['compras', 'sgf'], true) || ! in_array($tag, ['proof', 'demo'], true)) throw new LogicException('Named synthetic host required.');
|
||||
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-'.$tag.'-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach ($settings as $k => $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; }
|
||||
require $settings['HOST_ROOT'].'/vendor/autoload.php';
|
||||
$app = require $settings['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Finance\Payables\SettlementSchema;
|
||||
$db = DB::connection();
|
||||
if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->getConfig('host') !== '127.0.0.1'
|
||||
|| $db->selectOne('select current_database() as d')->d !== $settings['DB_DATABASE'] || ! app()->environment('payable-testing')) throw new LogicException('Synthetic database guard refused.');
|
||||
$role = $db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user');
|
||||
if ($role->rolsuper || $role->rolcreatedb || $role->rolcreaterole) throw new LogicException('Restricted QA role required.');
|
||||
$existing = array_filter(SettlementSchema::TABLES, fn ($t) => $db->getSchemaBuilder()->hasTable($t));
|
||||
if ($existing && count($existing) !== count(SettlementSchema::TABLES)) throw new LogicException('Incomplete schema, nothing repaired.');
|
||||
if (! $existing) SettlementSchema::install();
|
||||
$fixture = json_decode(file_get_contents($settings['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR);
|
||||
$grants = 0;
|
||||
DB::transaction(function () use ($fixture, &$grants): void {
|
||||
foreach (['preparacao' => ['payments_view', 'payment_prepare', 'allocation_prepare'], 'validacao' => ['payments_view', 'payment_confirm', 'allocation_confirm'], 'consulta' => ['payments_view']] as $role => $actions) {
|
||||
$account = $fixture['accounts'][$role];
|
||||
$user = \App\Models\User::findOrFail($account['id']);
|
||||
if ($user->email !== $role.'-obrigacoes@example.test') throw new LogicException('Existing synthetic identity required; no accounts created.');
|
||||
$person = DB::table('pa_access')->where($fixture['scope'])->where(['user_id' => $user->id, 'action' => 'view'])->value('person');
|
||||
if ($person !== 'synthetic-person-'.$role) throw new LogicException('Synthetic person mapping differs.');
|
||||
foreach ($actions as $action) $grants += DB::table('pa_access')->insertOrIgnore($fixture['scope'] + ['user_id' => $user->id, 'person' => $person, 'action' => $action]);
|
||||
}
|
||||
});
|
||||
echo json_encode(['host' => $mode, 'isolated' => true, 'schema_tables' => count(SettlementSchema::TABLES), 'synthetic_grants_added' => $grants,
|
||||
'accounts_created' => 0, 'documents_changed' => false, 'payments_created' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
// Adversarial operations exclusively in disposable proof databases, never in team demos.
|
||||
$mode = $argv[1] ?? ''; $job = $argv[2] ?? '';
|
||||
if (! in_array($mode, ['compras', 'sgf'], true)) throw new LogicException('Unknown proof host.');
|
||||
$private = 'C:/laragon/data/sgf-tempo/secrets/';
|
||||
$s = json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
$team = json_decode(file_get_contents($private.'payables-demo-'.$mode.'.json'), true, 32, JSON_THROW_ON_ERROR);
|
||||
if ($s['DB_DATABASE'] === $team['DB_DATABASE']) throw new LogicException('Team demonstration protected.');
|
||||
foreach ($s as $k => $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; }
|
||||
require $s['HOST_ROOT'].'/vendor/autoload.php'; $app = require $s['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tempo\Finance\Payables\{Payables, SupplierSettlements, SettlementSchema};
|
||||
$db = DB::connection();
|
||||
if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->selectOne('select current_database() as d')->d !== $s['DB_DATABASE']) throw new LogicException('QA target refused.');
|
||||
$f = json_decode(file_get_contents($s['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR);
|
||||
$input = json_decode(stream_get_contents(STDIN) ?: '{}', true, 64, JSON_THROW_ON_ERROR);
|
||||
$class = $mode === 'compras' ? \App\Services\SupplierPayables::class : \App\Modules\Purchasing\SupplierPayables::class;
|
||||
$select = function (string $role) use ($f, $mode) {
|
||||
$u = \App\Models\User::findOrFail($f['accounts'][$role]['id']); auth()->guard()->setUser($u);
|
||||
if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $f['scope']['tenant_id'], $f['scope']['company_id'], $f['scope']['unit_id']);
|
||||
return $u;
|
||||
};
|
||||
if ($job === 'inspect') {
|
||||
$counts = []; foreach (SettlementSchema::TABLES as $table) $counts[$table] = $db->table($table)->count();
|
||||
$out = ['counts' => $counts, 'allocation_events' => $db->table('ps_events')->where('kind', 'supplier_allocation.confirmed')->count(),
|
||||
'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php')];
|
||||
} elseif ($job === 'worker') {
|
||||
$u = $select($input['role']); $db->select('select set_config(?, ?, false)', ['application_name', $input['label']]);
|
||||
try { $out = ['status' => 200, 'result' => app($class)->engine($u)->settlements()->command($input['action'], $input['body'], $input['key'])]; }
|
||||
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $out = ['status' => $e->getStatusCode()]; }
|
||||
catch (\DomainException $e) { $out = ['status' => 409, 'message' => $e->getMessage()]; }
|
||||
catch (\InvalidArgumentException $e) { $out = ['status' => 422]; }
|
||||
} elseif ($job === 'race') {
|
||||
$db->beginTransaction(); $db->table($mode === 'compras' ? 'tenants' : 'companies')->where('id', $f['scope'][$mode === 'compras' ? 'tenant_id' : 'company_id'])->lockForUpdate()->firstOrFail();
|
||||
$processes = [];
|
||||
try {
|
||||
foreach ([0, 1] as $i) {
|
||||
$command = $input['commands'][$i] ?? $input;
|
||||
$p = new Process([PHP_BINARY, __FILE__, $mode, 'worker'], $s['HOST_ROOT']);
|
||||
$p->setInput(json_encode($command + ['key' => \Illuminate\Support\Str::uuid()->toString(), 'label' => 'ps_proof_'.$i], JSON_THROW_ON_ERROR));
|
||||
$p->setTimeout(35); $p->start(); $processes[] = $p;
|
||||
}
|
||||
config(['database.connections.ps_monitor' => $db->getConfig()]); $monitor = DB::connection('ps_monitor'); $deadline = microtime(true) + 18; $waits = 0;
|
||||
do {
|
||||
$waits = (int) $monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in ('ps_proof_0','ps_proof_1') and wait_event_type='Lock'", [$db->getDatabaseName()])->n;
|
||||
if ($waits === 2) break;
|
||||
foreach ($processes as $p) if (! $p->isRunning()) throw new RuntimeException('Worker ended before overlap: '.$p->getOutput().$p->getErrorOutput());
|
||||
usleep(20000);
|
||||
} while (microtime(true) < $deadline);
|
||||
if ($waits !== 2) throw new RuntimeException('Two lock waits required.');
|
||||
if (isset($input['revoke'])) $db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => $input['revoke']])->delete();
|
||||
$db->commit(); $results = [];
|
||||
foreach ($processes as $p) { $p->wait(); if (! $p->isSuccessful()) throw new RuntimeException($p->getOutput().$p->getErrorOutput()); $results[] = json_decode($p->getOutput(), true, 32, JSON_THROW_ON_ERROR); }
|
||||
$out = ['waits' => $waits, 'results' => $results];
|
||||
} finally { if ($db->transactionLevel()) $db->rollBack(); foreach ($processes as $p) if ($p->isRunning()) $p->stop(); }
|
||||
} elseif ($job === 'fault') {
|
||||
if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='supplier_allocation.confirmed' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()");
|
||||
else $db->unprepared('DROP TRIGGER ps_qa_fault ON ps_events; DROP FUNCTION ps_qa_fault()');
|
||||
$out = ['configured' => true];
|
||||
} elseif ($job === 'grant') {
|
||||
if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'], true)) throw new LogicException('Invalid QA action.');
|
||||
$role = $input['role']; $match = $f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'action' => $input['action']];
|
||||
if ($input['enabled']) $db->table('pa_access')->insertOrIgnore($match + ['person' => 'synthetic-person-'.$role]); else $db->table('pa_access')->where($match)->delete();
|
||||
$out = ['changed' => true];
|
||||
} elseif ($job === 'person') {
|
||||
$db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => 'view'])->update(['person' => $input['same'] ? 'synthetic-person-preparacao' : 'synthetic-person-validacao']);
|
||||
$out = ['changed' => true];
|
||||
} elseif ($job === 'immutable') {
|
||||
$refused = 0;
|
||||
foreach (['ps_payments', 'ps_payment_decisions', 'ps_allocations'] as $table) {
|
||||
try { DB::transaction(fn () => $db->table($table)->where('id', $db->table($table)->min('id'))->delete()); }
|
||||
catch (\Illuminate\Database\QueryException) { $refused++; }
|
||||
}
|
||||
$out = ['refused' => $refused];
|
||||
} elseif ($job === 'foreign') {
|
||||
$now = now(); $tenant = $db->table('tenants')->insertGetId(['name' => 'Outro contexto sintético', 'created_at' => $now, 'updated_at' => $now]);
|
||||
if ($mode === 'compras') {
|
||||
$u = \App\Models\User::factory()->create(['tenant_id' => $tenant, 'purchasing_role' => 'finance']); $scope = ['tenant_id' => $tenant, 'company_id' => 0, 'unit_id' => 0];
|
||||
} else {
|
||||
$company = $db->table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Outra empresa sintética', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$unit = $db->table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Outra unidade', 'created_at' => $now, 'updated_at' => $now]);
|
||||
$scope = ['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit];
|
||||
$u = \App\Models\User::create(['name' => 'Outro actor sintético', 'email' => 'foreign-settlement@example.test', 'password' => bin2hex(random_bytes(20))]);
|
||||
$db->table('company_memberships')->insert(['user_id' => $u->id, 'tenant_id' => $tenant, 'company_id' => $company]);
|
||||
foreach (['company.view' => null, 'unit.view' => $unit, 'supplier_payables.view' => $unit] as $permission => $which) $db->table('permission_grants')->insert(['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $which, 'permission' => $permission, 'user_id' => $u->id, 'created_at' => $now, 'updated_at' => $now]);
|
||||
}
|
||||
foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']);
|
||||
auth()->guard()->setUser($u); if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $tenant, $company, $unit);
|
||||
try { app($class)->engine($u)->settlements()->command('payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; }
|
||||
catch (\DomainException) { $out = ['refused' => true]; }
|
||||
} elseif ($job === 'multi') {
|
||||
// Core contract test with explicit synthetic source adapter, distinct from the real host source proof.
|
||||
$base = json_decode(file_get_contents($s['PAYABLE_BUNDLE']), true, 64, JSON_THROW_ON_ERROR);
|
||||
$scope = ['tenant_id' => 900001, 'company_id' => 900002, 'unit_id' => 900003]; $sources = [];
|
||||
foreach ([1 => 1160000, 2 => 500000, 3 => 100000] as $id => $minor) {
|
||||
$sources[$id] = array_replace($base, ['instance' => 'synthetic-core-multi', 'company' => 900002, 'document' => $id, 'local_id' => $id,
|
||||
'number' => 'SIM-CORE-'.$id, 'subtotal_minor' => $minor, 'tax_minor' => 0, 'total_minor' => $minor, 'supplier_id' => $id === 3 ? 999 : $base['supplier_id'],
|
||||
'lines' => [['id' => $id, 'order_line' => $id, 'quantity_milli' => 1000, 'subtotal_minor' => $minor, 'tax_minor' => 0, 'snapshot' => ['synthetic_core_fixture' => true]]]]);
|
||||
}
|
||||
$sources[4] = array_replace($sources[1], ['document' => 4, 'local_id' => 4, 'number' => 'SIM-CORE-UNIT-4']);
|
||||
$engine = static function ($actor, ?array $otherScope = null) use ($db, $scope, $sources): Payables {
|
||||
return new Payables($db, $actor, 'core-person-'.$actor, $otherScope ?? $scope, static function ($action) use ($actor) {
|
||||
$allowed = $actor === 1 ? ['view', 'prepare', 'payments_view', 'payment_prepare', 'allocation_prepare'] : ['view', 'validate', 'payments_view', 'payment_confirm', 'allocation_confirm'];
|
||||
if (! in_array($action, $allowed, true)) throw new DomainException('Core role refused.');
|
||||
}, static fn ($id) => $sources[$id] ?? throw new DomainException('Unknown core source.'));
|
||||
};
|
||||
$p = $engine(1); $v = $engine(2); $uuid = static fn () => \Illuminate\Support\Str::uuid()->toString();
|
||||
$obs = [];
|
||||
foreach ([1, 2, 3] as $id) { $proposal = $p->command('prepare', $id, 'Core synthetic source', $uuid()); $obs[$id] = $v->command('validate', $proposal['proposal_id'], 'Independent core recognition', $uuid())['obligation_id']; }
|
||||
$pay = static function ($ref, $amount) use ($p, $v, $obs, $sources, $uuid) {
|
||||
$r = $p->settlements()->command('payment-prepare', ['obligation_id' => $obs[1], 'source_revision' => Payables::revision($sources[1]), 'amount' => $amount, 'currency' => 'MZN', 'paid_on' => date('Y-m-d'), 'fact_reference' => $ref, 'evidence_reference' => $ref.'-EVID', 'reason' => 'Core synthetic payment'], $uuid());
|
||||
$v->settlements()->command('payment-decide', ['payment_id' => $r['payment_id'], 'decision' => 'confirmed', 'evidence_reference' => $ref.'-CONF', 'reason' => 'Independent core confirmation'], $uuid()); return $r['payment_id'];
|
||||
};
|
||||
$payment = $pay('SIM-MULTI-001', '14000.00');
|
||||
$plan = ['payment_id' => $payment, 'reference' => 'SIM-MULTI-ALLOC-001', 'reason' => 'Explicit multi-document allocation', 'allocations' => [
|
||||
['obligation_id' => $obs[1], 'source_revision' => Payables::revision($sources[1]), 'amount' => '11600.00'],
|
||||
['obligation_id' => $obs[2], 'source_revision' => Payables::revision($sources[2]), 'amount' => '2400.00']]];
|
||||
$proposal = $p->settlements()->command('allocation-prepare', $plan, $uuid());
|
||||
$v->settlements()->command('allocation-decide', ['proposal_id' => $proposal['proposal_id'], 'decision' => 'confirmed', 'reason' => 'Independent multi allocation'], $uuid());
|
||||
$payment2 = $pay('SIM-MULTI-002', '3000.00');
|
||||
$proposal2 = $p->settlements()->command('allocation-prepare', ['payment_id' => $payment2, 'reference' => 'SIM-MULTI-ALLOC-002', 'reason' => 'Second payment allocation', 'allocations' => [['obligation_id' => $obs[2], 'source_revision' => Payables::revision($sources[2]), 'amount' => '2600.00']]], $uuid());
|
||||
$v->settlements()->command('allocation-decide', ['proposal_id' => $proposal2['proposal_id'], 'decision' => 'confirmed', 'reason' => 'Independent second allocation'], $uuid());
|
||||
$foreign = false;
|
||||
try { $p->settlements()->command('allocation-prepare', ['payment_id' => $payment2, 'reference' => 'SIM-MULTI-FOREIGN', 'reason' => 'Foreign supplier refusal', 'allocations' => [['obligation_id' => $obs[3], 'source_revision' => Payables::revision($sources[3]), 'amount' => '100.00']]], $uuid()); } catch (DomainException) { $foreign = true; }
|
||||
$positions = $p->listing([1, 2, 3])['obligations']; $payments = $p->settlements()->listing()['payments'];
|
||||
$otherScope = array_replace($scope, ['unit_id' => 900004]); $op = $engine(1, $otherScope); $ov = $engine(2, $otherScope);
|
||||
$otherProposal = $op->command('prepare', 4, 'Other unit source', $uuid());
|
||||
$otherObligation = $ov->command('validate', $otherProposal['proposal_id'], 'Independent other unit recognition', $uuid())['obligation_id'];
|
||||
$otherUnitRefused = false;
|
||||
try { $op->settlements()->command('payment-prepare', ['obligation_id' => $otherObligation, 'source_revision' => Payables::revision($sources[4]), 'amount' => '14000.00', 'currency' => 'MZN', 'paid_on' => date('Y-m-d'), 'fact_reference' => 'SIM-MULTI-001', 'evidence_reference' => 'SIM-OTHER-UNIT-EVID', 'reason' => 'Duplicate through another unit'], $uuid()); }
|
||||
catch (DomainException) { $otherUnitRefused = true; }
|
||||
$out = ['balances' => array_column($positions, 'balance_minor'), 'unallocated_minor' => $payments[1]['unallocated_minor'], 'foreign_supplier_refused' => $foreign,
|
||||
'other_unit_reference_refused' => $otherUnitRefused,
|
||||
'kind' => 'core explicit synthetic source adapter; not another host invoice workflow'];
|
||||
} else throw new LogicException('Unknown probe job.');
|
||||
echo json_encode($out, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
Reference in New Issue
Block a user