Accept conditional service calculation with versioned fiscal profile

This commit is contained in:
2026-10-26 16:48:44 +02:00
parent bc2707bb65
commit 9f480045fa
25 changed files with 801 additions and 3 deletions
@@ -0,0 +1,68 @@
<?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
{
$existing=DB::selectOne("SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->definition;
$expression=substr($existing,6); // retain every existing permission/scope branch
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 (($expression) OR (permission IN ('service_fiscal.view','service_fiscal.propose','service_fiscal.approve') AND unit_id IS NULL) OR (permission='service_fiscal.calculate' AND unit_id IS NOT NULL))");
Schema::create('sf_proposals', function (Blueprint $t) {
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
$t->string('kind'); $t->string('identifier'); $t->unsignedInteger('version');
$t->foreignId('profile_id')->constrained('service_calculation_profiles')->restrictOnDelete();
$t->foreignId('draft_id')->nullable()->constrained('a_billing_drafts')->restrictOnDelete();
$t->foreignId('previous_id')->nullable()->constrained('sf_proposals')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users'); $t->jsonb('data'); $t->timestampTz('created_at');
$t->unique(['unit_id','kind','identifier','version']);
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
});
Schema::create('sf_decisions', function (Blueprint $t) {
$t->id(); $t->foreignId('proposal_id')->unique()->constrained('sf_proposals')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users'); $t->string('decision'); $t->text('reason'); $t->timestampTz('created_at');
});
Schema::create('sf_activation_events', function (Blueprint $t) {
$t->id(); $t->foreignId('proposal_id')->constrained('sf_proposals')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users'); $t->boolean('active'); $t->text('reason'); $t->timestampTz('created_at');
});
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_fiscal_proposal_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.kind NOT IN ('profile','temporal') OR NOT EXISTS(
SELECT 1 FROM service_calculation_profiles p WHERE p.id=NEW.profile_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id
) OR (NEW.kind='profile' AND NEW.draft_id IS NOT NULL) OR (NEW.kind='temporal' AND NEW.draft_id IS NULL)
OR (NEW.draft_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=NEW.draft_id AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id))
OR (NEW.previous_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM sf_proposals p WHERE p.id=NEW.previous_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.kind=NEW.kind AND p.identifier=NEW.identifier AND p.version=NEW.version-1))
THEN RAISE EXCEPTION 'SGF_FISCAL_SCOPE'; END IF;
IF NEW.previous_id IS NULL AND NEW.version<>1 THEN RAISE EXCEPTION 'SGF_FISCAL_INITIAL_VERSION'; END IF;
IF NEW.kind='profile' AND NOT EXISTS(SELECT 1 FROM a_services s WHERE s.id=(NEW.data->>'service_id')::bigint AND s.tenant_id=NEW.tenant_id AND s.company_id=NEW.company_id)
THEN RAISE EXCEPTION 'SGF_FISCAL_SERVICE_SCOPE'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER fiscal_proposal_scope BEFORE INSERT ON sf_proposals FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_proposal_guard();
CREATE OR REPLACE FUNCTION sgf_fiscal_decision_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.decision NOT IN ('approved','refused') OR EXISTS(SELECT 1 FROM sf_proposals WHERE id=NEW.proposal_id AND actor_id=NEW.actor_id)
THEN RAISE EXCEPTION 'SGF_FISCAL_DECISION'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER fiscal_decision_guard BEFORE INSERT ON sf_decisions FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_decision_guard();
CREATE OR REPLACE FUNCTION sgf_fiscal_activation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS(SELECT 1 FROM sf_proposals p JOIN sf_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.proposal_id AND p.kind='profile')
THEN RAISE EXCEPTION 'SGF_FISCAL_ACTIVATION'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER fiscal_activation_guard BEFORE INSERT ON sf_activation_events FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_activation_guard();
SQL);
foreach (['sf_proposals','sf_decisions','sf_activation_events'] as $table) {
DB::unprepared("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
}
}
public function down(): void { throw new RuntimeException('Fiscal history requires a reviewed preservation procedure.'); }
};