Files
SGF/database/migrations/2026_10_25_040000_create_internal_confirmations.php
T

62 lines
4.4 KiB
PHP

<?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
{
Schema::create('b_internal_confirmations', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); $table->unsignedBigInteger('unit_id');
$table->foreignId('billing_draft_id')->unique()->constrained('a_billing_drafts')->restrictOnDelete();
$table->unsignedInteger('billing_version'); $table->unsignedInteger('version')->default(1);
$table->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$table->jsonb('snapshot'); $table->timestampTz('created_at');
$table->foreign(['tenant_id','company_id','unit_id','billing_draft_id'])->references(['tenant_id','company_id','unit_id','id'])->on('a_billing_drafts')->restrictOnDelete();
});
DB::statement('ALTER TABLE b_internal_confirmations ADD CHECK (version = 1 AND billing_version > 0 AND jsonb_typeof(snapshot) = \'object\')');
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_b_confirmation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM 1 FROM a_billing_drafts WHERE id = NEW.billing_draft_id AND version = NEW.billing_version FOR UPDATE;
IF NOT FOUND THEN RAISE EXCEPTION 'SGF_B_STALE_DRAFT'; END IF;
RETURN NEW;
END; $$;
CREATE OR REPLACE FUNCTION sgf_b_draft_locked() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM b_internal_confirmations WHERE billing_draft_id = OLD.id) THEN RAISE EXCEPTION 'SGF_B_CONFIRMED_IMMUTABLE'; END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; 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' WHEN 'internal_confirmations' THEN 'b_internal_confirmations' 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; $$;
CREATE TRIGGER b_confirmation_valid BEFORE INSERT ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_b_confirmation_guard();
CREATE TRIGGER b_confirmation_immutable BEFORE UPDATE OR DELETE ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
CREATE TRIGGER b_billing_locked BEFORE UPDATE OR DELETE ON a_billing_drafts FOR EACH ROW EXECUTE FUNCTION sgf_b_draft_locked();
SQL);
$company = ['company.view','company.edit','unit.create','access.manage']; $unit = ['unit.view','unit.edit','billing_drafts.confirm_internal'];
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
{
throw new RuntimeException('Internal confirmations are immutable; rollback requires a separately reviewed preservation procedure.');
}
};