Accept technical extraction of shared budget core with QA limits

This commit is contained in:
2026-10-29 13:25:05 +02:00
parent d071bc03d3
commit f77900e198
15 changed files with 1220 additions and 0 deletions
+2
View File
@@ -1,5 +1,7 @@
# SGF Tempo — instruções locais
- Aceitação expressa de 29/10/2026: `COMMON-BUDGET-SYNTHETIC-v1` aceite como extracção técnica do núcleo comum e checkpoint exclusivamente local autorizado nos dois repositórios. Ver `docs/COMMON_BUDGET_ACCEPTANCE.md`. Preservar revisão visual de Compras pendente, SGF apenas pelo adaptador QA sem interface deste percurso e cobertura dos dois testes MFA pendente. Automação do navegador não bloqueia desenvolvimento. Próximo passo: percurso sintético integrado no SGF para a equipa, reutilizando o núcleo. Sem utilização real, migração/concessão real, push/publicação ou avanço para obrigações/pagamentos/contabilidade por arrasto.
- Autorização específica posterior de 28/10/2026 executada: apenas `110000` aplicada em `sgf_tempo_dev`, por caminho exclusivo com `sgf_dev_owner`, e sete concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `industrial_budgets.view/prepare`, `industrial_budget_comparisons.view/prepare`; conta #2 `industrial_budgets.view/validate`, `industrial_budget_comparisons.view`. Ver actualização de `docs/INDUSTRIAL_BUDGET_DELIVERY.md`. Preservadas 81 tabelas anteriores, acessos e instalação `120000`; seis tabelas novas vazias. Aplicação mantém `sgf_dev_app`; orçamento/conversão/oficinas/custos/recebimentos/emissão reais bloqueados, guardas SQL verificadas. Indicações anteriores de `110000`/sete concessões por aplicar são históricas. Sem utilização real ou publicação.
- Autorização específica posterior de 28/10/2026 executada: apenas `120000` aplicada em `sgf_tempo_dev`, por caminho exclusivo com `sgf_dev_owner`, e seis concessões auditadas no tenant #1 / Tempo Lda #1 / SEGI #1: conta #1 `workshop_billing.view/prepare/generate`; conta #2 `workshop_billing.view/review` e `workshop.view`. Ver actualização de `docs/WORKSHOP_BILLING_DELIVERY.md`. Preservadas 74 tabelas anteriores e acessos; sete tabelas novas vazias. Aplicação mantém `sgf_dev_app`; conversão/oficinas/custos/recebimentos/emissão reais bloqueados e guardas SQL verificadas. `110000` e sete concessões do orçamento permanecem pendentes; não aplicar por arrasto. Consulta de oficina da conta #2 não concede apontamento/verificação/supervisão. Indicações anteriores de `120000`/seis concessões por aplicar são históricas; sem utilização real ou publicação.
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Modules\Purchasing;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Tempo\Finance\Budget\BudgetContext;
use Tempo\Finance\Budget\BudgetControl;
/** SGF composition adapter; synthetic-only until schema/rights are explicitly deployed. */
final class BudgetAdapter
{
public static function guard(): void
{
abort_unless(app()->environment('budget-testing'), 403, 'Controlo orçamental comum disponível apenas no ensaio isolado.');
$c = DB::connection();
$name = $c->getDatabaseName();
if ($c->getDriverName() !== 'pgsql' || ! preg_match('/^budget_core_sgf_[a-f0-9]{12}$/D', $name)
|| (string) $c->getConfig('host') !== '127.0.0.1' || ! empty($c->getConfig('url'))
|| $c->selectOne('select current_database() as db')->db !== $name) {
throw new \LogicException('COMMON_BUDGET_QA_DATABASE_REFUSED');
}
}
public function for(User $user): BudgetControl
{
self::guard();
$selection = app(CompanyContext::class);
abort_unless(auth()->id() === $user->id && $selection->userId() === $user->id && $selection->unitId() !== null, 403);
$scope = $selection->identifiers() + ['unit_id' => $selection->unitId()];
$context = new BudgetContext($user->id, $scope, (int) now()->year, 'MZN');
return new BudgetControl(DB::connection(), $context, 'cb_',
function (string $action, BudgetContext $ctx): void {
self::guard();
$s = $ctx->scope;
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
DB::table('users')->where('id', $ctx->actorId)->lockForUpdate()->firstOrFail();
abort_unless(auth()->id() === $ctx->actorId, 403);
$current = app(CompanyContext::class);
abort_unless($current->identifiers() === array_intersect_key($s, array_flip(['tenant_id', 'company_id'])) && $current->unitId() === $s['unit_id'], 409, 'O contexto mudou.');
$current->selectFor($ctx->actorId, $s['tenant_id'], $s['company_id'], $s['unit_id']);
app(Permissions::class)->require($ctx->actorId, $s['tenant_id'], $s['company_id'], 'purchasing_budget.'.$action, $s['unit_id']);
},
function (string $action, array $data, ?int $origin, BudgetContext $ctx): void {
DB::table('cb_audit')->insert($ctx->scope + ['actor_id' => $ctx->actorId, 'action' => $action, 'origin_id' => $origin, 'payload' => json_encode($data, JSON_THROW_ON_ERROR), 'created_at' => now()]);
},
function (int $author, BudgetContext $ctx): bool {
$s = $ctx->scope;
DB::table('users')->where('id', $author)->lockForUpdate()->firstOrFail();
return DB::table('company_memberships')->where(['user_id' => $author, 'tenant_id' => $s['tenant_id'], 'company_id' => $s['company_id']])->exists()
&& app(Permissions::class)->has($author, $s['tenant_id'], $s['company_id'], 'purchasing_budget.reinforcement.propose', $s['unit_id']);
},
);
}
/** A mapped procurement reference, never an industrial lot or a department inferred as a unit. */
public function record(User $user, int $origin, string $kind, int $amount = 0, ?int $subtotal = null): ?array
{
self::guard();
return DB::transaction(function () use ($user, $origin, $kind, $amount, $subtotal) {
$engine = $this->for($user);
$source = DB::table('cb_origins')->where($engine->context->scope)->where('id', $origin)->first();
abort_unless($source, 403, 'Origem de compras não mapeada neste contexto.');
return $engine->record($origin, (int) $source->cost_center_id, $source->required_kind, $kind, $amount, $subtotal);
});
}
public function create(User $user, array $data): int
{
self::guard();
$engine = $this->for($user);
abort_unless(DB::table('cb_centres')->where($engine->context->scope)->where('id', $data['cost_center_id'])->exists(), 403, 'Centro não mapeado neste contexto.');
return $engine->create($data);
}
}
+1
View File
@@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
require_once base_path('packages/budget-control/bootstrap.php');
$this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
}
+21
View File
@@ -0,0 +1,21 @@
# Aceitação técnica — núcleo orçamental comum
29/10/2026. Decisão expressa da Tempo nesta sessão: aceitar `COMMON-BUDGET-SYNTHETIC-v1` **como extracção técnica do núcleo comum**, com checkpoint exclusivamente local nos dois repositórios. Referências: [entrega técnica](COMMON_BUDGET_DELIVERY.md), [plano](PURCHASING_COMMON_CORE_PLAN.md) e [provas dos dois hosts](proofs/common-budget-hosts.json).
A aceitação abrange a fonte única, os adaptadores locais, equivalência da sequência orçamental, protecções e provas técnicas descritas na entrega. Não significa aceitação visual, instalação de Compras integral no SGF ou autorização operacional real.
## Pendências preservadas
1. **Revisão visual de Compras pendente.** A tentativa de Computer Use foi interrompida por não ser possível determinar o URL do navegador com segurança. Não houve demonstração visual concluída dos valores/efeitos. Esta limitação de automação não bloqueia o desenvolvimento autorizado.
2. **SGF demonstrado apenas pelo adaptador em QA.** Não existe interface deste percurso orçamental no SGF; prova de bootstrap/adaptador não equivale a revisão visual ou percurso integral disponível à equipa.
3. **Cobertura MFA pendente em Compras.** Os testes ignorados são `users with two factor enabled are redirected to two factor challenge` (`tests/Feature/Auth/AuthenticationTest.php`) e `security page is displayed` (`tests/Feature/Settings/SecurityTest.php`). Ambos dependem de `Features::twoFactorAuthentication()`, desactivada na configuração actual. Não retiram a evidência das regras orçamentais, autorização/isolamento e concorrência testadas; não demonstram o fluxo de autenticação/página de segurança com MFA activo. MFA não foi activado nesta aceitação.
Compras conserva a interface e percursos existentes, agora delegando os efeitos orçamentais no núcleo comum; isso não autoriza utilização real nem supera os limites operacionais anteriores. SGF conserva o adaptador bloqueado fora do ambiente/base QA e o orçamento industrial separado.
## Checkpoint e continuação
Autoriza-se preservar apenas o incremento e os seus documentos/provas em Git local, sem credenciais, dados privados, push ou publicação. Alterações anteriores de outros incrementos permanecem na árvore de trabalho e não são incluídas por arrasto.
O próximo passo é disponibilizar um percurso sintético integrado no SGF para a equipa testar, reutilizando este núcleo, mantendo os direitos/contextos sintéticos e a separação entre demonstração e utilização real. Esta aceitação não activa migrações, concessões, dados de cliente, obrigações, pagamentos, contabilidade ou emissão real. A implementação desse percurso não foi realizada nesta passagem de encerramento técnico.
As menções anteriores a aceitação/checkpoint pendentes conservam-se como histórico e são substituídas apenas no âmbito desta extracção técnica. Revisão visual e cobertura MFA mantêm-se abertas.
+100
View File
@@ -0,0 +1,100 @@
# Controlo orçamental comum — entrega técnica em verificação
**Actualização posterior de 29/10/2026:** extracção técnica aceite pela Tempo, com autorização de checkpoint exclusivamente local. Ver [aceitação e pendências preservadas](COMMON_BUDGET_ACCEPTANCE.md). A revisão visual de Compras continua pendente; SGF continua demonstrado apenas pelo adaptador QA, sem interface deste percurso; os dois testes MFA ignorados continuam pendentes. As menções abaixo a aceitação/checkpoint por realizar são históricas; não há activação operacional decorrente desta aceitação.
29/10/2026. `COMMON-BUDGET-SYNTHETIC-v1`. Implementação do primeiro incremento de [desenho e transição](PURCHASING_COMMON_CORE_PLAN.md). **Sem aceitação presumida ou checkpoint de encerramento.** Apenas provas isoladas; nenhuma migração, concessão ou operação nas bases de desenvolvimento/produção. Não houve publicação ou push.
## Resultado e fronteira
Existe uma fonte única em `packages/budget-control`: `Tempo\Finance\Budget\BudgetControl`, contexto explícito `BudgetContext` e erro de domínio `BudgetFailure`. Foram retirados os algoritmos de movimentos/reforços/consumo de Compras; os percursos anteriores delegam neste núcleo. O núcleo grava dotação, aprovação, reforço, reserva, ajuste de adjudicação, compromisso, libertação permitida e consumo orçamental. Saldo/total aprovado também são calculados pelo núcleo.
Não chama `auth()`, sessão ou contexto global de uma aplicação, nem depende de Sales, recebimentos, contas a pagar, pagamentos, contabilidade ou de um SGF remoto. Usa `Illuminate\Database\ConnectionInterface` já disponibilizada pelos hosts, contexto imutável, tabelas por prefixo e callbacks locais de autorização/bloqueio/auditoria. A autorização é revalidada antes de qualquer replay; a auditoria participa na transacção do efeito.
O primeiro consumidor é Compras, nos seus percursos efectivos. O segundo é a composição reduzida de QA do SGF: bootstrap Laravel real, modelos de identidade/contexto/permissões reais e adaptador `app/Modules/Purchasing/BudgetAdapter.php`. O adaptador SGF recusa funcionar fora de `budget-testing` e de uma base descartável `budget_core_sgf_<12 hex>`, PostgreSQL local verificado. **Não se declara instalado o módulo completo de Compras no SGF, nova interface visual ou circuito de procurement SGF completo.** A origem SGF é uma referência sintética mapeada para testar o primeiro efeito comum; não é uma obrigação ou factura operacional.
## Dependências e valores
- Mantidos manifests/lockfiles: Laravel 13.39.0 / Brick Math 1.0.0 de Compras e Laravel 13.38.0 / Brick Math 0.14.8 do SGF. Nenhuma instalação, actualização ou substituição de dependências.
- O núcleo usa os inteiros em centavos já gravados por Compras. Não calcula IVA, não converte câmbio e não introduz float. MZN é o recorte suportado; o adaptador Compras verifica a moeda empresarial antes/depois do bloqueio. Outras moedas permanecem explicitamente não suportadas.
- Conversão decimal de entrada continua no Brick Math da aplicação Compras, exactamente pelos caminhos anteriores. Quantidades em milésimos, arredondamento HalfUp das linhas e cálculo cumulativo de facturas parciais permanecem em `InvoiceWorkflow`/serviços anteriores. Política SGF de truncagem do IVA de vendas não é aplicada a compras.
- Não se recalcularam documentos nem se reescreveram valores históricos. O total inicial permanece imutável no percurso normal; apenas reforços aprovados contam para a dotação total.
- A chave histórica `(requisition_id, kind)` continua a identificar o efeito. Replay idêntico não grava; o núcleo reconstrói o alvo monetário da entrada histórica para recusar replay com montante diferente, sem exigir hash ou migração retroactiva. Esta recusa é um reforço explícito: antes, o percurso de movimentos devolvia silenciosamente o efeito existente mesmo quando o montante pedido diferia. Não altera a história nem cria nova versão de documentos.
## Adaptadores e responsabilidade
| Ponto | Papel nesta entrega |
| --- | --- |
| Compras `app/Services/CommonBudget.php` | Confere sessão/actor, empresa, moeda e perfil actual; bloqueia empresa/actor; traduz erros; liga auditoria e elegibilidade do proponente |
| Compras `BudgetWorkflow.php` | Conserva contrato dos chamadores, exigência de transacção exterior, vínculo da requisição e classificação corrente/investimento; delega o efeito |
| Compras `BudgetReinforcementWorkflow.php` | Conserva conversão decimal/normalização de referência e retorno Eloquent; delega proposta/decisão |
| Compras `BudgetController.php` | Conserva interface/validação/autorizações de origem; delega dotação, aprovação e saldos |
| Compras `InvoiceWorkflow.php` | Conserva conferência de factura/preço/IVA/quantidades, actor distinto e auditoria `invoice.validated`; delega apenas consumo orçamental na mesma transacção |
| Compras `Budget.php` | Mantém modelo/imutabilidade e usa a soma inteira comum do total aprovado |
| SGF `app/Modules/Purchasing/BudgetAdapter.php` | Confere actor autenticado e contexto seleccionado, revalida membership/acções por unidade dentro do bloqueio e usa origens/centros explicitamente mapeados de QA |
| `BudgetControl` | Único escritor do ledger/dotação/reforços dentro do recorte; não substitui elegibilidade de procurement ou conferência do documento |
Compras continua a tratar o seu `tenant_id` como empresa. SGF usa separadamente tenant/empresa/unidade; o teste cria identificadores diferentes e um centro de compras próprio. Nenhum departamento ou lote industrial é convertido implicitamente em unidade/dotação. O orçamento industrial existente do SGF não foi alterado.
## Fonte única e distribuição autónoma
O provider SGF carrega `packages/budget-control/bootstrap.php`. Compras procura o artefacto instalado em `packages/budget-control`, ou o caminho explícito `config/common-budget.php` / `COMMON_BUDGET_PATH`. Só em ambiente não produtivo existe fallback para a fonte canónica do checkout SGF vizinho. Não há chamadas remotas.
A prova autónoma empacota temporariamente apenas bootstrap e os três ficheiros de produção do núcleo, fora de ambos os projectos, e arranca Compras usando esse artefacto destacado. O teste exige que a classe efectivamente carregada venha do artefacto. O SGF carrega a fonte canónica. Os SHA-256 e traços normalizados coincidem. O artefacto temporário é eliminado no final, não constitui uma segunda fonte mantida. Para distribuição comercial, o artefacto local versionado terá de acompanhar Compras; não basta enviar o checkout sem o pacote. Automatização completa de releases/licenciamento não está incluída.
## Provas executadas
Comando dos dois hosts, a partir de Compras:
```powershell
& 'C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe' tests/Concurrency/run-common-budget.php
```
O coordenador usa uma ligação administrativa à base de manutenção `postgres` exclusivamente para criar/eliminar duas bases novas. Não consulta ou migra bases de negócio. Em Compras aplica as migrações existentes apenas a `compras_receipts_test_<12 hex>` e usa as fixtures sintéticas existentes; no SGF aplica a fundação de identidade e o schema de QA apenas a `budget_core_sgf_<12 hex>`. Bases eliminadas no final. Configurações privadas são consumidas em processo, sem imprimir/versionar credenciais. Não se fez comparação de dados activos nem se reivindica checksum desses dados.
Evidência dirigida: [dois hosts e concorrência](proofs/common-budget-hosts.json). O relatório inclui hash do núcleo realmente carregado, igualdade dos traços, bases descartadas e ausência de revisão visual.
| Sequência, em ambos os hosts | Resultado em centavos |
| --- | --- |
| Dotação aprovada 100000; reserva 30000 repetida | Reservado 30000; disponível 70000 |
| Ajuste da adjudicação para 35000 repetido | Reservado 35000; disponível 65000 |
| Compromisso 35000 repetido | Reservado 0; comprometido 35000; disponível 65000 |
| Consumo parcial 10000 repetido | Comprometido 25000; consumido 10000; disponível 65000 |
| Reforço 20000 proposto | Disponível permanece 65000 |
| Aprovação distinta do reforço repetida | Dotação total 120000; disponível 85000 |
Também passaram, nos dois hosts: recusa de aprovação própria; conflito de montante em replay; recusa de libertação já comprometida; isolamento de dotação entre empresas; libertação antes de compromisso uma única vez; base sem IVA usando subtotal original; falha SQL injectada na auditoria com rollback da reserva e retry sem duplicação; replay de decisão com permissão entretanto revogada. No SGF: origem de outra empresa recusada, unidade reservada recusada e contexto mudado após obter o serviço recusado.
**Concorrência PostgreSQL: oito cenários aprovados, quatro por host.** Duas ligações/processos PHP independentes, com dois waits de bloqueio observados em cada cenário: duas reservas acima do saldo conjunto; repetição da mesma reserva; repetição do mesmo compromisso; repetição da mesma libertação. No excesso apenas um efeito de 6000 cabe na dotação 10000; nos replays ambos os pedidos obtêm sucesso e o saldo só muda uma vez. Não é uma prova de todas as corridas possíveis ou teste de carga.
## Regressões e compilação
- Referência antes da alteração: 75 testes dirigidos / 317 asserções aprovados. Mesma bateria após extracção: 75 / 317, sem mudar expectativas existentes.
- Regressão completa Compras: **498 aprovados, 2 ignorados, 4141 asserções**, incluindo percursos/autorização existentes. Os dois ignorados são as limitações MFA anteriormente documentadas; não se declarou cobertura nova desses casos.
- PHPStan Compras: zero erros. Pint dos ficheiros de produção alterados: aprovado. Sintaxe PHP 8.4 do pacote/adaptador/fixtures SGF: aprovada.
- Compilação Compras: `npm.cmd run build`, aprovada; compilação SGF: `scripts/sgf.ps1 npm run build`, aprovada. Sem alterações de dependências; geração Wayfinder ocorreu pelo build habitual.
- A suite destrutiva SGF normal não foi executada sobre `sgf_tempo_test`, para preservar a demonstração da equipa. O delta SGF foi verificado no host/bootstrap real com base descartável e nas verificações acima. Não se atribui regressão integral de todos os módulos SGF a esta prova.
- Uma tentativa inicial com outro PHP existente falhou por falta do driver SQLite, sem asserções ou alteração de base. A bateria válida usou o executável já instalado com SQLite. O primeiro comando `npm` foi recusado pela política PowerShell; usou-se `npm.cmd`, sem mudar a política.
## Ficheiros deste incremento
**Fonte comum no SGF:** `packages/budget-control/bootstrap.php`, `README.md`, `src/BudgetContext.php`, `src/BudgetFailure.php`, `src/BudgetControl.php`; provas reproduzíveis em `tests/host-support.php`, `tests/run-host.php`, `tests/sgf-schema.php` dentro do mesmo pacote.
**SGF:** `app/Providers/AppServiceProvider.php`, `app/Modules/Purchasing/BudgetAdapter.php`, este relatório, `docs/proofs/common-budget-hosts.json` e referências nos documentos/instruções existentes.
**Compras:** `app/Providers/AppServiceProvider.php`, `config/common-budget.php`, `app/Services/CommonBudget.php`, `BudgetWorkflow.php`, `BudgetReinforcementWorkflow.php`, `InvoiceWorkflow.php`, `app/Models/Budget.php`, `app/Http/Controllers/BudgetController.php`, `tests/Concurrency/run-common-budget.php` e referências nos relatórios existentes.
Alterações anteriores de oficinas/navegação, fontes e histórico SGF e documentação prévia de Compras foram preservadas, sem commit, reset ou checkout. Não são atribuídas a este incremento.
## Migrações para desenvolvimento/produção — separadas e não executadas
**Compras:** o adaptador conserva as tabelas/colunas/índices existentes; esta extracção não necessita de migração SQL para os efeitos existentes. Não foram executadas migrações nessas bases. A distribuição tem de incluir o pacote e conservar os seus originais e lockfiles.
**SGF:** o único schema executado é a fixture `packages/budget-control/tests/sgf-schema.php`, que tem guarda QA e não está registada em `database/migrations`. Documenta `cb_centres`, `cb_origins`, `cb_budgets`, `cb_budget_entries`, `cb_budget_consumptions`, `cb_budget_reinforcements`, `cb_audit` e as sete acções `purchasing_budget.*`, mais a extensão da constraint de permissões na base nova. Nenhuma concessão real foi instalada.
A futura migração SGF terá de adaptar essas referências aos cadastros definitivos de Purchasing/Platform, completar constraints compostas de pertença e políticas de imutabilidade, conferir schema/permissões de destino e ser ensaiada com backup/restauro. Não apresentar esta fixture como migração operacional pronta nem retirar a guarda para apontar para `sgf_tempo_dev`. Não fundir tabelas/IDs/históricos existentes por igualdade numérica.
## Limitações e próximos passos
Revisão visual não realizada; nenhum ecrã novo apresentado como aceite. SGF demonstra o efeito orçamental do primeiro incremento, não Compras integral instalado. O pacote não inclui obrigações, pagamentos ou contabilidade, emissão real, câmbio, novas reversões ou cancelamento de ordens já emitidas. A revogação/concessão de direitos reais permanece fora desta entrega.
Próximo passo dentro desta entrega: revisão do desenho/código/provas e dificuldades que a equipa identificar, sem presumir aceitação. Depois, preparar o empacotamento e os pontos de montagem do módulo completo nos dois produtos com os cadastros definitivos. T2 (obrigações), T3 (pagamentos) e restantes passos do plano não foram iniciados. As capacidades inexistentes continuam pendentes, sem motores fictícios ou reaproveitamento indevido de recebimentos de clientes/orçamento industrial.
+147
View File
@@ -0,0 +1,147 @@
# Compras e núcleo financeiro comum — desenho e transição
**Actualização de execução, 29/10/2026:** primeiro incremento implementado e tecnicamente demonstrado em hosts sintéticos, conforme [entrega e limites](COMMON_BUDGET_DELIVERY.md). A marcação de desenho/não executado abaixo conserva o estado da proposta original. Não há aceitação, implantação ou extracção integral de Compras presumidas; T2 e seguintes não foram iniciados.
29/10/2026. Estado: desenho técnico revisto e primeiro incremento delimitado; reestruturação e provas ainda não executadas. Referência principal para os dois projectos. Não substitui as matrizes de requisitos ou as aceitações existentes.
## 1. Decisão expressa e alcance
Decisão da Tempo: **um módulo de Compras, um núcleo financeiro comum e duas composições comerciais**. O ERP SGF inclui Compras. A composição Compras autónomo inclui as capacidades comuns necessárias, sem exigir a facturação de vendas SGF nem um SGF remoto. Os percursos entregues são preservados. Esta orientação substitui a proposta anterior de duas aplicações ligadas por conector remoto como arquitectura principal.
Desenvolvimento e validação usam organizações, unidades, contas e operações sintéticas; pessoas reais e concessões na Tempo pertencem à implantação. As configurações, dados, aprovações e alterações posteriores dos dois projectos mantêm-se. Nesta passagem alterou-se apenas documentação; não se leram credenciais, executaram bases/testes ou alteraram dependências.
Origem das restrições: composição comercial, núcleo único, preservação e uso sintético são decisões expressas da Tempo. Organização em pacotes, transacção local, contratos e sequência abaixo são propostas de implementação. Regras empresariais continuam a ser as aprovadas e versionadas, não políticas universais criadas pela modularização.
## 2. Duas composições, a mesma implementação
| Componente | ERP SGF | Compras autónomo |
| --- | --- | --- |
| Plataforma comum: identidade, empresa fiscal/unidade, autorizações, auditoria e comandos | Incluída | Incluída na extensão necessária ao produto |
| Núcleo financeiro comum e suas migrações | Uma implementação local | A mesma implementação local/versionada |
| Módulo de Compras e seus percursos | Incluído | Incluído |
| Facturação de vendas, oficinas, produção e outros módulos ERP | Conforme composição | Não obrigatórios para instalar, entrar, comprar ou pagar fornecedores |
| Comunicação entre Compras e financeiro | Interfaces de aplicação no mesmo processo | As mesmas interfaces no mesmo processo |
Autonomia é autonomia de instalação e utilização, não outra cópia das regras. As duas instalações de clientes diferentes têm naturalmente os seus próprios dados; não se pretende sincronizar automaticamente essas instalações. Integração com facturação externa poderá ser opcional e contratada separadamente, nunca condição para usar Compras.
Proposta de estrutura lógica: `Platform` (contexto/acesso/auditoria), `Finance` (capacidades financeiras comuns), `Inventory` (movimentos físicos comuns), `Purchasing` (procurement), `Sales` e restantes módulos SGF. Stock físico não é contabilidade geral; ambos têm responsáveis definidos. As dependências apontam de Purchasing/Sales para contratos comuns, sem Finance depender dos controladores ou tabelas de vendas.
Os nomes e o formato físico dos pacotes são escolhas técnicas. A fonte de cada módulo deve ser única, com namespace próprio, versão e testes. Não copiar `App/Services` de Compras para outro `App/Modules` e continuar a manter ambos. Durante a transição, fachadas antigas delegam para o serviço extraído; não executam também a lógica antiga. Distribuir os mesmos artefactos em duas composições, sem exigir caminhos relativos entre repositórios no cliente.
No modo instalado, preferir uma ligação transaccional local para documento, efeito e auditoria quando pertencem à mesma operação. Separar propriedade das tabelas por módulo não exige bases separadas. Não unir agora as bases existentes, tabelas `users`/`tenants`, identificadores, históricos ou sessões. A estratégia exacta de schema será demonstrada em instalação sintética antes de qualquer migração de cliente.
## 3. Percursos de Compras a preservar
Referências relativas a `C:/laragon/www/compras`. As provas anteriores são reutilizadas; acrescentar verificações apenas do delta. Estado de implementação não equivale a autorização operacional real.
| Percurso preservado | Implementação de referência | Preservação na composição SGF/autónoma |
| --- | --- | --- |
| Empresa/equipa, departamentos e centros | `app/Http/Controllers/CompanyController.php`; `app/Enums/PurchasingRole.php` | Adaptar contexto e acesso sem alargar poderes; manter utilizadores e direitos históricos |
| Planeamento e marcos ligados à requisição | `app/Services/PurchasePlanningWorkflow.php` | Manter versões, responsáveis e ausência de reserva pelo simples planeamento |
| Requisição, linhas e aprovações sequenciais | `app/Services/RequisitionWorkflow.php` | Manter autoria, limites/base congelados, decisões distintas, exactidão e histórico |
| Fornecedor, documentos e dados bancários | `SupplierBankWorkflow.php`; controladores `SupplierController.php`/`SupplierDocumentController.php` | Preservar revisões, dupla validação bancária, cifragem e documentos privados |
| Cotações, matriz e conflitos | `ProposalComparison.php`; `ConflictGuard.php`; `QuotationController.php` | Mesmos critérios e elegibilidade; não reutilizar direitos de vendas |
| Abertura, qualificação, admissão e recomendação | `ProposalOpeningWorkflow.php`; `DocumentaryQualificationWorkflow.php`; `FormalAdmission.php`; `FormalRecommendation.php` | Preservar adopções opcionais, políticas por versão, bases, quóruns e decisões |
| Adjudicação, aprovação final e ordem | `PurchaseWorkflow.php`; `InvestmentWorkflow.php` | Manter nova aprovação do valor final, ordem única, snapshots e requisitos de investimento |
| Revisão/cancelamento antes de ordem | `PurchaseControlPolicyWorkflow.php`; `PurchaseRevisionWorkflow.php` | Preservar limites, libertação única e novo rascunho sem herdar decisões; não alargar a ordens emitidas |
| Recepção, qualidade e aceitação parcial | `ReceiptWorkflow.php` | Mesmas quantidades aceites, segregação, guia e origem única do movimento físico |
| Stock, equipamentos e peças | `StockWorkflow.php`; `EquipmentWorkflow.php` | Manter armazéns, saldos, saídas, transferências, contagens, ajustes e rastreabilidade |
| Dotação, reforço, reserva e compromisso | `BudgetWorkflow.php`; `BudgetReinforcementWorkflow.php` | Extrair a capacidade financeira existente; não criar orçamento paralelo no SGF |
| Factura recebida e conferência | `InvoiceWorkflow.php` | Preservar preço/unidade/IVA da ordem, aceitação física e facturação parcial cumulativa |
| Pagamento externo e conferência | `ExternalPaymentWorkflow.php` | Manter saldo, versão bancária e confirmação independente; não executar transferências |
| Processo único, auditoria e impressão | `PurchaseProcessController.php`; `ProcurementReviewReport.php`; `routes/web.php` | Conservar documentos e leitura histórica, com navegação adaptável às duas composições |
Os serviços sem caminho completo nesta tabela ficam em `app/Services`; os controladores ficam em `app/Http/Controllers`. `docs/validacao-tecnica.md` já descreve recomendação/migração 000020; resumos ainda referidos a 000017 são anteriores, não indício de perda. Não reabrir a aceitação dos incrementos conservados nem declarar executadas novas provas a partir desses relatórios.
## 4. Capacidades comuns e um responsável por efeito
Responsável significa o módulo/serviço que valida e grava o efeito, não uma pessoa real. Um documento pode originar efeitos diferentes, cada um com identidade e responsável próprios; não deve provocar duas gravações do mesmo efeito.
| Capacidade/efeito | Existente e origem | Falta desenvolver/adaptar | Único responsável de destino |
| --- | --- | --- | --- |
| Contexto empresarial e autorização | SGF: `CompanyContext`, `Permissions`, memberships/unidades. Compras: tenant=empresa, perfil/departamento e `CurrentActor` | Contrato comum e adaptação dos perfis; unidade não equivale a departamento/armazém | Platform; Purchasing conserva a elegibilidade e segregação do seu processo |
| Montantes e quantidades | Compras: centavos, milésimos e HalfUp; SGF: decimais exactos/racionais e política própria de IVA de serviços | Tipos/contratos com moeda, escala, unidade e política explícitas; sem uniformizar resultados históricos | Política de cálculo identificada por domínio/versão; Finance valida representação dos efeitos monetários |
| Dotação e reforço | Compras: `Budget`, `BudgetReinforcement`, aprovações e histórico | Extrair reutilizando regras existentes e generalizar origem sem perder controlos | Finance/BudgetControl |
| Reserva, ajuste na adjudicação, compromisso, libertação permitida e consumo | Compras: `BudgetWorkflow`, `InvoiceWorkflow`, `budget_entries`, `budget_consumptions` | Unificar todos os escritores desses efeitos, idempotência por origem e transacção local | Finance/BudgetControl; Purchasing pede o efeito e valida o processo |
| Orçamento industrial previsto/realizado | SGF: `app/Modules/Industry/IndustrialBudgets.php`, simulação aceite | Articular dimensões futuras; não substituir controlo de disponibilidade por comparação de custo | Industry/CostPlanning; não é outro escritor do ledger de dotações |
| Factura recebida e obrigação a fornecedor | Compras: registo e conferência em `InvoiceWorkflow` | Serviço comum de contas a pagar, vencimentos/condições e correcções; preservar documento de origem | Purchasing confere factura; Finance/Payables grava a obrigação uma vez |
| Pagamento e afectação à obrigação | Compras: registo/conferência externo em `ExternalPaymentWorkflow` | Extrair mecanismo para Finance/Treasury/Payables; execução bancária e conciliação completa não demonstradas | Finance; Purchasing recebe estado/consulta e não liquida de novo |
| Facturação de vendas e recebimentos | SGF: `ServiceInvoicing`, `ServiceReceivables`, emissão/recebimentos delimitados de teste | Interfaces financeiras comuns futuras; contas a receber não substituem contas a pagar | Sales conserva documento; Finance a receber/tesouraria quando extraído; nenhuma dependência comercial obrigatória em Compras |
| Quantidade física e saldo por armazém | Compras: `ReceiptWorkflow`, `StockWorkflow`, `StockMovement` | Extrair Inventory comum e delegar entradas/saídas de todos os consumidores; não presumir método de valorização | Inventory, invocado uma vez por origem; Purchasing aceita recepção e Workshop declara consumo |
| Custo directo/industrial | SGF: `DirectCosts`, `IndustrialCosts` e `ExactCostMath`, actualmente sintéticos | Ligar movimentos e taxas identificados, sem repetir consumo físico ou custo | Serviços de custos comuns/Industry conforme natureza; Inventory continua dono da quantidade |
| Classificação e conhecimento fiscal | SGF: `CatalogueAccounting`, `ServiceFiscal`; requisitos FIS-SGF-13 a 19 | Completar consumidor de compras e vigência/dedutibilidade; reutilizar conhecimento aceite | Finance/TaxAccounting, com regra e período explícitos; taxa da cotação não confirma dedutibilidade |
| Lançamento, razão, fecho e modelos | SGF: âmbito descrito em `SERVICE_INVOICE_CALCULATION_SPEC.md`, não motor geral concluído | Construir capacidades e ligações em falta com origem/versão e correcções | Finance/GeneralLedger e Finance/TaxReporting; nenhum lançamento implícito na migração/importação |
| Auditoria e repetição de comandos | Ambos: histórico, transacções, chaves/hash em percursos específicos | Contrato de comando/origem e correlação comuns; preservar protecções existentes | Platform regista auditoria; cada serviço mantém unicidade do seu efeito na mesma transacção |
Um núcleo financeiro comum não significa que todas as suas capacidades já existam no SGF. Parte será formada por extracção do código de Compras; parte pelas capacidades SGF já demonstradas; parte terá de ser desenvolvida. Receber factura não é emitir factura de vendas. Compromisso orçamental, obrigação, pagamento, movimento físico e lançamento são efeitos distintos.
## 5. Dependências e compatibilidade antes da extracção
1. Ambos usam Laravel 13, mas `composer.lock` difere: Compras Laravel 13.39.0/Brick Math 1.0.0; SGF Laravel 13.38.0/Brick Math 0.14.8. Escolher uma combinação suportada para o pacote no ensaio sintético; não actualizar manifests/lockfiles dos clientes automaticamente. Dependência incompatível é um problema concreto de implementação, não motivo para duplicar o motor.
2. Compras usa Inertia/TypeScript; SGF usa React/JSX e JSON de sessão. Primeiro separar aplicação/domínio da apresentação. Preservar ecrãs Compras, permitindo layouts diferentes; não reescrever todo o frontend para demonstrar o primeiro efeito partilhado.
3. `BelongsToTenant` e `CurrentActor` de Compras dependem de `auth()`; SGF usa selecção empresa/unidade e token de contexto. Definir actor/contexto explícitos e adaptadores locais revalidáveis. Não desactivar filtros ou conceder poderes globais para acomodar o pacote.
4. Um utilizador Compras tem actualmente um tenant/perfil/departamento; o SGF permite memberships e concessões. Definir correspondência sem usar email, nome ou igualdade de IDs como autoridade. Conta multiempresa e eventual evolução do modelo Compras exigem testes específicos; a extracção inicial pode limitar o cenário sintético a uma empresa/unidade, declarando a cobertura.
5. Não reaproveitar os nomes `tenants`/`users` para fundir tabelas automaticamente. Namespace próprio e uma migração proprietária de cada tabela; manter referências de origem e regras de acesso. Não juntar histórias de migrações ou copiar schemas de clientes para o novo ensaio.
6. As tabelas outbox SGF capturadas/sintéticas não são um serviço financeiro executável. Chamadas locais são suficientes para a primeira composição; não introduzir conector remoto, broker ou transacção distribuída como dependência.
## 6. Transição gradual
| Passo | Resultado | Preservação / condição de passagem |
| --- | --- | --- |
| T0 — este desenho | Percursos, efeitos, responsáveis e primeiro incremento delimitados | Documentação apenas; nenhuma aceitação funcional nova |
| T1 — controlo orçamental comum | Um serviço, contratos locais e dois hosts sintéticos exercitam o mesmo pacote | Paridade das regras/resultados existentes; todos os escritores do ledger delimitado passam pela mesma implementação |
| T2 — factura recebida e contas a pagar | Documento conferido origina uma obrigação comum; ecrãs/decisões Compras conservados | Sem emissão de venda obrigatória; consumo orçamental não repetido; correcções no âmbito implementado |
| T3 — pagamentos comuns | Extrair registo, conferência e afectação externa existentes; ligar obrigação | Um pagamento/afectação por origem; sem execução bancária presumida; preservar validação bancária |
| T4 — inventário e custos | Extrair movimentos físicos existentes e ligar consumidores SGF | Uma origem física, um movimento; valorizações e regras de custo explícitas |
| T5 — contabilidade/fiscalidade e composições completas | Completar consumidores, modelos e empacotamento das duas instalações | Conformidade/implantação por capacidade e cliente; sem obrigar a facturação de vendas |
A ordem é incremental e pode ser ajustada por dependências demonstradas. Enquanto uma capacidade não foi extraída, mantém-se o seu escritor actual. Quando passar ao módulo comum, a fachada antiga apenas delega. Não manter dois fornecedores seleccionáveis para a mesma capacidade nas duas composições finais, nem comparar em paralelo gravando duas vezes. Comparações de paridade usam rollback ou leitura sem efeitos.
Dados históricos não são executados de novo como comandos. A transição de um cliente exige inventário de código/schema/dados/permissões equivalentes, backup/restauro e migração aditiva ensaiada. Preservar identificadores públicos/documentos/snapshots ou manter correspondência imutável, sem usar a extracção para recalcular IVA ou reaprovar processos concluídos. Reversão da versão de código não pode apagar efeitos ou deixar dois escritores activos; planear compatibilidade e recuperação antes de qualquer aplicação real.
## 7. Primeiro incremento delimitado — COMMON-BUDGET-SYNTHETIC-v1
**Objectivo:** demonstrar, em dois hosts de instalação sintéticos, a mesma implementação local de controlo orçamental proveniente de Compras, sem carregar vendas/facturação SGF como dependência.
**Incluído:** contrato de actor/contexto, origem do comando, moeda/base/exercício/centro, identidade idempotente e decisão elegível; operações existentes de dotação/reforço, reserva, ajuste de adjudicação, compromisso, libertação apenas no âmbito actualmente permitido e consumo por factura. Consolidar leitura de disponibilidade, ledger e auditoria, sem novo método de cálculo ou política empresarial. Pedidos sem dotação mantêm a distinção histórica de documento de desenvolvimento não controlado; não reinterpretar isso como controlo aprovado para utilização real.
**Fronteira de código prevista:** extrair de `BudgetWorkflow.php`, `BudgetReinforcementWorkflow.php`, operações orçamentais dos controladores de orçamento e de `InvoiceWorkflow::validate`, com `Budget`, `BudgetEntry`, `BudgetConsumption` e `BudgetReinforcement`. Manter as decisões de procurement e a conferência da factura nos seus serviços. Inventariar todos os pontos de escrita dessas tabelas antes da primeira alteração e incluir os testes/concorrência de orçamento e reforço existentes. Os ficheiros são pontos de origem, não autorização para copiar duas implementações.
**Dois hosts de prova:** composição Compras autónomo e composição ERP SGF reduzida com Purchasing, Platform e Finance. Ambos usam o mesmo artefacto/versionamento; cada um instala a sua base sintética isolada. Exercitar o circuito de aplicação real do módulo em cada host, não apenas um teste unitário com duas etiquetas. As entidades/actores de teste não são contas reais e não dependem de Osmane/Samira. A montagem visual completa do ERP não é condição para esta prova de extracção, mas o host deve carregar o módulo e os seus adaptadores de forma efectiva.
**Excluído:** alterações nas bases/clientes existentes, concessões reais, migração da Tempo, reestruturação integral do frontend, vendas, emissão fiscal, novo livro geral, criação de pagamentos, execução bancária, stock/valorização e novas regras de cancelamento. O consumo por factura é só o efeito orçamental existente, não a implementação antecipada de Payables. Não mover operações fora do incremento por arrasto.
**Critério de saída:** mesmas entradas produzem o mesmo ledger/saldos/recusas nos dois hosts, utilizando a mesma implementação, preservando base com/sem IVA, segregação, histórico e transacção. Uma origem gera um único efeito, replay idêntico recupera o resultado e conflito de conteúdo é recusado. Factura parcial consome apenas o montante devido, compromisso não é consumido duas vezes e falha da auditoria reverte o movimento. Os testes devem demonstrar revogação de direito e concorrência PostgreSQL. Artefacto Compras instala/arranca sem Sales ou SGF remoto.
Este incremento fica delimitado para orientar a próxima reestruturação. Não foi implementado ou aceite nesta passagem; as dependências acima deverão ser resolvidas no ensaio isolado, sem nova aprovação de cada pequeno passo técnico.
## 8. Plano de provas sintéticas, não executadas nesta passagem
| Prova nos dois modos | Resultado exigido |
| --- | --- |
| Instalação autónoma sem Sales e sem rede para SGF | Arranque, contexto, procurement e controlo orçamental funcionam localmente |
| Instalação ERP com Purchasing | Mesmo pacote/migrações, serviços e resultados; sem segundo serviço de orçamento |
| Empresa A/B, unidade central/reservada, IDs coincidentes | Nenhum acesso ou movimento cruzado; contexto incompatível recusado |
| Proponente, aprovador, comprador e conferente sintéticos | Direitos explícitos; pessoa não valida a própria operação; revogação revalidada na transacção |
| Dotação 1000, reserva 300, ajuste para 350, compromisso 350, consumo parcial 100 | Ledger conserva os deltas; 100 consumidos e 250 comprometidos; disponível 650, sem dupla subtracção |
| Reforço 200 proposto e depois aprovado distintamente | Disponível só aumenta após aprovação; replay não aumenta outra vez |
| Mesmo comando repetido; chave igual com conteúdo diferente | Mesmo resultado sem novo efeito; conflito explícito no segundo caso |
| Duas reservas simultâneas acima do saldo conjunto | Uma recusa ou espera conforme serialização; saldo nunca negativo |
| Falha entre documento, movimento e auditoria | Rollback local integral; repetição segura e sem reserva órfã |
| Cancelamento permitido antes de compromisso / ordem já emitida | Libertação única só no primeiro âmbito; manter recusa no segundo |
| Moeda/base/exercício ausentes ou incompatíveis | Pendência/recusa explicável, sem zero ou política inventada |
| Migração sintética e recuperação da versão | Contagens/origens/histórico conservados; nenhum replay de documentos históricos |
Provas futuras T2–T5 acrescentam obrigação única, pagamento parcial, versões bancárias, movimento físico único, origem até lançamento e compatibilidade de regra/modelo fiscal. Não são critérios artificialmente antecipados para bloquear T1. Reutilizar provas aceites e registar apenas o delta nas matrizes/relatórios existentes. Demonstração persistente da equipa e bases destrutivas de regressão precisam de ambientes separados; não executar testes de extracção na demonstração SGF existente.
## 9. Referências SGF e cobertura documental
- `AGENTS.md`: decisões de produto e preservação; âmbito sintético.
- `docs/PRODUCT_DEMO.md`: capacidades existentes e demonstração, sem contabilidade geral presumida.
- `docs/SERVICE_INVOICE_CALCULATION_SPEC.md`, FIS-SGF-13 a 19: requisitos de compras/classificação, stock, contabilidade, custos e modelos; estados e critérios mantidos, sem nova matriz de requisitos.
- `app/Shared/Context/CompanyContext.php`, `app/Modules/Identity/Services/Permissions.php`: contexto/acessos existentes.
- `app/Modules/Services/ServiceCalculation.php`, `DraftValues.php`, `ServiceReceivables.php`, `CatalogueAccounting.php`: semântica financeira/fiscal existente e limites.
- `app/Modules/Industry/IndustrialBudgets.php`, `IndustrialCosts.php`: orçamento de custo e custeio industrial distintos de dotação de procurement.
- `composer.json`/`composer.lock` dos dois projectos: dependências reais a reconciliar na extracção.
Pendências específicas de desenho: forma física/versionamento/distribuição dos pacotes; schema sintético e estratégia futura de migração; correspondência multiempresa/unidade/departamento; combinação de dependências suportada. São decisões de implementação a demonstrar em QA, não requisitos para criar pessoas reais nem pretexto para um segundo motor financeiro.
+247
View File
@@ -0,0 +1,247 @@
{
"version": "COMMON-BUDGET-SYNTHETIC-v1",
"checked_at": "2026-10-29T11:07:06+00:00",
"client_databases_touched": false,
"hosts": [
{
"host": "compras",
"passed": true,
"detached_package_artifact": true,
"core_sha256": "1349d1ab4ab58a83c9abbf43549ebe7e4ed77717ed5463a2defac748cf7b9a31",
"steps": {
"reserve": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 30000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 70000
},
"award": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 35000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 65000
},
"commit": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 35000,
"consumed_minor": 0,
"available_minor": 65000
},
"consume": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 25000,
"consumed_minor": 10000,
"available_minor": 65000
},
"reinforcement": {
"approved_total_minor": 120000,
"reinforced_minor": 20000,
"reserved_minor": 0,
"committed_minor": 25000,
"consumed_minor": 10000,
"available_minor": 85000
}
},
"races": [
{
"scenario": "over_reserve",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 6000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 4000
}
},
{
"scenario": "same_reserve",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 6000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 4000
}
},
{
"scenario": "same_commit",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 6000,
"consumed_minor": 0,
"available_minor": 4000
}
},
{
"scenario": "same_release",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 10000
}
}
],
"checks": [
"independent_approval",
"partial_consumption",
"same_command_replay",
"changed_replay_rejected",
"committed_release_refused",
"reinforcement_pending_and_approved",
"tenant_isolation",
"release_once",
"net_tax_basis",
"audit_rollback_and_retry",
"revoked_reviewer_replay"
],
"sgf_context_checks": [],
"visual_review": false
},
{
"host": "sgf",
"passed": true,
"detached_package_artifact": false,
"core_sha256": "1349d1ab4ab58a83c9abbf43549ebe7e4ed77717ed5463a2defac748cf7b9a31",
"steps": {
"reserve": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 30000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 70000
},
"award": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 35000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 65000
},
"commit": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 35000,
"consumed_minor": 0,
"available_minor": 65000
},
"consume": {
"approved_total_minor": 100000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 25000,
"consumed_minor": 10000,
"available_minor": 65000
},
"reinforcement": {
"approved_total_minor": 120000,
"reinforced_minor": 20000,
"reserved_minor": 0,
"committed_minor": 25000,
"consumed_minor": 10000,
"available_minor": 85000
}
},
"races": [
{
"scenario": "over_reserve",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 6000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 4000
}
},
{
"scenario": "same_reserve",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 6000,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 4000
}
},
{
"scenario": "same_commit",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 6000,
"consumed_minor": 0,
"available_minor": 4000
}
},
{
"scenario": "same_release",
"passed": true,
"observed_lock_waits": 2,
"balance": {
"approved_total_minor": 10000,
"reinforced_minor": 0,
"reserved_minor": 0,
"committed_minor": 0,
"consumed_minor": 0,
"available_minor": 10000
}
}
],
"checks": [
"independent_approval",
"partial_consumption",
"same_command_replay",
"changed_replay_rejected",
"committed_release_refused",
"reinforcement_pending_and_approved",
"tenant_isolation",
"release_once",
"net_tax_basis",
"audit_rollback_and_retry",
"revoked_reviewer_replay"
],
"sgf_context_checks": [
"foreign_source_refused",
"reserved_unit_refused",
"stale_context_refused"
],
"visual_review": false
}
],
"equivalent": true,
"disposable_databases_dropped": true
}
+11
View File
@@ -0,0 +1,11 @@
# Shared budget control — COMMON-BUDGET-SYNTHETIC-v1
Canonical source: this directory. PHP 8.4+, Illuminate Database 13 as supplied by each host. No Brick Math dependency, new vendor packages or version changes. Monetary inputs are exact existing integer MZN cents; document tax/quantity calculations remain in their current host workflows.
`BudgetContext` explicitly carries actor, organisation scope, fiscal reference year and supported currency. `BudgetControl` is the single writer for allocations, approvals, reinforcements, reservation/award/commit/release and invoice budget consumption. Host callbacks acquire the organisation lock, revalidate identity/permissions, audit within the same transaction and check proposer eligibility. No call to auth/session/global DB facade occurs in the package.
Compras uses its existing tables and scope `tenant_id` (company); SGF QA uses separate `cb_` tables scoped by tenant/company/unit with mapped centres/origins. No automatic identifier conversion. There are no sales, payable, payment, industrial budget or remote SGF dependencies.
Load `bootstrap.php` after the host's existing vendor autoloader. For local Compras development the provider locates this source tree. A standalone deployment installs the same versioned package artifact under `packages/budget-control`, or sets `COMMON_BUDGET_PATH` to its installed artifact; the local sibling fallback is disabled in production. No second maintained source copy is created. QA records the loaded source hash in both booted Laravel hosts.
Existing tables/constraints remain valid in Compras; no live migration is run. SGF schema/rights are QA-only in this increment. SGF operational activation, full procurement UI installation and customer data migration remain pending. See the delivery report for evidence and limitations.
+6
View File
@@ -0,0 +1,6 @@
<?php
// One source tree, loaded by each composition; no Composer or Brick Math replacement.
require_once __DIR__.'/src/BudgetContext.php';
require_once __DIR__.'/src/BudgetFailure.php';
require_once __DIR__.'/src/BudgetControl.php';
@@ -0,0 +1,19 @@
<?php
namespace Tempo\Finance\Budget;
final readonly class BudgetContext
{
/** @param array<string, int> $scope Explicit host identifiers, never inferred from each other. */
public function __construct(public int $actorId, public array $scope, public int $year, public string $currency)
{
if ($actorId < 1 || $scope === [] || $year < 2026 || $currency !== 'MZN') {
throw new \InvalidArgumentException('Contexto orçamental ou moeda não suportados.');
}
foreach ($scope as $column => $id) {
if (! in_array($column, ['tenant_id', 'company_id', 'unit_id'], true) || ! is_int($id) || $id < 1) {
throw new \InvalidArgumentException('Âmbito explícito inválido.');
}
}
}
}
@@ -0,0 +1,291 @@
<?php
namespace Tempo\Finance\Budget;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
/**
* Single writer of allocation, reinforcement and procurement budget effects.
* Hosts supply authentication/authorisation and organisation locks, not financial rules.
* All money is the existing exact integer minor unit; no tax/price rounding here.
*/
final class BudgetControl
{
public const VERSION = 'COMMON-BUDGET-SYNTHETIC-v1';
public const MAX_MINOR = 100000000000000;
public function __construct(
private ConnectionInterface $db,
public readonly BudgetContext $context,
private string $prefix,
private \Closure $lockAndAuthorize,
private \Closure $audit,
private \Closure $eligibleAuthor,
) {
if (! preg_match('/^[a-z_]*$/D', $prefix)) {
throw new \InvalidArgumentException('Invalid budget table prefix.');
}
}
private function query(string $table): Builder
{
return $this->db->table($this->prefix.$table)->where($this->context->scope);
}
private function atomic(string $action, callable $work): mixed
{
return $this->db->transaction(function () use ($action, $work) {
($this->lockAndAuthorize)($action, $this->context);
return $work();
});
}
private function fail(string $message, string $field = 'budget', int $status = 422): never
{
throw new BudgetFailure($message, $field, $status);
}
private function allocation(int $id): object
{
return $this->query('budgets')->where('id', $id)->lockForUpdate()->first()
?? $this->fail('Dotação fora do âmbito autorizado.', status: 403);
}
private function stamp(): string
{
return date('Y-m-d H:i:s');
}
private function emit(string $action, array $data, ?int $origin = null): void
{
($this->audit)($action, $data, $origin, $this->context);
}
public static function approvedTotal(int $initial, int $reinforcements): int
{
$sum = $initial + $reinforcements;
if (! is_int($sum)) {
throw new \OverflowException('Montante orçamental não representável.');
}
return $sum;
}
private function total(object $budget): int
{
return self::approvedTotal((int) $budget->amount_minor, (int) $this->query('budget_reinforcements')->where('budget_id', $budget->id)->where('status', 'approved')->sum('amount_minor'));
}
/** Host validates the cost centre and input format; original integer amount is preserved. */
public function create(array $data): int
{
return $this->atomic('create', function () use ($data) {
$fields = ['cost_center_id', 'year', 'kind', 'includes_tax', 'amount_minor', 'reason'];
if (array_diff(array_keys($data), $fields) !== [] || array_diff($fields, array_keys($data)) !== []
|| ! is_int($data['cost_center_id']) || $data['cost_center_id'] < 1
|| ! is_int($data['year']) || $data['year'] < 2026 || $data['year'] > 2100
|| ! is_bool($data['includes_tax']) || ! is_string($data['reason']) || trim($data['reason']) === '') {
$this->fail('Dados de dotação inválidos ou campos protegidos.');
}
if ($this->query('budgets')->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists()) {
$this->fail('Já existe dotação para este centro e exercício.', status: 409);
}
if (! is_int($data['amount_minor']) || $data['amount_minor'] < 0 || $data['amount_minor'] > self::MAX_MINOR || ! in_array($data['kind'], ['operating', 'investment'], true)) {
$this->fail('Dotação não representável.');
}
$id = $this->query('budgets')->insertGetId($this->context->scope + $data + ['created_by' => $this->context->actorId, 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.created', (array) $this->allocation($id));
return $id;
});
}
public function approve(int $id): void
{
$this->atomic('approve', function () use ($id) {
$budget = $this->allocation($id);
if ((int) $budget->created_by === $this->context->actorId) {
$this->fail('Aprovação exige identidade distinta.', status: 403);
}
if ($budget->approved_by) {
return;
}
$this->query('budgets')->where('id', $id)->update(['approved_by' => $this->context->actorId, 'approved_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.approved', (array) $this->allocation($id));
});
}
/** @return array<string, int>|null */
public function record(int $origin, int $centre, string $requiredKind, string $kind, int $amount = 0, ?int $subtotal = null): ?array
{
return $this->atomic('record', function () use ($origin, $centre, $requiredKind, $kind, $amount, $subtotal) {
if (! in_array($kind, ['reserve', 'award', 'commit', 'release'], true) || $amount < 0) {
$this->fail('Movimento orçamental inválido.');
}
$own = $this->query('budget_entries')->where('requisition_id', $origin);
$first = (clone $own)->orderBy('id')->first();
$budget = $first ? $this->allocation((int) $first->budget_id) : $this->query('budgets')->where('cost_center_id', $centre)->where('year', $this->context->year)->lockForUpdate()->first();
// Exact legacy behaviour: unconfigured development documents are explicitly uncontrolled.
if (! $budget) {
return null;
}
if ($budget->kind !== $requiredKind) {
$this->fail('A dotação deve ter a classificação explícita adequada: investimento para processos com equipamento; corrente para os restantes.');
}
if (! $budget->approved_by || ($kind !== 'release' && (int) $budget->year !== $this->context->year)) {
$this->fail('Dotação pendente de aprovação ou exercício encerrado.');
}
if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'], true)) {
$amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
}
$result = ['budget_id' => (int) $budget->id, 'year' => (int) $budget->year];
$old = (clone $own)->where('kind', $kind)->first();
if ($old) {
// Reconstruct original target before this entry, including historical rows without hashes.
$before = (int) (clone $own)->where('id', '<', $old->id)->sum('reserved_minor');
$target = $kind === 'commit' ? (int) $old->committed_minor : $before + (int) $old->reserved_minor;
if ($kind !== 'release' && $target !== $amount) {
$this->fail('Origem já registada com outro montante; não é criado segundo efeito.');
}
return $result;
}
$reserved = (int) (clone $own)->sum('reserved_minor');
$committed = (int) (clone $own)->sum('committed_minor');
if ($committed !== 0 || ($kind !== 'reserve' && ! $first)) {
$this->fail('Processo sem reserva válida ou já comprometido.');
}
[$rd, $cd] = match ($kind) {
'reserve', 'award' => [$amount - $reserved, 0],
'release' => [-$reserved, 0],
'commit' => [-$reserved, $amount],
};
$balance = $this->balanceOf($budget);
if ($balance['available_minor'] < $rd + $cd) {
$this->fail('Saldo orçamental insuficiente. Não são permitidos excessos.');
}
$this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => $kind, 'reserved_minor' => $rd, 'committed_minor' => $cd, 'created_at' => $this->stamp()]);
$this->emit('budget.'.$kind, ['budget_id' => $budget->id, 'reserved_minor' => $rd, 'committed_minor' => $cd], $origin);
return $result;
});
}
/** Only budget consumption: does not create a payable, invoice, payment or accounting entry. */
public function consume(int $origin, int $invoice, int $total, int $subtotal): array
{
return $this->atomic('consume', function () use ($origin, $invoice, $total, $subtotal) {
$own = $this->query('budget_entries')->where('requisition_id', $origin);
$commit = (clone $own)->where('kind', 'commit')->first();
if (! $commit) {
$this->fail('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.', 'invoice');
}
$budget = $this->allocation((int) $commit->budget_id);
if (! $budget->approved_by || (int) $budget->year !== $this->context->year) {
$this->fail('Orçamento não aprovado ou fora do exercício.', 'invoice');
}
$amount = (int) ($budget->includes_tax ? $total : $subtotal);
$old = $this->query('budget_consumptions')->where('invoice_id', $invoice)->first();
$result = ['budget_id' => (int) $budget->id, 'consumed_minor' => $amount];
if ($old) {
$entry = (clone $own)->where('kind', 'invoice-'.$invoice)->first();
if ((int) $old->budget_id !== (int) $budget->id || (int) $old->amount_minor !== $amount || ! $entry || (int) $entry->committed_minor !== -$amount) {
$this->fail('Factura já consumida com outra origem ou montante.', 'invoice');
}
return $result;
}
if ($amount < 0 || $amount > (int) (clone $own)->sum('committed_minor')) {
$this->fail('Compromisso insuficiente para a factura.', 'invoice');
}
$this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => 'invoice-'.$invoice, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => $this->stamp()]);
$this->query('budget_consumptions')->insert($this->context->scope + ['budget_id' => $budget->id, 'invoice_id' => $invoice, 'created_by' => $this->context->actorId, 'amount_minor' => $amount, 'created_at' => $this->stamp()]);
$this->emit('budget.consumed', $result + ['invoice_id' => $invoice], $origin);
return $result;
});
}
private function validReinforcement(object $budget, int $amount): void
{
if (! $budget->approved_by || (int) $budget->year !== $this->context->year || $amount <= 0) {
$this->fail('Exige dotação aprovada do exercício actual e montante positivo.', 'reinforcement');
}
if ($amount > self::MAX_MINOR - $this->total($budget)) {
$this->fail('A dotação acumulada ultrapassa o limite técnico de valores suportados.', 'reinforcement');
}
}
public function proposeReinforcement(int $id, int $amount, string $reference, string $reason): int
{
return $this->atomic('reinforcement.propose', function () use ($id, $amount, $reference, $reason) {
$budget = $this->allocation($id);
$hash = hash('sha256', json_encode(['budget_id' => $id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
$old = $this->query('budget_reinforcements')->where('reference', $reference)->first();
if ($old) {
if ((int) $old->created_by !== $this->context->actorId || $old->payload_hash !== $hash) {
$this->fail('Referência já registada com outros dados.', 'reference');
}
return (int) $old->id;
}
$this->validReinforcement($budget, $amount);
if ($reference === '' || $reason === '') {
$this->fail('Referência e fundamento obrigatórios.', 'reinforcement');
}
$rid = $this->query('budget_reinforcements')->insertGetId($this->context->scope + ['budget_id' => $id, 'created_by' => $this->context->actorId, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason, 'status' => 'pending', 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.reinforcement_proposed', ['reinforcement_id' => $rid, 'budget_id' => $id, 'amount_minor' => $amount, 'status' => 'pending']);
return $rid;
});
}
public function reviewReinforcement(int $id, string $decision, string $reason): void
{
$this->atomic('reinforcement.review', function () use ($id, $decision, $reason) {
$r = $this->query('budget_reinforcements')->where('id', $id)->first() ?? $this->fail('Reforço fora do âmbito.', status: 403);
$budget = $this->allocation((int) $r->budget_id);
$r = $this->query('budget_reinforcements')->where('id', $id)->lockForUpdate()->first();
if ((int) $r->created_by === $this->context->actorId) {
$this->fail('Aprovação exige identidade distinta.', status: 403);
}
if (! in_array($decision, ['approved', 'rejected'], true) || trim($reason) === '') {
$this->fail('Decisão e fundamento obrigatórios.', 'reinforcement');
}
if ($r->status !== 'pending') {
if ($r->status !== $decision) {
$this->fail('Decisão final imutável.', 'reinforcement');
}
return;
}
if ($decision === 'approved') {
$this->validReinforcement($budget, (int) $r->amount_minor);
if (! ($this->eligibleAuthor)((int) $r->created_by, $this->context)) {
$this->fail('Autor da proposta deixou de ser elegível.', 'reinforcement');
}
}
$this->query('budget_reinforcements')->where('id', $id)->update(['status' => $decision, 'reviewed_by' => $this->context->actorId, 'review_reason' => trim($reason), 'reviewed_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.reinforcement_'.$decision, ['reinforcement_id' => $id, 'budget_id' => (int) $r->budget_id, 'amount_minor' => (int) $r->amount_minor, 'status' => $decision]);
});
}
private function balanceOf(object $budget): array
{
$entries = $this->query('budget_entries')->where('budget_id', $budget->id);
$reserved = (int) (clone $entries)->sum('reserved_minor');
$committed = (int) (clone $entries)->sum('committed_minor');
$consumed = (int) $this->query('budget_consumptions')->where('budget_id', $budget->id)->sum('amount_minor');
$total = $this->total($budget);
return ['approved_total_minor' => $budget->approved_by ? $total : 0, 'reinforced_minor' => $total - (int) $budget->amount_minor, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0];
}
public function balance(int $id): array
{
return $this->atomic('view', fn () => $this->balanceOf($this->allocation($id)));
}
}
@@ -0,0 +1,11 @@
<?php
namespace Tempo\Finance\Budget;
final class BudgetFailure extends \DomainException
{
public function __construct(string $message, public readonly string $field = 'budget', public readonly int $status = 422)
{
parent::__construct($message);
}
}
@@ -0,0 +1,78 @@
<?php
use Illuminate\Support\Facades\DB;
function budgetQaAssert(bool $condition, string $message): void
{
if (! $condition) throw new RuntimeException($message);
}
function budgetQaFixture(string $mode): array
{
if ($mode === 'compras') {
require_once base_path('tests/Support/ReceiptFixture.php');
$f = receiptFixture();
$a = \App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'finance']);
$v = \App\Models\User::factory()->create(['tenant_id'=>$f['tenant']->id,'department_id'=>$f['department']->id,'purchasing_role'=>'admin']);
return ['actor'=>$a->id,'validator'=>$v->id,'tenant'=>$f['tenant']->id,'centre'=>$f['r']->cost_center_id,'origin'=>$f['r']->id,'invoice'=>$f['order']->id,'scope'=>['tenant_id'=>$f['tenant']->id]];
}
$now = now();
$tenant = DB::table('tenants')->insertGetId(['name'=>'Synthetic tenant','created_at'=>$now,'updated_at'=>$now]);
// Deliberately different tenant/company/unit identifiers, no numeric equivalence.
DB::table('companies')->insert(['tenant_id'=>$tenant,'name'=>'Unused synthetic company','created_at'=>$now,'updated_at'=>$now]);
$company = DB::table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Synthetic purchaser','created_at'=>$now,'updated_at'=>$now]);
DB::table('units')->insert(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Reserved','created_at'=>$now,'updated_at'=>$now]);
$unit = DB::table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Purchasing QA','created_at'=>$now,'updated_at'=>$now]);
$scope = ['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$a = \App\Models\User::create(['name'=>'Synthetic preparer','email'=>'prepare-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
$v = \App\Models\User::create(['name'=>'Synthetic validator','email'=>'validate-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
foreach ([$a,$v] as $u) {
DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);
foreach (['company.view'=>null,'unit.view'=>$unit] as $right=>$unitId) DB::table('permission_grants')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unitId,'permission'=>$right,'created_at'=>$now,'updated_at'=>$now]);
}
foreach ([$a->id=>['create','record','consume','view','reinforcement.propose'],$v->id=>['approve','view','reinforcement.review']] as $id=>$rights) foreach ($rights as $right) DB::table('permission_grants')->insert($scope+['user_id'=>$id,'permission'=>'purchasing_budget.'.$right,'created_at'=>$now,'updated_at'=>$now]);
$centre = DB::table('cb_centres')->insertGetId($scope+['code'=>'CENTRE-TEST']);
$origin = DB::table('cb_origins')->insertGetId($scope+['cost_center_id'=>$centre,'required_kind'=>'operating','reference'=>'REQ-'.bin2hex(random_bytes(6))]);
return ['actor'=>$a->id,'validator'=>$v->id,'tenant'=>$tenant,'company'=>$company,'unit'=>$unit,'centre'=>$centre,'origin'=>$origin,'invoice'=>$origin,'scope'=>$scope];
}
function budgetQaSelect(string $mode, array $f, string $role='actor'): object
{
$user = \App\Models\User::findOrFail($f[$role]);
auth()->guard()->setUser($user);
if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($user->id,$f['tenant'],$f['company'],$f['unit']);
return $user;
}
function budgetQaEngine(string $mode, array $f, string $role='actor'): \Tempo\Finance\Budget\BudgetControl
{
$user = budgetQaSelect($mode,$f,$role);
return $mode === 'compras' ? app(\App\Services\CommonBudget::class)->for($user) : app(\App\Modules\Purchasing\BudgetAdapter::class)->for($user);
}
function budgetQaRecord(string $mode, array $f, string $kind, int $amount=0): mixed
{
$user = budgetQaSelect($mode,$f);
return DB::transaction(function () use ($mode,$f,$kind,$amount,$user) {
if ($mode === 'compras') {
$request = \App\Models\Requisition::findOrFail($f['origin']);
return app(\App\Services\BudgetWorkflow::class)->record($user,$request,$kind,$amount,$amount);
}
return app(\App\Modules\Purchasing\BudgetAdapter::class)->record($user,$f['origin'],$kind,$amount,$amount);
});
}
function budgetQaCreate(string $mode, array $f, int $amount=100000, bool $tax=true): int
{
$data = ['cost_center_id'=>$f['centre'],'year'=>(int)now()->year,'kind'=>'operating','amount_minor'=>$amount,'includes_tax'=>$tax,'reason'=>'Synthetic allocation'];
$user = budgetQaSelect($mode,$f);
$id = $mode==='compras' ? budgetQaEngine($mode,$f)->create($data) : app(\App\Modules\Purchasing\BudgetAdapter::class)->create($user,$data);
budgetQaEngine($mode,$f,'validator')->approve($id);
return $id;
}
function budgetQaReject(callable $work, string $message): void
{
try { $work(); } catch (\Tempo\Finance\Budget\BudgetFailure|\Illuminate\Validation\ValidationException|\Symfony\Component\HttpKernel\Exception\HttpException $e) { return; }
throw new RuntimeException('Expected rejection: '.$message);
}
+145
View File
@@ -0,0 +1,145 @@
<?php
// Runs inside a real Laravel host, with a coordinator-provisioned disposable database.
$root = $argv[1] ?? '';
$mode = $argv[2] ?? '';
$database = getenv('DB_DATABASE');
$pattern = $mode === 'compras' ? '/^compras_receipts_test_[a-f0-9]{12}$/D' : '/^budget_core_sgf_[a-f0-9]{12}$/D';
if (! in_array($mode,['compras','sgf'],true) || ! preg_match($pattern,$database) || getenv('DB_HOST') !== '127.0.0.1') throw new LogicException('Unsafe host test configuration.');
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
if (DB::connection()->getDatabaseName() !== $database || DB::selectOne('select current_database() as db')->db !== $database) throw new LogicException('Effective QA database mismatch.');
require __DIR__.'/host-support.php';
$worker = in_array('--worker',$argv,true);
if ($worker) {
$input = json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);
DB::select('select set_config(?, ?, false)',['application_name',$input['label']]);
try {
budgetQaRecord($mode,$input['fixture'],$input['kind'],$input['amount']);
$status = 200;
} catch (\Tempo\Finance\Budget\BudgetFailure|\Illuminate\Validation\ValidationException $e) { $status = 422; }
echo json_encode(['status'=>$status,'pid'=>DB::selectOne('select pg_backend_pid() as pid')->pid],JSON_THROW_ON_ERROR);
exit;
}
if ($mode === 'compras') {
\Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);
} else {
require __DIR__.'/sgf-schema.php';
}
$prefix = $mode==='sgf'?'cb_':'';
$f = budgetQaFixture($mode);
$budget = budgetQaCreate($mode,$f);
$steps = [];
foreach ([['reserve',30000],['award',35000],['commit',35000]] as [$kind,$amount]) {
budgetQaRecord($mode,$f,$kind,$amount);
budgetQaRecord($mode,$f,$kind,$amount);
$steps[$kind] = budgetQaEngine($mode,$f)->balance($budget);
}
// Real invoice foreign key in Compras, source-reference-only consumption in SGF.
if ($mode==='compras') {
budgetQaSelect($mode,$f);
$sourceInvoice = \App\Models\Invoice::query()->create(['tenant_id'=>$f['tenant'],'supplier_id'=>\App\Models\PurchaseOrder::findOrFail($f['invoice'])->supplier_id,'purchase_order_id'=>$f['invoice'],'created_by'=>$f['actor'],'number'=>'TEST-BUDGET-ONLY','issued_on'=>now()->format('Y-m-d'),'subtotal_minor'=>10000,'tax_minor'=>0,'total_minor'=>10000,'snapshot'=>[]]);
$f['invoice'] = $sourceInvoice->id;
}
$engine = budgetQaEngine($mode,$f);
$engine->consume($f['origin'],$f['invoice'],10000,10000);
$engine->consume($f['origin'],$f['invoice'],10000,10000);
$steps['consume'] = $engine->balance($budget);
budgetQaAssert($steps['consume']['reserved_minor']===0 && $steps['consume']['committed_minor']===25000 && $steps['consume']['consumed_minor']===10000 && $steps['consume']['available_minor']===65000,'Partial consumption differs.');
budgetQaReject(fn()=>budgetQaRecord($mode,$f,'reserve',30001),'changed replay');
budgetQaReject(fn()=>budgetQaRecord($mode,$f,'release'),'release committed source');
budgetQaReject(fn()=>$engine->consume($f['origin'],$f['invoice'],10001,10001),'changed consumption replay');
$rid = $engine->proposeReinforcement($budget,20000,'REF-QA','Synthetic reinforcement');
budgetQaAssert($engine->balance($budget)['available_minor']===65000,'Pending reinforcement changed availability.');
budgetQaReject(fn()=>$engine->reviewReinforcement($rid,'approved','Self approval'),'self approval');
$review = budgetQaEngine($mode,$f,'validator');
$review->reviewReinforcement($rid,'approved','Distinct synthetic validation');
$review->reviewReinforcement($rid,'approved','Distinct synthetic validation');
$steps['reinforcement'] = $review->balance($budget);
budgetQaAssert($steps['reinforcement']['available_minor']===85000,'Reinforcement differs.');
budgetQaSelect($mode,$f);
$foreign = budgetQaFixture($mode);
budgetQaCreate($mode,$foreign);
budgetQaSelect($mode,$f);
budgetQaReject(fn()=>budgetQaEngine($mode,$f)->balance($budget+1),'foreign allocation');
if ($mode === 'sgf') {
budgetQaReject(fn()=>app(\App\Modules\Purchasing\BudgetAdapter::class)->record(budgetQaSelect($mode,$f),$foreign['origin'],'reserve',100,100),'foreign mapped source');
budgetQaReject(fn()=>app(\App\Shared\Context\CompanyContext::class)->selectFor($f['actor'],$f['tenant'],$f['company'],$f['unit']-1),'reserved unit');
$stale = budgetQaEngine($mode,$f);
budgetQaSelect($mode,$foreign);
budgetQaReject(fn()=>$stale->balance($budget),'stale context');
}
// Release is allowed once before commitment.
$release = budgetQaFixture($mode); $releaseBudget = budgetQaCreate($mode,$release,10000);
budgetQaRecord($mode,$release,'reserve',4000);
budgetQaRecord($mode,$release,'release'); budgetQaRecord($mode,$release,'release');
budgetQaAssert(budgetQaEngine($mode,$release)->balance($releaseBudget)['available_minor']===10000,'Release differs.');
// The below-tax basis is preserved, without a document recalculation.
$net = budgetQaFixture($mode); $netBudget = budgetQaCreate($mode,$net,10000,false);
budgetQaEngine($mode,$net)->record($net['origin'],$net['centre'],'operating','reserve',11600,10000);
budgetQaAssert(budgetQaEngine($mode,$net)->balance($netBudget)['reserved_minor']===10000,'Net basis differs.');
// Failed audit must roll back the ledger and make retry safe.
$failure = budgetQaFixture($mode); $failureBudget = budgetQaCreate($mode,$failure,10000);
$auditTable = $mode==='compras'?'audit_logs':'cb_audit';
DB::unprepared("CREATE FUNCTION budget_qa_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='budget.reserve' THEN RAISE EXCEPTION 'QA_AUDIT_FAILURE'; END IF; RETURN NEW; END $$; CREATE TRIGGER budget_qa_fail BEFORE INSERT ON {$auditTable} FOR EACH ROW EXECUTE FUNCTION budget_qa_fail()");
try { budgetQaRecord($mode,$failure,'reserve',4000); throw new RuntimeException('Audit fault was not observed.'); }
catch (\Illuminate\Database\QueryException $expected) { budgetQaAssert(str_contains($expected->getMessage(),'QA_AUDIT_FAILURE'),'Wrong injected failure.'); }
DB::unprepared("DROP TRIGGER budget_qa_fail ON {$auditTable}; DROP FUNCTION budget_qa_fail()");
budgetQaAssert(budgetQaEngine($mode,$failure)->balance($failureBudget)['available_minor']===10000,'Fault left an orphan reservation.');
budgetQaRecord($mode,$failure,'reserve',4000); budgetQaRecord($mode,$failure,'reserve',4000);
budgetQaAssert(budgetQaEngine($mode,$failure)->balance($failureBudget)['reserved_minor']===4000,'Recovery duplicated effect.');
// Revalidate host permission even when the command is an idempotent replay.
if ($mode==='compras') DB::table('users')->where('id',$f['validator'])->update(['purchasing_role'=>'warehouse']);
else DB::table('permission_grants')->where('user_id',$f['validator'])->where('permission','purchasing_budget.reinforcement.review')->delete();
budgetQaReject(fn()=>budgetQaEngine($mode,$f,'validator')->reviewReinforcement($rid,'approved','Replay after revocation'),'revoked reviewer replay');
$races=[];
$monitorConfig=DB::connection()->getConfig();
config(['database.connections.budget_monitor'=>$monitorConfig]);
$monitor=DB::connection('budget_monitor');
foreach (['over_reserve','same_reserve','same_commit','same_release'] as $scenario) {
$r = budgetQaFixture($mode); $rb = budgetQaCreate($mode,$r,10000); $second=$r;
if ($scenario==='over_reserve') {
if ($mode==='compras') {
budgetQaSelect($mode,$r);
$clone=\App\Models\Requisition::findOrFail($r['origin'])->replicate(); $clone->number='REQ-QA-'.bin2hex(random_bytes(6)); $clone->save(); $second['origin']=$clone->id;
} else $second['origin']=DB::table('cb_origins')->insertGetId($r['scope']+['cost_center_id'=>$r['centre'],'required_kind'=>'operating','reference'=>'REQ-RACE-'.bin2hex(random_bytes(6))]);
}
if (in_array($scenario,['same_commit','same_release'],true)) budgetQaRecord($mode,$r,'reserve',6000);
$kind=match($scenario){'same_commit'=>'commit','same_release'=>'release',default=>'reserve'};
$db=DB::connection(); $db->beginTransaction();
$db->table($mode==='compras'?'tenants':'companies')->where('id',$mode==='compras'?$r['tenant']:$r['company'])->lockForUpdate()->firstOrFail();
$processes=[];
try {
foreach ([$r,$second] as $i=>$fixture) {
$p=new Process([PHP_BINARY,__FILE__,$root,$mode,'--worker'],$root);
$p->setInput(json_encode(['fixture'=>$fixture,'label'=>'common_budget_'.$mode.'_'.$i,'kind'=>$kind,'amount'=>6000],JSON_THROW_ON_ERROR));
$p->setTimeout(35); $p->start(); $processes[]=$p;
}
$deadline=microtime(true)+15; $waits=0;
do {
$waits=(int)$monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in (?,?) and wait_event_type='Lock'",[$database,'common_budget_'.$mode.'_0','common_budget_'.$mode.'_1'])->n;
if ($waits===2) break;
foreach($processes as $p) if(!$p->isRunning()) throw new RuntimeException('Race worker exited before overlap: '.$p->getOutput().$p->getErrorOutput());
usleep(20000);
} while(microtime(true)<$deadline);
budgetQaAssert($waits===2,'Two overlapping PostgreSQL lock waits not observed.');
$db->commit(); $results=[];
foreach($processes as $p){$p->wait();budgetQaAssert($p->isSuccessful(),'Race worker failed: '.$p->getOutput().$p->getErrorOutput());$results[]=json_decode($p->getOutput(),true,32,JSON_THROW_ON_ERROR);}
$statuses=array_column($results,'status');sort($statuses);
budgetQaAssert($statuses===($scenario==='over_reserve'?[200,422]:[200,200]),'Race statuses differ.');
budgetQaAssert(count(array_unique(array_column($results,'pid')))===2,'Independent DB workers required.');
$b=budgetQaEngine($mode,$r)->balance($rb);
budgetQaAssert($b['available_minor']===($scenario==='same_release'?10000:4000),'Race duplicated budget effect.');
$races[]=['scenario'=>$scenario,'passed'=>true,'observed_lock_waits'=>$waits,'balance'=>$b];
} finally {
if($db->transactionLevel())$db->rollBack();
foreach($processes as $p)if($p->isRunning())$p->stop();
}
}
$reflection = new ReflectionClass(\Tempo\Finance\Budget\BudgetControl::class);
if($mode==='compras')budgetQaAssert(str_contains(str_replace('\\','/',$reflection->getFileName()),'common-budget-artifact-'),'Standalone did not load the detached package artifact.');
echo json_encode(['host'=>$mode,'passed'=>true,'detached_package_artifact'=>$mode==='compras','core_sha256'=>hash_file('sha256',$reflection->getFileName()),'steps'=>$steps,'races'=>$races,'checks'=>['independent_approval','partial_consumption','same_command_replay','changed_replay_rejected','committed_release_refused','reinforcement_pending_and_approved','tenant_isolation','release_once','net_tax_basis','audit_rollback_and_retry','revoked_reviewer_replay'],'sgf_context_checks'=>$mode==='sgf'?['foreign_source_refused','reserved_unit_refused','stale_context_refused']:[],'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
// QA fixture schema only. Never registered as an application migration.
\App\Modules\Purchasing\BudgetAdapter::guard();
foreach (['0001_01_01_000000_create_users_table.php', '2026_10_25_000000_create_identity_foundation.php', '2026_10_25_010000_add_identity_workflow.php'] as $migration) {
(require base_path('database/migrations/'.$migration))->up();
}
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR ((permission IN ('unit.view','unit.edit') OR permission IN ('purchasing_budget.create','purchasing_budget.approve','purchasing_budget.record','purchasing_budget.consume','purchasing_budget.view','purchasing_budget.reinforcement.propose','purchasing_budget.reinforcement.review')) AND unit_id IS NOT NULL))");
$scope = function (Blueprint $t): void {
$t->unsignedBigInteger('tenant_id');
$t->unsignedBigInteger('company_id');
$t->unsignedBigInteger('unit_id');
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
};
Schema::create('cb_centres', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->string('code');
$t->unique(['tenant_id','company_id','unit_id','code']);
});
Schema::create('cb_origins', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('cost_center_id')->constrained('cb_centres');
$t->string('required_kind'); $t->string('reference')->unique();
});
Schema::create('cb_budgets', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('cost_center_id')->constrained('cb_centres');
$t->integer('year'); $t->string('kind')->default('operating'); $t->bigInteger('amount_minor');
$t->boolean('includes_tax'); $t->text('reason');
$t->foreignId('created_by')->constrained('users'); $t->foreignId('approved_by')->nullable()->constrained('users');
$t->timestamp('approved_at')->nullable(); $t->timestamps();
$t->unique(['tenant_id','company_id','unit_id','cost_center_id','year']);
});
Schema::create('cb_budget_entries', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('budget_id')->constrained('cb_budgets');
$t->foreignId('requisition_id')->constrained('cb_origins'); $t->foreignId('created_by')->constrained('users');
$t->string('kind'); $t->bigInteger('reserved_minor'); $t->bigInteger('committed_minor'); $t->timestamp('created_at');
$t->unique(['requisition_id','kind']);
});
Schema::create('cb_budget_consumptions', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('budget_id')->constrained('cb_budgets');
// Only a synthetic source identifier, not a payable or invoice implementation.
$t->unsignedBigInteger('invoice_id')->unique(); $t->foreignId('created_by')->constrained('users');
$t->bigInteger('amount_minor'); $t->timestamp('created_at');
});
Schema::create('cb_budget_reinforcements', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('budget_id')->constrained('cb_budgets');
$t->foreignId('created_by')->constrained('users'); $t->foreignId('reviewed_by')->nullable()->constrained('users');
$t->string('reference'); $t->string('payload_hash',64); $t->bigInteger('amount_minor'); $t->text('reason');
$t->string('status')->default('pending'); $t->text('review_reason')->nullable(); $t->timestamp('reviewed_at')->nullable(); $t->timestamps();
$t->unique(['tenant_id','company_id','unit_id','reference']);
});
Schema::create('cb_audit', function (Blueprint $t) use ($scope) {
$t->id(); $scope($t); $t->foreignId('actor_id')->constrained('users'); $t->string('action');
$t->unsignedBigInteger('origin_id')->nullable(); $t->jsonb('payload'); $t->timestamp('created_at');
});