This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
@@ -0,0 +1,36 @@
<?php
namespace Tempo\Purchasing\Delivery;
final class BootOverlay
{
public static function prepare(string $host): array
{
$result = [];
foreach (['app/Providers/AppServiceProvider.php', 'app/Shared/Context/ModuleNavigation.php'] as $path) {
$source = file_get_contents($host.'/'.$path);
$hash = hash('sha256', $source);
$source = str_replace("\r\n", "\n", $source);
if (str_ends_with($path, 'AppServiceProvider.php')) {
$old = '$this->app->scoped(CompanyContext::class';
$new = "if (is_file(base_path('packages/internal-requisitions/bootstrap.php'))) { require_once base_path('packages/internal-requisitions/bootstrap.php'); }\n ".$old;
$source = self::replace($source, $old, $new);
$source = self::replace($source, "public function boot(): void\n {", "public function boot(): void\n {\n if (is_file(base_path('packages/internal-requisitions/bootstrap.php'))) { \$this->loadRoutesFrom(base_path('packages/internal-requisitions/integrations/sgf/operational/routes.php')); }");
} else {
$source = self::replace($source, "'purchasing'=>['purchasing_budget.', 'supplier_payables.']", "'purchasing'=>['purchasing_budget.', 'purchasing_requisitions.', 'supplier_payables.']");
}
$result[$path] = ['before_sha256' => $hash, 'after_sha256' => hash('sha256', $source), 'contents' => $source];
}
return $result;
}
private static function replace(string $source, string $old, string $new): string
{
if (substr_count($source, $old) !== 1) {
throw new \RuntimeException('Arranque/navegação do host divergiu; reconciliar antes de preparar.');
}
return str_replace($old, $new, $source);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Tempo\Purchasing\Delivery;
final class BudgetOverlay
{
public static function prepare(string $host): array
{
$path = 'app/Modules/Purchasing/BudgetAdapter.php';
$source = file_get_contents($host.'/'.$path);
$before = hash('sha256', $source);
$anchor = "public static function guard(): void\n {";
$source = str_replace("\r\n", "\n", $source);
if (substr_count($source, $anchor) !== 1) {
throw new \RuntimeException('Adaptador orçamental divergente.');
}
$source = str_replace($anchor, $anchor."\n if (config('purchasing-operational.enabled') === true) {\n \\Tempo\\Purchasing\\Delivery\\Installation::assertBudgetReady(DB::connection());\n return;\n }", $source);
return [$path => ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source]];
}
}
@@ -0,0 +1,97 @@
<?php
namespace Tempo\Purchasing\Delivery;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Blueprint;
/** Host schema preserving the existing SGF common-budget contract; no business motor or fixtures. */
final class BudgetSchema
{
public static function create(ConnectionInterface $db): void
{
$schema = $db->getSchemaBuilder();
$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');
});
}
}
@@ -0,0 +1,23 @@
<?php
namespace Tempo\Purchasing\Delivery;
use Illuminate\Support\Facades\Route;
final class Composition
{
public static function routes(string $bundle): void
{
$previous = [];
foreach (Route::getRoutes() as $route) {
$previous[spl_object_id($route)] = true;
}
require $bundle.'/integrations/sgf/internal-requisitions/routes.php';
require $bundle.'/integrations/sgf/access/routes.php';
foreach (Route::getRoutes() as $route) {
if (! isset($previous[spl_object_id($route)])) {
$route->middleware(RuntimeContext::class);
}
}
}
}
@@ -0,0 +1,120 @@
<?php
namespace Tempo\Purchasing\Delivery;
use App\Shared\Context\CompanyContext;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Schema\Blueprint;
use Tempo\Purchasing\SgfCandidate\Schema as RequisitionSchema;
/** Schema preparation is deliberately independent of route/code activation and grants. */
final class Installation
{
public const TABLES = ['cb_centres', 'cb_origins', 'cb_budgets', 'cb_budget_entries', 'cb_budget_consumptions', 'cb_budget_reinforcements', 'cb_audit'];
public static function target(ConnectionInterface $db): string
{
if ($db->getDriverName() !== 'pgsql' || $db->selectOne('select current_database() as name')->name !== $db->getDatabaseName()) {
throw new \RuntimeException('Destino PostgreSQL não conferido.');
}
$server = $db->selectOne('select inet_server_addr()::text as address, inet_server_port() as port');
return hash('sha256', json_encode([$db->getDriverName(), $db->getConfig('host'), (string) $db->getConfig('port'), $db->getDatabaseName(), $server->address, $server->port], JSON_THROW_ON_ERROR));
}
public static function migrationHash(): string
{
return hash('sha256', file_get_contents(__DIR__.'/BudgetSchema.php').file_get_contents(__DIR__.'/budget-contract.json').file_get_contents(dirname(__DIR__).'/internal-requisitions/Schema.php'));
}
public static function signature(ConnectionInterface $db, array $tables): string
{
sort($tables);
$bindings = implode(',', array_fill(0, count($tables), '?'));
return hash('sha256', json_encode([
$db->select("select table_name,column_name,data_type,character_maximum_length,is_nullable,ordinal_position from information_schema.columns where table_schema='public' and table_name in ($bindings) order by table_name,ordinal_position", $tables),
$db->select("select c.relname,k.conname,pg_get_constraintdef(k.oid) as definition from pg_constraint k join pg_class c on c.oid=k.conrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ($bindings) order by c.relname,k.conname", $tables),
$db->select("select c.relname,t.tgname,pg_get_triggerdef(t.oid) as definition from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relname in ($bindings) and not t.tgisinternal order by c.relname,t.tgname", $tables),
], JSON_THROW_ON_ERROR));
}
public static function tables(): array
{
return [...self::TABLES, 'permission_grants', 'ir_requests', 'ir_steps', 'ir_events', 'ir_commands', 'ir_policies', 'ir_policy_versions'];
}
public static function migrate(ConnectionInterface $db, string $expectedTarget, string $backupHash, string $operator): array
{
if (! hash_equals(self::target($db), $expectedTarget) || ! preg_match('/^[a-f0-9]{64}$/D', $backupHash) || trim($operator) === '') {
throw new \RuntimeException('Destino, backup ou operador não conferidos.');
}
return $db->transaction(function () use ($db, $backupHash, $operator) {
$db->select("select pg_advisory_xact_lock(hashtext('tempo.internal-requisitions.install.v1'))");
$schema = $db->getSchemaBuilder();
if ($schema->hasTable('sgc_installations')) {
$old = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first();
if (! $old || $old->revision !== 1 || ! hash_equals($old->migration_sha256, self::migrationHash()) || ! hash_equals($old->schema_signature, self::signature($db, self::tables()))) {
throw new \RuntimeException('Instalação/schema divergente; reconciliar sem sobrescrever.');
}
return ['prepared' => true, 'replayed' => true, 'activated' => false];
}
foreach (['users', 'tenants', 'companies', 'units', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
if (! $schema->hasTable($table)) {
throw new \RuntimeException('Fundação de identidade ausente: '.$table);
}
}
$budgetTables = array_filter(self::TABLES, fn ($table) => $schema->hasTable($table));
if ($budgetTables !== [] && count($budgetTables) !== count(self::TABLES)) {
throw new \RuntimeException('Schema orçamental parcial; não completar às cegas.');
}
if ($budgetTables === []) {
BudgetSchema::create($db);
}
$contract = json_decode(file_get_contents(__DIR__.'/budget-contract.json'), true, 16, JSON_THROW_ON_ERROR);
if (! hash_equals($contract['schema_signature'], self::signature($db, self::TABLES))) {
throw new \RuntimeException('Schema orçamental incompatível com o contrato aceite.');
}
$constraint = $db->selectOne("select pg_get_constraintdef(oid) as definition from pg_constraint where conrelid='permission_grants'::regclass and conname='permission_grants_scope_check'");
if (! $constraint || ! preg_match('/^CHECK\s*\((.*)\)$/s', $constraint->definition, $parts)) {
throw new \RuntimeException('Catálogo de âmbito desconhecido.');
}
$budgetRights = array_values(array_filter(AccessOverlay::RIGHTS, fn ($right) => str_starts_with($right, 'purchasing_budget.')));
if (array_filter($budgetRights, fn ($right) => ! str_contains($constraint->definition, "'".$right."'")) !== []) {
$literals = implode(',', array_map(fn ($right) => $db->getPdo()->quote($right).'::text', $budgetRights));
$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 (({$parts[1]}) OR (unit_id IS NOT NULL AND permission::text IN ($literals)))");
}
RequisitionSchema::install($db);
$schema->create('sgc_installations', function (Blueprint $table) {
$table->string('module')->primary();
$table->integer('revision');
$table->string('schema_signature', 64);
$table->string('migration_sha256', 64);
$table->string('backup_sha256', 64);
$table->string('operator');
$table->string('database_operator');
$table->timestampTz('prepared_at');
});
$db->table('sgc_installations')->insert(['module' => 'internal-requisitions', 'revision' => 1, 'schema_signature' => self::signature($db, self::tables()), 'migration_sha256' => self::migrationHash(), 'backup_sha256' => $backupHash, 'operator' => $operator, 'database_operator' => $db->selectOne('select current_user as name')->name, 'prepared_at' => now()]);
$db->unprepared("CREATE FUNCTION sgc_installations_immutable() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'Preserve installation history'; END; \$\$; CREATE TRIGGER sgc_installations_immutable BEFORE UPDATE OR DELETE ON sgc_installations FOR EACH ROW EXECUTE FUNCTION sgc_installations_immutable();");
return ['prepared' => true, 'replayed' => false, 'activated' => false];
});
}
public static function assertBudgetReady(ConnectionInterface $db): void
{
abort_unless(config('purchasing-operational.enabled') === true, 403);
abort_unless(hash_equals((string) config('purchasing-operational.target', ''), self::target($db)), 403, 'Destino operacional divergente.');
$context = app(CompanyContext::class);
$scope = $context->identifiers() + ['unit_id' => $context->unitId()];
abort_unless($scope['unit_id'] !== null && in_array(implode(':', $scope), config('purchasing-operational.scopes', []), true), 403, 'Opção interna não configurada para esta unidade.');
abort_unless($db->getSchemaBuilder()->hasTable('sgc_installations'), 503, 'Schema operacional não preparado.');
$row = $db->table('sgc_installations')->where('module', 'internal-requisitions')->first();
abort_unless($row && $row->revision === 1 && hash_equals($row->migration_sha256, self::migrationHash()) && hash_equals($row->schema_signature, self::signature($db, self::tables())), 503, 'Schema operacional não preparado.');
}
}
@@ -0,0 +1,34 @@
<?php
namespace Tempo\Purchasing\Delivery;
use App\Shared\Context\CompanyContext;
use App\Shared\Context\ModuleNavigation;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class RuntimeContext
{
public function handle(Request $request, Closure $next): mixed
{
abort_unless(config('purchasing-operational.enabled') === true, 404);
$context = app(CompanyContext::class);
Installation::assertBudgetReady(DB::connection());
$scope = $context->identifiers();
$policy = config('modules.companies.'.$scope['tenant_id'].':'.$scope['company_id'], []);
$contracted = $policy['contracted'] ?? config('modules.contracted', []);
abort_unless(in_array('purchasing', $contracted, true) && in_array('purchasing', $policy['enabled'] ?? $contracted, true), 403);
$initialisation = $request->is('purchasing/internal-requisitions/access-initialisation', 'api/internal-requisitions/access-initialisation');
if (! $initialisation) {
abort_unless($request->session()->get('company_context.module_id') === 'purchasing', 409, 'Seleccione o módulo Compras.');
abort_unless(in_array('purchasing', array_column(app(ModuleNavigation::class)->available($context), 'id'), true), 403);
}
if ($request->is('api/*')) {
$token = (string) $request->session()->get('company_context.token', '');
abort_unless($token !== '' && hash_equals($token, (string) $request->header('X-SGF-Context')), 409);
}
return $next($request);
}
}
@@ -0,0 +1,7 @@
<?php
// Explicit deployment composition; never required by the installed SGF provider.
require_once __DIR__.'/BudgetSchema.php';
require_once dirname(__DIR__).'/access/AccessOverlay.php';
require_once __DIR__.'/Installation.php';
require_once dirname(__DIR__).'/internal-requisitions/bootstrap.php';
@@ -0,0 +1,4 @@
{
"schema_signature": "44f37aa636b110921737b7dcabf9344c9332cff1d1e7e7f047d6128208939d69",
"origin": "Existing accepted SGF common-budget host schema; no data or credentials"
}
+6
View File
@@ -0,0 +1,6 @@
<?php
// Copied as <local bundle>/bootstrap.php, without a source-project dependency.
require_once __DIR__.'/integrations/sgf/operational/bootstrap.php';
require_once __DIR__.'/integrations/sgf/operational/RuntimeContext.php';
require_once __DIR__.'/integrations/sgf/operational/Composition.php';
+5
View File
@@ -0,0 +1,5 @@
<?php
use Tempo\Purchasing\Delivery\Composition;
Composition::routes(dirname(__DIR__, 3));