diff --git a/AGENTS.md b/AGENTS.md index 5f459dc..3e9dd9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/Modules/Purchasing/BudgetAdapter.php b/app/Modules/Purchasing/BudgetAdapter.php new file mode 100644 index 0000000..56d7a06 --- /dev/null +++ b/app/Modules/Purchasing/BudgetAdapter.php @@ -0,0 +1,82 @@ +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); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7f315a2..360143f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); } diff --git a/docs/COMMON_BUDGET_ACCEPTANCE.md b/docs/COMMON_BUDGET_ACCEPTANCE.md new file mode 100644 index 0000000..ab92ba8 --- /dev/null +++ b/docs/COMMON_BUDGET_ACCEPTANCE.md @@ -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. diff --git a/docs/COMMON_BUDGET_DELIVERY.md b/docs/COMMON_BUDGET_DELIVERY.md new file mode 100644 index 0000000..eedb0a2 --- /dev/null +++ b/docs/COMMON_BUDGET_DELIVERY.md @@ -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. diff --git a/docs/PURCHASING_COMMON_CORE_PLAN.md b/docs/PURCHASING_COMMON_CORE_PLAN.md new file mode 100644 index 0000000..bfa0bd3 --- /dev/null +++ b/docs/PURCHASING_COMMON_CORE_PLAN.md @@ -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. diff --git a/docs/proofs/common-budget-hosts.json b/docs/proofs/common-budget-hosts.json new file mode 100644 index 0000000..ce13ed6 --- /dev/null +++ b/docs/proofs/common-budget-hosts.json @@ -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 +} diff --git a/packages/budget-control/README.md b/packages/budget-control/README.md new file mode 100644 index 0000000..35abad1 --- /dev/null +++ b/packages/budget-control/README.md @@ -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. diff --git a/packages/budget-control/bootstrap.php b/packages/budget-control/bootstrap.php new file mode 100644 index 0000000..f780651 --- /dev/null +++ b/packages/budget-control/bootstrap.php @@ -0,0 +1,6 @@ + $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.'); + } + } + } +} diff --git a/packages/budget-control/src/BudgetControl.php b/packages/budget-control/src/BudgetControl.php new file mode 100644 index 0000000..ef5db7f --- /dev/null +++ b/packages/budget-control/src/BudgetControl.php @@ -0,0 +1,291 @@ +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|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))); + } +} diff --git a/packages/budget-control/src/BudgetFailure.php b/packages/budget-control/src/BudgetFailure.php new file mode 100644 index 0000000..498e987 --- /dev/null +++ b/packages/budget-control/src/BudgetFailure.php @@ -0,0 +1,11 @@ +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); +} diff --git a/packages/budget-control/tests/run-host.php b/packages/budget-control/tests/run-host.php new file mode 100644 index 0000000..80325b9 --- /dev/null +++ b/packages/budget-control/tests/run-host.php @@ -0,0 +1,145 @@ +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; diff --git a/packages/budget-control/tests/sgf-schema.php b/packages/budget-control/tests/sgf-schema.php new file mode 100644 index 0000000..9329e48 --- /dev/null +++ b/packages/budget-control/tests/sgf-schema.php @@ -0,0 +1,59 @@ +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'); +});