diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index d9c071e..e4b6d9a 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -4,13 +4,11 @@ namespace App\Http\Controllers; use App\Enums\PurchasingRole; use App\Models\Budget; -use App\Models\BudgetConsumption; -use App\Models\BudgetEntry; use App\Models\BudgetReinforcement; use App\Models\CostCenter; +use App\Services\CommonBudget; use App\Services\ConflictGuard; use App\Services\CurrentActor; -use App\Services\RequisitionWorkflow; use Brick\Math\BigDecimal; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -30,15 +28,10 @@ class BudgetController extends Controller { $this->access($request); - return Inertia::render('budgets/index', ['centers' => CostCenter::query()->orderBy('code')->get(), 'budgets' => Budget::query()->latest('id')->get()->map(function (Budget $budget) { - $entries = BudgetEntry::query()->where('budget_id', $budget->id); - $reserved = (int) (clone $entries)->sum('reserved_minor'); - $committed = (int) $entries->sum('committed_minor'); - $consumed = (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor'); + return Inertia::render('budgets/index', ['centers' => CostCenter::query()->orderBy('code')->get(), 'budgets' => Budget::query()->latest('id')->get()->map(function (Budget $budget) use ($request) { + $adapter = app(CommonBudget::class); - $total = $budget->approvedTotalMinor(); - - return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $budget->approved_by ? $total : 0, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0]; + return [...$budget->toArray(), ...$adapter->execute(fn () => $adapter->for($request->user())->balance($budget->id))]; }), 'reinforcements' => BudgetReinforcement::query()->latest('id')->paginate(20)]); } @@ -50,9 +43,8 @@ class BudgetController extends Controller DB::transaction(function () use ($request, $data, $kind) { app(ConflictGuard::class)->lockCompany($request->user()); app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]); - abort_if(Budget::query()->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists(), 409, 'Já existe dotação para este centro e exercício.'); - $budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'kind' => $kind, 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]); - app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray()); + $adapter = app(CommonBudget::class); + $adapter->execute(fn () => $adapter->for($request->user())->create(['cost_center_id' => (int) $data['cost_center_id'], 'year' => (int) $data['year'], 'kind' => $kind, 'includes_tax' => (bool) $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']])); }); return back(); @@ -64,13 +56,8 @@ class BudgetController extends Controller DB::transaction(function () use ($request, $budget) { app(ConflictGuard::class)->lockCompany($request->user()); app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]); - $budget = Budget::query()->lockForUpdate()->findOrFail($budget->id); - abort_if($budget->created_by === $request->user()->id, 403); - if ($budget->approved_by) { - return; - } - $budget->update(['approved_by' => $request->user()->id, 'approved_at' => now()]); - app(RequisitionWorkflow::class)->audit($request->user(), 'budget.approved', null, null, $budget->toArray()); + $adapter = app(CommonBudget::class); + $adapter->execute(fn () => $adapter->for($request->user())->approve($budget->id)); }); return back(); diff --git a/app/Models/Budget.php b/app/Models/Budget.php index db34ba7..75ea1a6 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -3,8 +3,8 @@ namespace App\Models; use App\Models\Concerns\BelongsToTenant; -use Brick\Math\BigDecimal; use Illuminate\Database\Eloquent\Model; +use Tempo\Finance\Budget\BudgetControl; /** @property int $id * @property int $amount_minor @@ -22,7 +22,7 @@ class Budget extends Model { $sum = BudgetReinforcement::query()->where('budget_id', $this->id)->where('status', 'approved')->sum('amount_minor'); - return BigDecimal::of((string) $sum)->plus($this->amount_minor)->toInt(); + return BudgetControl::approvedTotal((int) $this->amount_minor, (int) $sum); } protected static function booted(): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..2741457 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,7 +15,11 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $package = config('common-budget.path'); + if (! is_file($package.'/bootstrap.php') && ! $this->app->environment('production')) { + $package = base_path('../sgf-tempo/packages/budget-control'); + } + require_once $package.'/bootstrap.php'; } /** diff --git a/app/Services/BudgetReinforcementWorkflow.php b/app/Services/BudgetReinforcementWorkflow.php index 8c21689..d502d1d 100644 --- a/app/Services/BudgetReinforcementWorkflow.php +++ b/app/Services/BudgetReinforcementWorkflow.php @@ -2,100 +2,29 @@ namespace App\Services; -use App\Enums\PurchasingRole; use App\Models\Budget; use App\Models\BudgetReinforcement; use App\Models\User; use Brick\Math\BigDecimal; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -use Illuminate\Validation\ValidationException; class BudgetReinforcementWorkflow { - private function actor(User $user, int $tenantId): void - { - abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403); - } - - private function valid(Budget $budget, int $amount): void - { - if (! $budget->approved_by || (int) $budget->year !== now()->year || $amount <= 0) { - throw ValidationException::withMessages(['reinforcement' => 'Exige dotação aprovada do exercício actual e montante positivo.']); - } - // Same technical monetary ceiling as requisitions; not a company approval threshold. - if (BigDecimal::of($budget->approvedTotalMinor())->plus($amount)->isGreaterThan('100000000000000')) { - throw ValidationException::withMessages(['reinforcement' => 'A dotação acumulada ultrapassa o limite técnico de valores suportados.']); - } - } - /** @param array $data */ public function propose(User $user, Budget $budget, array $data): BudgetReinforcement { - $this->actor($user, $budget->tenant_id); + abort_unless($user->tenant_id === $budget->tenant_id, 403); + $adapter = app(CommonBudget::class); + $amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt(); + $id = $adapter->execute(fn () => $adapter->for($user)->proposeReinforcement($budget->id, $amount, Str::upper(Str::squish($data['reference'])), trim($data['reason']))); - return DB::transaction(function () use ($user, $budget, $data) { - app(ConflictGuard::class)->lockCompany($user); - $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); - $this->actor($user, $budget->tenant_id); - $budget = Budget::query()->lockForUpdate()->findOrFail($budget->id); - $amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt(); - $reference = Str::upper(Str::squish($data['reference'])); - $reason = trim($data['reason']); - $hash = hash('sha256', json_encode(['budget_id' => $budget->id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR)); - $existing = BudgetReinforcement::query()->where('reference', $reference)->first(); - if ($existing) { - if ($existing->created_by !== $user->id || $existing->payload_hash !== $hash) { - throw ValidationException::withMessages(['reference' => 'Referência já registada com outros dados.']); - } - - return $existing; - } - $this->valid($budget, $amount); - if ($reference === '' || $reason === '') { - throw ValidationException::withMessages(['reinforcement' => 'Referência e fundamento obrigatórios.']); - } - $reinforcement = BudgetReinforcement::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'created_by' => $user->id, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason]); - $this->audit($user, $reinforcement, 'budget.reinforcement_proposed'); - - return $reinforcement; - }); + return BudgetReinforcement::query()->findOrFail($id); } public function review(User $user, BudgetReinforcement $reinforcement, string $decision, string $reason): void { - $this->actor($user, $reinforcement->tenant_id); - abort_if($user->id === $reinforcement->created_by, 403); - if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') { - throw ValidationException::withMessages(['reinforcement' => 'Decisão e fundamento obrigatórios.']); - } - DB::transaction(function () use ($user, $reinforcement, $decision, $reason) { - app(ConflictGuard::class)->lockCompany($user); - $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); - $this->actor($user, $reinforcement->tenant_id); - $budget = Budget::query()->lockForUpdate()->findOrFail($reinforcement->budget_id); - $reinforcement = BudgetReinforcement::query()->lockForUpdate()->findOrFail($reinforcement->id); - if ($reinforcement->status !== 'pending') { - if ($reinforcement->status !== $decision) { - throw ValidationException::withMessages(['reinforcement' => 'Decisão final imutável.']); - } - - return; - } - if ($decision === 'approved') { - $this->valid($budget, $reinforcement->amount_minor); - $author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($reinforcement->created_by); - if (! $author || ! in_array($author->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) { - throw ValidationException::withMessages(['reinforcement' => 'Autor da proposta deixou de ser elegível.']); - } - } - $reinforcement->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => trim($reason), 'reviewed_at' => now()]); - $this->audit($user, $reinforcement, 'budget.reinforcement_'.$decision); - }); - } - - private function audit(User $user, BudgetReinforcement $reinforcement, string $action): void - { - app(RequisitionWorkflow::class)->audit($user, $action, null, null, ['reinforcement_id' => $reinforcement->id, 'budget_id' => $reinforcement->budget_id, 'amount_minor' => $reinforcement->amount_minor, 'status' => $reinforcement->status]); + abort_unless($user->tenant_id === $reinforcement->tenant_id, 403); + $adapter = app(CommonBudget::class); + $adapter->execute(fn () => $adapter->for($user)->reviewReinforcement($reinforcement->id, $decision, $reason)); } } diff --git a/app/Services/BudgetWorkflow.php b/app/Services/BudgetWorkflow.php index 7bbbdd7..a3c5e65 100644 --- a/app/Services/BudgetWorkflow.php +++ b/app/Services/BudgetWorkflow.php @@ -2,68 +2,25 @@ namespace App\Services; -use App\Models\Budget; -use App\Models\BudgetConsumption; -use App\Models\BudgetEntry; use App\Models\Requisition; use App\Models\User; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class BudgetWorkflow { - /** Called inside the procurement transaction, after the company lock. - * @return array|null - */ + /** @return array|null */ public function record(User $user, Requisition $request, string $kind, int $amount = 0, ?int $subtotal = null): ?array { abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $request->tenant_id === $user->tenant_id, 403); if (DB::connection()->transactionLevel() === 0) { throw new \LogicException('Movimentos de orçamento exigem transacção.'); } - app(ConflictGuard::class)->lockCompany($user); - $existing = BudgetEntry::query()->where('requisition_id', $request->id)->orderBy('id')->first(); - $budget = $existing - ? Budget::query()->lockForUpdate()->findOrFail($existing->budget_id) - : Budget::query()->where('cost_center_id', $request->cost_center_id)->where('year', now()->year)->lockForUpdate()->first(); - // Unconfigured legacy processes remain development documents, explicitly unchecked. - if (! $budget) { - return null; - } - $requiredKind = $request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating'; - if ($budget->kind !== $requiredKind) { - throw ValidationException::withMessages(['budget' => '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 !== now()->year)) { - throw ValidationException::withMessages(['budget' => 'Dotação pendente de aprovação ou exercício encerrado.']); - } - if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'])) { - $amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.'); - } - if (BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', $kind)->exists()) { - return ['budget_id' => $budget->id, 'year' => (int) $budget->year]; - } - $own = BudgetEntry::query()->where('requisition_id', $request->id); - $reserved = (int) (clone $own)->sum('reserved_minor'); - $committed = (int) $own->sum('committed_minor'); - if ($committed !== 0 || ($kind !== 'reserve' && ! $existing)) { - throw ValidationException::withMessages(['budget' => 'Processo sem reserva válida ou já comprometido.']); - } - [$reserveDelta, $commitDelta] = match ($kind) { - 'reserve', 'award' => [$amount - $reserved, 0], - 'release' => [-$reserved, 0], - 'commit' => [-$reserved, $amount], - default => throw new \LogicException('Movimento orçamental inválido.'), - }; - $ledger = BudgetEntry::query()->where('budget_id', $budget->id); - $used = (int) (clone $ledger)->sum('reserved_minor') + (int) $ledger->sum('committed_minor'); - $used += (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor'); - if ($used + $reserveDelta + $commitDelta > $budget->approvedTotalMinor()) { - throw ValidationException::withMessages(['budget' => 'Saldo orçamental insuficiente. Não são permitidos excessos.']); - } - BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => $kind, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta, 'created_at' => now()]); - app(RequisitionWorkflow::class)->audit($user, 'budget.'.$kind, $request, null, ['budget_id' => $budget->id, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta]); + $adapter = app(CommonBudget::class); - return ['budget_id' => $budget->id, 'year' => (int) $budget->year]; + return $adapter->execute(fn () => $adapter->for($user)->record( + $request->id, $request->cost_center_id, + $request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating', + $kind, $amount, $subtotal, + )); } } diff --git a/app/Services/CommonBudget.php b/app/Services/CommonBudget.php new file mode 100644 index 0000000..ee99ec4 --- /dev/null +++ b/app/Services/CommonBudget.php @@ -0,0 +1,58 @@ +id() === $user->id && $user->tenant_id !== null, 403); + abort_unless(DB::table('tenants')->where('id', $user->tenant_id)->value('currency') === 'MZN', 422, 'Controlo comum desta entrega suporta apenas MZN; câmbio permanece pendente.'); + $context = new BudgetContext($user->id, ['tenant_id' => (int) $user->tenant_id], (int) now()->year, 'MZN'); + + return new BudgetControl(DB::connection(), $context, '', + function (string $action) use ($user): void { + app(ConflictGuard::class)->lockCompany($user); + abort_unless(DB::table('tenants')->where('id', $user->tenant_id)->value('currency') === 'MZN', 422, 'Moeda da organização incompatível com esta entrega.'); + $roles = in_array($action, ['create', 'approve', 'reinforcement.propose', 'reinforcement.review', 'view'], true) + ? [PurchasingRole::Admin, PurchasingRole::Finance] + : ($action === 'consume' ? [PurchasingRole::Finance] : null); + app(CurrentActor::class)->lock($user, $roles); + }, + function (string $action, array $data, ?int $origin) use ($user): void { + // InvoiceWorkflow retains invoice.validated in the enclosing transaction. + if ($action !== 'budget.consumed') { + $request = $origin ? Requisition::query()->findOrFail($origin) : null; + app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data); + } + }, + fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(), + ); + } + + /** + * @template T + * + * @param callable(): T $command + * @return T + */ + public function execute(callable $command): mixed + { + try { + return $command(); + } catch (BudgetFailure $e) { + abort_if($e->status !== 422, $e->status, $e->getMessage()); + throw ValidationException::withMessages([$e->field => $e->getMessage()]); + } + } +} diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php index f14ff4c..98b730e 100644 --- a/app/Services/InvoiceWorkflow.php +++ b/app/Services/InvoiceWorkflow.php @@ -3,9 +3,6 @@ namespace App\Services; use App\Enums\PurchasingRole; -use App\Models\Budget; -use App\Models\BudgetConsumption; -use App\Models\BudgetEntry; use App\Models\GoodsReceiptItem; use App\Models\Invoice; use App\Models\InvoiceItem; @@ -130,23 +127,10 @@ class InvoiceWorkflow $this->invalid('Conferência alterada por outra factura; a validação está bloqueada.'); } } - $commit = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'commit')->first(); - if (! $commit) { - $this->invalid('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.'); - } - $budget = Budget::query()->lockForUpdate()->findOrFail($commit->budget_id); - if (! $budget->approved_by || (int) $budget->year !== now()->year) { - $this->invalid('Orçamento não aprovado ou fora do exercício.'); - } - $amount = (int) ($budget->includes_tax ? $invoice->total_minor : $invoice->subtotal_minor); - $remaining = (int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor'); - if ($amount > $remaining) { - $this->invalid('Compromisso insuficiente para a factura.'); - } - BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => 'invoice-'.$invoice->id, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => now()]); - BudgetConsumption::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'invoice_id' => $invoice->id, 'created_by' => $user->id, 'amount_minor' => $amount, 'created_at' => now()]); + $adapter = app(CommonBudget::class); + $effect = $adapter->execute(fn () => $adapter->for($user)->consume($request->id, $invoice->id, (int) $invoice->total_minor, (int) $invoice->subtotal_minor)); $invoice->update(['status' => 'validated', 'validated_by' => $user->id, 'validated_at' => now()]); - app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id, 'budget_id' => $budget->id, 'consumed_minor' => $amount]); + app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id] + $effect); }); } } diff --git a/config/common-budget.php b/config/common-budget.php new file mode 100644 index 0000000..718ba15 --- /dev/null +++ b/config/common-budget.php @@ -0,0 +1,5 @@ + env('COMMON_BUDGET_PATH', base_path('packages/budget-control')), +]; diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md index 7582958..0456fe6 100644 --- a/docs/compatibilidade.md +++ b/docs/compatibilidade.md @@ -1,5 +1,11 @@ # Compatibilidade e estado do SGC +**Aceitação posterior, 29/10/2026:** extracção técnica do núcleo comum aceite e checkpoint exclusivamente local autorizado. [Âmbito e pendências](../../sgf-tempo/docs/COMMON_BUDGET_ACCEPTANCE.md): visual Compras, interface do percurso SGF e cobertura MFA permanecem pendentes; nenhuma utilização real habilitada. + +**Controlo orçamental comum, 29/10/2026:** dotação/reforços/ledger/consumo agora delegam numa implementação única, com equivalência nos dois hosts sintéticos. Ver [entrega, provas e limitações](../../sgf-tempo/docs/COMMON_BUDGET_DELIVERY.md). Não significa Compras completo instalado no SGF nem activação operacional; restantes estados/aceitações preservados. + +**Orientação posterior de 29/10/2026:** um módulo de Compras, um núcleo financeiro comum e duas composições comerciais. Preservar os percursos desta matriz; a independência comercial não exige outro motor financeiro ou SGF remoto. O [plano de referência](../../sgf-tempo/docs/PURCHASING_COMMON_CORE_PLAN.md) mapeia capacidades/efeitos e delimita o primeiro incremento sintético; não declara extracção concluída. O estado funcional mais recente deve ser lido em `validacao-tecnica.md`, incluindo recomendação/000020, posterior a resumos 000017 abaixo. + Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\compras, Laravel + React + PostgreSQL, http://127.0.0.1:8015. Preservar conta, dados, configuração, cookie e porta. SGF Tempo e Investmoz são independentes. A implementação Python separada fica apenas como referência; não importar contas, sessões ou bases experimentais. | Área | Implementado no Laravel | Pendente | diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md index fde5cbc..6e5ea26 100644 --- a/docs/orientacoes-continuidade.md +++ b/docs/orientacoes-continuidade.md @@ -1,5 +1,17 @@ # Continuidade do desenvolvimento do SGC +**Aceitação técnica de 29/10/2026:** a Tempo aceitou a extracção `COMMON-BUDGET-SYNTHETIC-v1` e autorizou checkpoint exclusivamente local nos dois repositórios, sem uso real/push/publicação. Ver [aceitação](../../sgf-tempo/docs/COMMON_BUDGET_ACCEPTANCE.md). Revisão visual Compras pendente; SGF apenas adaptador QA sem interface deste percurso; dois testes MFA ignorados continuam pendentes. Esta aceitação substitui apenas a pendência de encerramento técnico abaixo. Próximo passo: percurso sintético integrado SGF, sem bloquear desenvolvimento pela automação do navegador. + +**Execução posterior em 29/10/2026:** `COMMON-BUDGET-SYNTHETIC-v1` implementado, com fonte única no pacote SGF, adaptadores locais e duas bases descartáveis. Ver [relatório técnico](../../sgf-tempo/docs/COMMON_BUDGET_DELIVERY.md). Conservar as alterações e os percursos; sem aceitação presumida, checkpoint de encerramento, migração/concessões/dados reais, publicação ou push. Não avançar para obrigações/pagamentos/contabilidade por arrasto. + +## Orientação de produto confirmada — 29/10/2026 + +Decisão expressa da Tempo: um módulo de Compras, um núcleo financeiro comum e duas composições comerciais — ERP SGF com Compras e Compras autónomo com as capacidades comuns necessárias. O cliente de Compras não precisa de adoptar a facturação de vendas SGF nem de um SGF remoto. Esta orientação substitui a proposta de duas aplicações ligadas por conector remoto; as referências históricas à independência do SGF não impedem o núcleo comum. Preservar todos os percursos, dados, contas, decisões, versões, testes e alterações posteriores; não copiar o motor para manter duas implementações. + +O desenho de referência está em [Compras e núcleo financeiro comum](../../sgf-tempo/docs/PURCHASING_COMMON_CORE_PLAN.md), no projecto SGF. Inclui mapa de preservação, capacidades existentes/em falta, responsabilidade única pelos efeitos e transição. Primeiro incremento delimitado: `COMMON-BUDGET-SYNTHETIC-v1`, extracção do controlo orçamental existente e prova nos dois hosts sintéticos, sem dependência de Sales. Nesta passagem só documentação; reestruturação segue esse plano. Pessoas reais/concessões empresariais pertencem à implantação, não são dependências para construir/testar. Não executar seeds ou migrações reais por consequência desta orientação. + +Para o estado funcional actual, consultar `docs/validacao-tecnica.md`, que já descreve recomendação e migração 000020; o resumo abaixo até 000017 é anterior. A orientação arquitectural não reabre aceitações nem declara novas provas realizadas. + ## Orientação actual após abertura e actas — 28 de Outubro de 2026 Referência consolidada: `07e775c`; consultar primeiro diff --git a/tests/Concurrency/run-common-budget.php b/tests/Concurrency/run-common-budget.php new file mode 100644 index 0000000..4fce5f8 --- /dev/null +++ b/tests/Concurrency/run-common-budget.php @@ -0,0 +1,50 @@ +'budget-coordinator','DB_CONNECTION'=>'pgsql','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array'] as $key=>$value) { putenv($key.'='.$value); $_ENV[$key]=$_SERVER[$key]=$value; } +$app=require __DIR__.'/../../bootstrap/app.php'; +$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use Illuminate\Support\Facades\DB; +use Symfony\Component\Process\Process; +$source=DB::connection('pgsql')->getConfig(); +if(($source['driver']??'')!=='pgsql'||($source['host']??'')!=='127.0.0.1')throw new LogicException('Local PostgreSQL maintenance connection required.'); +$maintenance=[...$source,'database'=>'postgres','url'=>null]; +config(['database.connections.budget_admin'=>$maintenance]); +$admin=DB::connection('budget_admin')->getPdo(); +$sgf=realpath(base_path('../sgf-tempo')); +$script=$sgf.'/packages/budget-control/tests/run-host.php'; +$report=['version'=>'COMMON-BUDGET-SYNTHETIC-v1','checked_at'=>gmdate('c'),'client_databases_touched'=>false,'hosts'=>[]]; +$artifact=sys_get_temp_dir().'/common-budget-artifact-'.bin2hex(random_bytes(6)); +if(!mkdir($artifact.'/src',0700,true))throw new RuntimeException('Cannot prepare isolated package artifact.'); +$artifactFiles=['bootstrap.php','src/BudgetContext.php','src/BudgetFailure.php','src/BudgetControl.php']; +foreach($artifactFiles as $file)if(!copy($sgf.'/packages/budget-control/'.$file,$artifact.'/'.$file))throw new RuntimeException('Cannot build package artifact.'); +try { +foreach(['compras','sgf'] as $mode){ + $db=($mode==='compras'?'compras_receipts_test_':'budget_core_sgf_').bin2hex(random_bytes(6)); + $created=false; + try{ + $admin->exec('CREATE DATABASE "'.$db.'"');$created=true; + $environment=['APP_ENV'=>$mode==='compras'?'testing':'budget-testing','DB_CONNECTION'=>'pgsql','DB_DATABASE'=>$db,'DB_URL'=>'','DB_HOST'=>'127.0.0.1','DB_PORT'=>(string)$source['port'],'DB_USERNAME'=>$source['username'],'DB_PASSWORD'=>$source['password'],'CACHE_STORE'=>'array','SESSION_DRIVER'=>'array','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array','BCRYPT_ROUNDS'=>'4','COMMON_BUDGET_PATH'=>$mode==='compras'?$artifact:$sgf.'/packages/budget-control']; + $php=$mode==='compras'?PHP_BINARY:'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe'; + $root=$mode==='compras'?base_path():$sgf; + $process=new Process([$php,$script,$root,$mode],$root,$environment);$process->setTimeout(120);$process->run(); + if(!$process->isSuccessful())throw new RuntimeException('QA host failed: '.$process->getOutput().$process->getErrorOutput()); + $report['hosts'][]=json_decode($process->getOutput(),true,64,JSON_THROW_ON_ERROR); + }finally{ + if(isset($process)&&$process->isRunning())$process->stop(); + if($created){ + $stmt=$admin->prepare('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=? AND pid<>pg_backend_pid()');$stmt->execute([$db]); + $admin->exec('DROP DATABASE "'.$db.'"'); + } + } +} +} finally { + // Delete only the exact files created above, never a recursive or computed project path. + foreach($artifactFiles as $file)if(is_file($artifact.'/'.$file))unlink($artifact.'/'.$file); + rmdir($artifact.'/src');rmdir($artifact); +} +if($report['hosts'][0]['core_sha256']!==$report['hosts'][1]['core_sha256']||$report['hosts'][0]['steps']!==$report['hosts'][1]['steps'])throw new RuntimeException('Host equivalence failed.'); +$report['equivalent']=true;$report['disposable_databases_dropped']=true; +file_put_contents($sgf.'/docs/proofs/common-budget-hosts.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL); +echo "Two Laravel hosts passed the same core sequence and PostgreSQL concurrency checks. Disposable databases dropped.\n";