Accept technical extraction of shared budget core with QA limits
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user