commit13
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Tempo\Purchasing\Delivery\Composition;
|
||||
|
||||
Composition::routes(dirname(__DIR__, 3));
|
||||
Reference in New Issue
Block a user