Preserva entrega A aceite de servicos em rascunho

This commit is contained in:
2026-10-25 22:02:44 +02:00
parent 26b80df545
commit ca13acb74e
31 changed files with 1152 additions and 17 deletions
@@ -0,0 +1,140 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) {
Schema::create('a_'.$area, function (Blueprint $table) use ($area): void {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('company_id');
$table->unsignedBigInteger('unit_id')->nullable();
$table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete();
$table->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete();
$table->string('name');
$table->unsignedInteger('version')->default(1);
$table->jsonb('data');
if (in_array($area, ['service_drafts', 'billing_drafts'], true)) {
$table->unsignedBigInteger('client_id');
$table->foreign(['tenant_id', 'company_id', 'client_id'])->references(['tenant_id', 'company_id', 'id'])->on('a_clients')->restrictOnDelete();
$table->string('state')->default('draft');
}
if ($area === 'billing_drafts') {
$table->unsignedBigInteger('source_id');
$table->foreign(['tenant_id', 'company_id', 'unit_id', 'source_id'])->references(['tenant_id', 'company_id', 'unit_id', 'id'])->on('a_service_drafts')->restrictOnDelete();
}
$table->timestampsTz();
$table->unique(['tenant_id', 'company_id', 'id']);
$table->unique(['tenant_id', 'company_id', 'unit_id', 'id']);
});
$unitCheck = in_array($area, ['service_drafts', 'billing_drafts'], true) ? 'IS NOT NULL' : 'IS NULL';
DB::statement("ALTER TABLE a_$area ADD CHECK (unit_id $unitCheck AND version > 0 AND jsonb_typeof(data) = 'object')");
if (in_array($area, ['service_drafts', 'billing_drafts'], true)) { DB::statement("ALTER TABLE a_$area ADD CHECK (state = 'draft')"); }
}
Schema::create('a_history', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); $table->unsignedBigInteger('unit_id')->nullable();
$table->string('area', 30); $table->unsignedBigInteger('record_id'); $table->unsignedInteger('version');
$table->unsignedBigInteger('actor_id'); $table->jsonb('snapshot'); $table->timestampTz('created_at')->useCurrent();
$table->unique(['area', 'record_id', 'version']);
$table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete();
});
Schema::create('a_events', function (Blueprint $table): void {
$table->id(); $table->foreignId('history_id')->unique()->constrained('a_history')->restrictOnDelete();
$table->string('type', 80); $table->unsignedInteger('schema_version')->default(1);
$table->jsonb('payload'); $table->timestampTz('created_at')->useCurrent();
});
Schema::create('a_outbox', function (Blueprint $table): void {
$table->foreignId('event_id')->primary()->constrained('a_events')->restrictOnDelete();
$table->string('state')->default('captured'); $table->timestampTz('created_at')->useCurrent();
});
DB::statement("ALTER TABLE a_outbox ADD CHECK (state = 'captured')");
Schema::create('a_pending', function (Blueprint $table): void {
$table->id(); $table->foreignId('history_id')->constrained('a_history')->restrictOnDelete();
$table->string('code', 80); $table->string('message', 500); $table->timestampTz('created_at')->useCurrent();
$table->unique(['history_id', 'code']);
});
Schema::create('a_commands', function (Blueprint $table): void {
$table->id(); $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id');
$table->unsignedBigInteger('actor_id'); $table->string('command_key', 100); $table->string('request_hash', 64);
$table->jsonb('result'); $table->timestampTz('created_at')->useCurrent();
$table->unique(['tenant_id', 'company_id', 'command_key']);
});
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_a_record_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE line jsonb; field text; value text;
BEGIN
IF TG_OP = 'INSERT' AND NEW.version <> 1 THEN RAISE EXCEPTION 'SGF_A_INITIAL_VERSION'; END IF;
IF TG_OP = 'UPDATE' THEN
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id OR NEW.company_id IS DISTINCT FROM OLD.company_id OR NEW.unit_id IS DISTINCT FROM OLD.unit_id THEN
RAISE EXCEPTION 'SGF_A_IMMUTABLE_SCOPE';
END IF;
IF NEW.version <> OLD.version + 1 THEN RAISE EXCEPTION 'SGF_A_VERSION_REQUIRED'; END IF;
IF TG_TABLE_NAME = 'a_billing_drafts' THEN
IF NEW.source_id IS DISTINCT FROM OLD.source_id THEN RAISE EXCEPTION 'SGF_A_IMMUTABLE_SOURCE'; END IF;
END IF;
END IF;
IF TG_TABLE_NAME IN ('a_service_drafts','a_billing_drafts') THEN
IF (NEW.data - ARRAY['description','currency','lines','client_name','source_version','tax_status','accounting_status']) <> '{}'::jsonb THEN RAISE EXCEPTION 'SGF_A_UNSUPPORTED_DATA'; END IF;
IF NEW.data->>'tax_status' IS DISTINCT FROM 'pending' OR NEW.data->>'accounting_status' IS DISTINCT FROM 'not_processed' THEN RAISE EXCEPTION 'SGF_A_DRAFT_ONLY'; END IF;
IF NEW.data->>'currency' IS NOT NULL AND NEW.data->>'currency' !~ '^[A-Z]{3}$' THEN RAISE EXCEPTION 'SGF_A_CURRENCY'; END IF;
IF jsonb_typeof(NEW.data->'lines') IS DISTINCT FROM 'array' OR jsonb_array_length(NEW.data->'lines') < 1 THEN RAISE EXCEPTION 'SGF_A_LINES'; END IF;
FOR line IN SELECT * FROM jsonb_array_elements(NEW.data->'lines') LOOP
IF (line - ARRAY['service_id','service_name','description','quantity','unit_price']) <> '{}'::jsonb THEN RAISE EXCEPTION 'SGF_A_UNSUPPORTED_LINE_DATA'; END IF;
IF NOT EXISTS(SELECT 1 FROM a_services s WHERE s.id = (line->>'service_id')::bigint AND s.tenant_id = NEW.tenant_id AND s.company_id = NEW.company_id) THEN RAISE EXCEPTION 'SGF_A_FOREIGN_SERVICE'; END IF;
FOREACH field IN ARRAY ARRAY['quantity','unit_price'] LOOP
value := line->>field;
IF value IS NOT NULL THEN
IF jsonb_typeof(line->field) <> 'string' OR value !~ '^(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?$' THEN RAISE EXCEPTION 'SGF_A_DECIMAL_PRECISION'; END IF;
IF field = 'quantity' AND value::numeric <= 0 THEN RAISE EXCEPTION 'SGF_A_QUANTITY'; END IF;
END IF;
END LOOP;
END LOOP;
END IF;
RETURN NEW;
END; $$;
CREATE OR REPLACE FUNCTION sgf_a_history_guard() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE item record; target text;
BEGIN
target := CASE NEW.area WHEN 'clients' THEN 'a_clients' WHEN 'services' THEN 'a_services' WHEN 'service_drafts' THEN 'a_service_drafts' WHEN 'billing_drafts' THEN 'a_billing_drafts' ELSE NULL END;
IF target IS NULL THEN RAISE EXCEPTION 'SGF_A_AREA'; END IF;
EXECUTE format('SELECT tenant_id,company_id,unit_id,version FROM %I WHERE id=$1', target) INTO item USING NEW.record_id;
IF item IS NULL OR item.tenant_id IS DISTINCT FROM NEW.tenant_id OR item.company_id IS DISTINCT FROM NEW.company_id OR item.unit_id IS DISTINCT FROM NEW.unit_id OR item.version IS DISTINCT FROM NEW.version THEN RAISE EXCEPTION 'SGF_A_HISTORY_SCOPE'; END IF;
RETURN NEW;
END; $$;
SQL);
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) {
DB::statement("CREATE TRIGGER a_{$area}_guard BEFORE INSERT OR UPDATE ON a_$area FOR EACH ROW EXECUTE FUNCTION sgf_a_record_guard()");
}
DB::statement('CREATE TRIGGER a_history_scope BEFORE INSERT ON a_history FOR EACH ROW EXECUTE FUNCTION sgf_a_history_guard()');
foreach (['a_history', 'a_events', 'a_outbox', 'a_pending', 'a_commands'] as $table) {
DB::statement("CREATE TRIGGER {$table}_append_only BEFORE UPDATE OR DELETE ON $table FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
}
$company = ['company.view','company.edit','unit.create','access.manage']; $unit = ['unit.view','unit.edit'];
foreach (['clients','services','service_drafts','billing_drafts'] as $area) {
foreach (['view','create','edit','manage'] as $action) {
if (in_array($area, ['clients','services'], true)) { $company[] = "$area.$action"; } else { $unit[] = "$area.$action"; }
}
}
$companySql = "'".implode("','", $company)."'"; $unitSql = "'".implode("','", $unit)."'";
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 ($companySql) AND unit_id IS NULL) OR (permission IN ($unitSql) AND unit_id IS NOT NULL))");
}
public function down(): void
{
if (DB::table('permission_grants')->whereNotIn('permission', ['company.view','company.edit','unit.create','access.manage','unit.view','unit.edit'])->exists()) {
throw new RuntimeException('Remove explicit A grants through an approved procedure before rolling back.');
}
foreach (['a_commands','a_pending','a_outbox','a_events','a_history','a_billing_drafts','a_service_drafts','a_services','a_clients'] as $table) { Schema::dropIfExists($table); }
DB::statement('DROP FUNCTION IF EXISTS sgf_a_record_guard()'); DB::statement('DROP FUNCTION IF EXISTS sgf_a_history_guard()');
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') AND unit_id IS NOT NULL))");
}
};