feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
800
server/alembic/versions/20260716_0015_savings_value_ledger.py
Normal file
800
server/alembic/versions/20260716_0015_savings_value_ledger.py
Normal file
@@ -0,0 +1,800 @@
|
||||
"""add tenant-safe savings value ledger and append-only audit events
|
||||
|
||||
Revision ID: 20260716_0015
|
||||
Revises: 20260716_0014
|
||||
Create Date: 2026-07-16 20:10:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0015"
|
||||
down_revision: str | None = "20260716_0014"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0015 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_ledger_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
counts = {
|
||||
table_name: int(
|
||||
bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0
|
||||
)
|
||||
for table_name in (
|
||||
"profile_baseline_snapshots",
|
||||
"savings_opportunities",
|
||||
"savings_realizations",
|
||||
"savings_evidence_links",
|
||||
"savings_events",
|
||||
)
|
||||
}
|
||||
if any(counts.values()):
|
||||
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||||
raise RuntimeError(
|
||||
"cannot downgrade savings value ledger: immutable value facts exist "
|
||||
f"({summary})"
|
||||
)
|
||||
|
||||
|
||||
def _json_object_default() -> sa.TextClause:
|
||||
return sa.text("'{}'::json")
|
||||
|
||||
|
||||
def _json_array_default() -> sa.TextClause:
|
||||
return sa.text("'[]'::json")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"profile_baseline_snapshots",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("baseline_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("baseline_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("dimension_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("dimension_id", sa.String(length=160), nullable=False),
|
||||
sa.Column("metric_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("unit", sa.String(length=30), nullable=False),
|
||||
sa.Column("original_currency", sa.String(length=3), nullable=True),
|
||||
sa.Column("baseline_value", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("window_start", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("window_end", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("method", sa.String(length=80), nullable=False),
|
||||
sa.Column("query_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column("data_quality_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("data_quality_score", sa.Numeric(5, 4), nullable=False),
|
||||
sa.Column(
|
||||
"quality_issues_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_json_array_default(),
|
||||
),
|
||||
sa.Column("algorithm_version", sa.String(length=80), nullable=False),
|
||||
sa.Column("policy_version", sa.String(length=120), nullable=True),
|
||||
sa.Column("policy_effective_from", sa.Date(), nullable=True),
|
||||
sa.Column("policy_effective_to", sa.Date(), nullable=True),
|
||||
sa.Column("target_resource_type", sa.String(length=50), nullable=True),
|
||||
sa.Column("target_resource_id", sa.String(length=160), nullable=True),
|
||||
sa.Column("frozen_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("frozen_by", sa.String(length=120), nullable=False),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"baseline_type IN ('historical_cohort', 'policy_counterfactual', 'manual')",
|
||||
name="ck_profile_baseline_snapshots_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"data_quality_status IN ('complete', 'partial', 'insufficient', 'invalid')",
|
||||
name="ck_profile_baseline_snapshots_quality_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"baseline_value >= 0 AND sample_count >= 0",
|
||||
name="ck_profile_baseline_snapshots_values",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"data_quality_score >= 0 AND data_quality_score <= 1",
|
||||
name="ck_profile_baseline_snapshots_quality_score",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"window_end IS NULL OR window_start IS NOT NULL",
|
||||
name="ck_profile_baseline_snapshots_window_pair",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"window_start IS NULL OR window_end IS NULL OR window_end >= window_start",
|
||||
name="ck_profile_baseline_snapshots_window_order",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"baseline_type != 'historical_cohort' OR "
|
||||
"(window_start IS NOT NULL AND window_end IS NOT NULL AND sample_count > 0)",
|
||||
name="ck_profile_baseline_snapshots_historical_shape",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"baseline_type != 'policy_counterfactual' OR "
|
||||
"(policy_version IS NOT NULL AND length(trim(policy_version)) > 0 "
|
||||
"AND policy_effective_from IS NOT NULL AND target_resource_type IS NOT NULL "
|
||||
"AND target_resource_id IS NOT NULL)",
|
||||
name="ck_profile_baseline_snapshots_policy_shape",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"policy_effective_to IS NULL OR policy_effective_from IS NOT NULL",
|
||||
name="ck_profile_baseline_snapshots_policy_pair",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"policy_effective_from IS NULL OR policy_effective_to IS NULL "
|
||||
"OR policy_effective_to >= policy_effective_from",
|
||||
name="ck_profile_baseline_snapshots_policy_order",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"valid_until IS NULL OR valid_until >= frozen_at",
|
||||
name="ck_profile_baseline_snapshots_validity",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(baseline_key)) > 0 AND length(trim(query_fingerprint)) > 0",
|
||||
name="ck_profile_baseline_snapshots_keys",
|
||||
),
|
||||
sa.CheckConstraint("version >= 1", name="ck_profile_baseline_snapshots_version"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "id", name="uq_profile_baseline_snapshots_tenant_id"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"baseline_key",
|
||||
name="uq_profile_baseline_snapshots_tenant_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_profile_baseline_snapshots_lookup",
|
||||
"profile_baseline_snapshots",
|
||||
[
|
||||
"tenant_id",
|
||||
"baseline_type",
|
||||
"dimension_type",
|
||||
"dimension_id",
|
||||
"metric_key",
|
||||
"frozen_at",
|
||||
],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_profile_baseline_snapshots_quality",
|
||||
"profile_baseline_snapshots",
|
||||
["tenant_id", "data_quality_status", "frozen_at"],
|
||||
)
|
||||
op.create_table(
|
||||
"savings_opportunities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("opportunity_key", sa.String(length=180), nullable=False),
|
||||
sa.Column("benefit_key", sa.String(length=180), nullable=False),
|
||||
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
|
||||
# expense_claims/items 仍由 legacy bootstrap 管理,仅保存已校验软引用。
|
||||
sa.Column("claim_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("claim_no_snapshot", sa.String(length=80), nullable=False),
|
||||
sa.Column("claim_item_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("discovery_business_event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=160), nullable=False),
|
||||
sa.Column("category", sa.String(length=60), nullable=False),
|
||||
sa.Column("value_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("exposure_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("baseline_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("target_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("estimated_gross", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("estimated_cost", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("estimated_net", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("estimated_low", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("estimated_high", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("confidence", sa.Numeric(5, 4), nullable=False),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("reporting_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("attribution_method", sa.String(length=60), nullable=False),
|
||||
sa.Column("ai_decision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("suggested_action", sa.Text(), nullable=False),
|
||||
sa.Column("owner_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("owner_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("owner_role", sa.String(length=60), nullable=False),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False, server_default="identified"),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"dimension_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column(
|
||||
"baseline_snapshot_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_json_object_default(),
|
||||
),
|
||||
sa.Column(
|
||||
"evidence_json", sa.JSON(), nullable=False, server_default=_json_array_default()
|
||||
),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("realized_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"value_kind IN ('cash', 'labor')",
|
||||
name="ck_savings_opportunities_value_kind",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('identified', 'accepted', 'in_progress', 'realized', "
|
||||
"'verified', 'reversed', 'rejected', 'expired')",
|
||||
name="ck_savings_opportunities_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"exposure_amount >= 0 AND baseline_amount >= 0 AND target_amount >= 0 "
|
||||
"AND estimated_gross >= 0 AND estimated_cost >= 0 AND estimated_net >= 0 "
|
||||
"AND estimated_low >= 0 AND estimated_high >= 0",
|
||||
name="ck_savings_opportunities_amounts",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"estimated_net = estimated_gross - estimated_cost",
|
||||
name="ck_savings_opportunities_net_math",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"estimated_low <= estimated_net AND estimated_net <= estimated_high",
|
||||
name="ck_savings_opportunities_interval",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"confidence >= 0 AND confidence <= 1",
|
||||
name="ck_savings_opportunities_confidence",
|
||||
),
|
||||
sa.CheckConstraint("version >= 1", name="ck_savings_opportunities_version"),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(benefit_key)) > 0 AND length(trim(opportunity_key)) > 0",
|
||||
name="ck_savings_opportunities_keys",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(currency)) = 3 AND length(trim(reporting_currency)) = 3",
|
||||
name="ck_savings_opportunities_currencies",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status NOT IN ('accepted', 'in_progress', 'realized', 'verified', 'reversed') "
|
||||
"OR accepted_at IS NOT NULL",
|
||||
name="ck_savings_opportunities_acceptance",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status NOT IN ('in_progress', 'realized', 'verified', 'reversed') "
|
||||
"OR started_at IS NOT NULL",
|
||||
name="ck_savings_opportunities_started",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status NOT IN ('realized', 'verified', 'reversed') OR realized_at IS NOT NULL",
|
||||
name="ck_savings_opportunities_realized",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status NOT IN ('verified', 'reversed') OR verified_at IS NOT NULL",
|
||||
name="ck_savings_opportunities_verified",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status NOT IN ('verified', 'reversed', 'rejected', 'expired') "
|
||||
"OR closed_at IS NOT NULL",
|
||||
name="ck_savings_opportunities_closed",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
name="fk_savings_opportunities_tenant_case",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id", "discovery_business_event_id"],
|
||||
[
|
||||
"business_events.tenant_id",
|
||||
"business_events.expense_case_id",
|
||||
"business_events.id",
|
||||
],
|
||||
name="fk_savings_opportunities_tenant_event",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "baseline_snapshot_id"],
|
||||
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
|
||||
name="fk_savings_opportunities_tenant_baseline",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id", "ai_decision_id"],
|
||||
["ai_decisions.tenant_id", "ai_decisions.expense_case_id", "ai_decisions.id"],
|
||||
name="fk_savings_opportunities_tenant_ai_decision",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_opportunities_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "opportunity_key", name="uq_savings_opportunities_tenant_key"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_opportunities_tenant_status_due",
|
||||
"savings_opportunities",
|
||||
["tenant_id", "status", "due_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_opportunities_tenant_case",
|
||||
"savings_opportunities",
|
||||
["tenant_id", "expense_case_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_opportunities_tenant_benefit",
|
||||
"savings_opportunities",
|
||||
["tenant_id", "benefit_key"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_opportunities_tenant_owner",
|
||||
"savings_opportunities",
|
||||
["tenant_id", "owner_id", "status"],
|
||||
)
|
||||
op.create_table(
|
||||
"savings_realizations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("realization_key", sa.String(length=180), nullable=False),
|
||||
sa.Column("opportunity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("claim_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("claim_item_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("business_event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("realization_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("reversal_of_realization_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("realized_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("recorded_by_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("recorded_by_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("actual_gross", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("incremental_cost", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("actual_net", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("original_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("reporting_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("reporting_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("fx_rate", sa.Numeric(20, 8), nullable=False),
|
||||
sa.Column("fx_source", sa.String(length=80), nullable=False),
|
||||
sa.Column("fx_date", sa.Date(), nullable=False),
|
||||
sa.Column("fx_version", sa.String(length=80), nullable=False),
|
||||
sa.Column("attribution_method", sa.String(length=60), nullable=False),
|
||||
sa.Column("attribution_ratio", sa.Numeric(7, 6), nullable=False),
|
||||
sa.Column("benefit_key", sa.String(length=180), nullable=False),
|
||||
sa.Column(
|
||||
"dedupe_status",
|
||||
sa.String(length=24),
|
||||
nullable=False,
|
||||
server_default="pending_review",
|
||||
),
|
||||
sa.Column("canonical_realization_id", sa.String(length=36), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=24),
|
||||
nullable=False,
|
||||
server_default="pending_confirmation",
|
||||
),
|
||||
sa.Column("finance_confirmer_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("finance_confirmer_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("confirmation_note", sa.Text(), nullable=True),
|
||||
sa.Column("rejected_by_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("rejected_by_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("rejected_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rejection_reason", sa.Text(), nullable=True),
|
||||
sa.Column("reversed_by_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("reversed_by_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("reversed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("reversal_reason", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"baseline_snapshot_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_json_object_default(),
|
||||
),
|
||||
sa.Column(
|
||||
"final_snapshot_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_json_object_default(),
|
||||
),
|
||||
sa.Column(
|
||||
"evidence_json", sa.JSON(), nullable=False, server_default=_json_array_default()
|
||||
),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"realization_type IN ('actual', 'reversal')",
|
||||
name="ck_savings_realizations_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"dedupe_status IN ('pending_review', 'canonical', 'duplicate', 'excluded')",
|
||||
name="ck_savings_realizations_dedupe_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending_confirmation', 'finance_confirmed', 'rejected', 'reversed')",
|
||||
name="ck_savings_realizations_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"attribution_ratio > 0 AND attribution_ratio <= 1",
|
||||
name="ck_savings_realizations_attribution",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"incremental_cost >= 0 AND fx_rate > 0",
|
||||
name="ck_savings_realizations_cost_fx",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"actual_net = actual_gross - incremental_cost",
|
||||
name="ck_savings_realizations_net_math",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(realization_type = 'actual' AND reversal_of_realization_id IS NULL "
|
||||
"AND actual_gross >= 0 AND actual_net >= 0 AND reporting_amount >= 0) OR "
|
||||
"(realization_type = 'reversal' AND reversal_of_realization_id IS NOT NULL "
|
||||
"AND actual_gross <= 0 AND actual_net <= 0 AND reporting_amount <= 0)",
|
||||
name="ck_savings_realizations_amount_direction",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(dedupe_status = 'duplicate' AND canonical_realization_id IS NOT NULL "
|
||||
"AND canonical_realization_id <> id) OR "
|
||||
"(dedupe_status != 'duplicate' AND canonical_realization_id IS NULL)",
|
||||
name="ck_savings_realizations_duplicate_target",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'finance_confirmed' OR "
|
||||
"(finance_confirmer_id IS NOT NULL AND finance_confirmer_name IS NOT NULL "
|
||||
"AND confirmed_at IS NOT NULL AND confirmation_note IS NOT NULL "
|
||||
"AND (realization_type = 'reversal' OR finance_confirmer_id <> recorded_by_id) "
|
||||
"AND dedupe_status = 'canonical')",
|
||||
name="ck_savings_realizations_confirmation",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'rejected' OR (rejected_by_id IS NOT NULL "
|
||||
"AND rejected_by_name IS NOT NULL AND rejected_at IS NOT NULL "
|
||||
"AND rejection_reason IS NOT NULL)",
|
||||
name="ck_savings_realizations_rejection",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'reversed' OR (reversed_by_id IS NOT NULL "
|
||||
"AND reversed_by_name IS NOT NULL AND reversed_at IS NOT NULL "
|
||||
"AND reversal_reason IS NOT NULL)",
|
||||
name="ck_savings_realizations_reversal",
|
||||
),
|
||||
sa.CheckConstraint("version >= 1", name="ck_savings_realizations_version"),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(realization_key)) > 0 AND length(trim(benefit_key)) > 0",
|
||||
name="ck_savings_realizations_keys",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(original_currency)) = 3 "
|
||||
"AND length(trim(reporting_currency)) = 3",
|
||||
name="ck_savings_realizations_currencies",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "opportunity_id"],
|
||||
["savings_opportunities.tenant_id", "savings_opportunities.id"],
|
||||
name="fk_savings_realizations_tenant_opportunity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
name="fk_savings_realizations_tenant_case",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id", "business_event_id"],
|
||||
[
|
||||
"business_events.tenant_id",
|
||||
"business_events.expense_case_id",
|
||||
"business_events.id",
|
||||
],
|
||||
name="fk_savings_realizations_tenant_event",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
[
|
||||
"tenant_id", "opportunity_id", "benefit_key", "reversal_of_realization_id"
|
||||
],
|
||||
[
|
||||
"savings_realizations.tenant_id", "savings_realizations.opportunity_id",
|
||||
"savings_realizations.benefit_key", "savings_realizations.id",
|
||||
],
|
||||
name="fk_savings_realizations_tenant_reversal",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "benefit_key", "canonical_realization_id"],
|
||||
[
|
||||
"savings_realizations.tenant_id", "savings_realizations.benefit_key",
|
||||
"savings_realizations.id",
|
||||
],
|
||||
name="fk_savings_realizations_tenant_canonical",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_realizations_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "realization_key", name="uq_savings_realizations_tenant_key"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "opportunity_id", "benefit_key", "id",
|
||||
name="uq_savings_realizations_tenant_opportunity_benefit_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "benefit_key", "id",
|
||||
name="uq_savings_realizations_tenant_benefit_id",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_savings_realizations_actual_canonical_benefit",
|
||||
"savings_realizations",
|
||||
["tenant_id", "benefit_key"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"realization_type = 'actual' AND dedupe_status = 'canonical'"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_realizations_tenant_status_time",
|
||||
"savings_realizations",
|
||||
["tenant_id", "status", "realized_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_realizations_tenant_opportunity",
|
||||
"savings_realizations",
|
||||
["tenant_id", "opportunity_id", "realized_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_realizations_tenant_benefit",
|
||||
"savings_realizations",
|
||||
["tenant_id", "benefit_key", "dedupe_status"],
|
||||
)
|
||||
op.create_table(
|
||||
"savings_evidence_links",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("evidence_key", sa.String(length=180), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("entity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("opportunity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("realization_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("evidence_role", sa.String(length=50), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("resource_id", sa.String(length=160), nullable=False),
|
||||
sa.Column("source_system", sa.String(length=80), nullable=False),
|
||||
sa.Column("external_event_id", sa.String(length=160), nullable=True),
|
||||
sa.Column("content_hash", sa.String(length=80), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("collected_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"verification_status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="unverified",
|
||||
),
|
||||
sa.Column("verified_by", sa.String(length=120), nullable=True),
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"entity_type IN ('baseline', 'opportunity', 'realization')",
|
||||
name="ck_savings_evidence_links_entity_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(entity_type = 'baseline' AND baseline_snapshot_id = entity_id "
|
||||
"AND opportunity_id IS NULL AND realization_id IS NULL) OR "
|
||||
"(entity_type = 'opportunity' AND opportunity_id = entity_id "
|
||||
"AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR "
|
||||
"(entity_type = 'realization' AND realization_id = entity_id "
|
||||
"AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)",
|
||||
name="ck_savings_evidence_links_entity_shape",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_status IN ('unverified', 'verified', 'rejected', 'unavailable')",
|
||||
name="ck_savings_evidence_links_verification",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_status != 'verified' OR "
|
||||
"(verified_by IS NOT NULL AND verified_at IS NOT NULL)",
|
||||
name="ck_savings_evidence_links_verifier",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(evidence_key)) > 0 AND length(trim(content_hash)) > 0",
|
||||
name="ck_savings_evidence_links_keys",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "baseline_snapshot_id"],
|
||||
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
|
||||
name="fk_savings_evidence_links_tenant_baseline",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "opportunity_id"],
|
||||
["savings_opportunities.tenant_id", "savings_opportunities.id"],
|
||||
name="fk_savings_evidence_links_tenant_opportunity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "realization_id"],
|
||||
["savings_realizations.tenant_id", "savings_realizations.id"],
|
||||
name="fk_savings_evidence_links_tenant_realization",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_evidence_links_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "evidence_key", name="uq_savings_evidence_links_tenant_key"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_evidence_links_entity",
|
||||
"savings_evidence_links",
|
||||
["tenant_id", "entity_type", "entity_id", "collected_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_evidence_links_resource",
|
||||
"savings_evidence_links",
|
||||
["tenant_id", "resource_type", "resource_id"],
|
||||
)
|
||||
op.create_table(
|
||||
"savings_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("aggregate_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("aggregate_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("baseline_snapshot_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("opportunity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("realization_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("action", sa.String(length=60), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("actor_name", sa.String(length=120), nullable=False),
|
||||
sa.Column("actor_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("request_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("expected_version", sa.Integer(), nullable=False),
|
||||
sa.Column("result_version", sa.Integer(), nullable=False),
|
||||
sa.Column("payload_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column(
|
||||
"payload_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column("before_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
|
||||
sa.Column("after_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
|
||||
sa.Column(
|
||||
"response_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column("correlation_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("causation_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"occurred_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"aggregate_type IN ('baseline', 'opportunity', 'realization')",
|
||||
name="ck_savings_events_aggregate_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(aggregate_type = 'baseline' AND baseline_snapshot_id = aggregate_id "
|
||||
"AND opportunity_id IS NULL AND realization_id IS NULL) OR "
|
||||
"(aggregate_type = 'opportunity' AND opportunity_id = aggregate_id "
|
||||
"AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR "
|
||||
"(aggregate_type = 'realization' AND realization_id = aggregate_id "
|
||||
"AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)",
|
||||
name="ck_savings_events_aggregate_shape",
|
||||
),
|
||||
sa.CheckConstraint("length(trim(action)) > 0", name="ck_savings_events_action"),
|
||||
sa.CheckConstraint(
|
||||
"actor_type IN ('user', 'system', 'agent', 'service')",
|
||||
name="ck_savings_events_actor_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expected_version >= 0 AND result_version >= 1 "
|
||||
"AND result_version >= expected_version",
|
||||
name="ck_savings_events_version",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(request_id)) > 0 AND length(trim(payload_fingerprint)) > 0",
|
||||
name="ck_savings_events_request",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "baseline_snapshot_id"],
|
||||
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
|
||||
name="fk_savings_events_tenant_baseline",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "opportunity_id"],
|
||||
["savings_opportunities.tenant_id", "savings_opportunities.id"],
|
||||
name="fk_savings_events_tenant_opportunity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "realization_id"],
|
||||
["savings_realizations.tenant_id", "savings_realizations.id"],
|
||||
name="fk_savings_events_tenant_realization",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_savings_events_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "actor_id", "request_id", name="uq_savings_events_actor_request"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"aggregate_type",
|
||||
"aggregate_id",
|
||||
"result_version",
|
||||
name="uq_savings_events_aggregate_version",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_events_tenant_aggregate_time",
|
||||
"savings_events",
|
||||
["tenant_id", "aggregate_type", "aggregate_id", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_savings_events_tenant_correlation",
|
||||
"savings_events",
|
||||
["tenant_id", "correlation_id", "occurred_at"],
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION prevent_savings_events_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'savings_events are append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_savings_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON savings_events
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_savings_events_mutation()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_ledger_for_downgrade()
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_savings_events_append_only ON savings_events")
|
||||
op.execute("DROP FUNCTION IF EXISTS prevent_savings_events_mutation()")
|
||||
op.drop_table("savings_events")
|
||||
op.drop_table("savings_evidence_links")
|
||||
op.drop_table("savings_realizations")
|
||||
op.drop_table("savings_opportunities")
|
||||
op.drop_table("profile_baseline_snapshots")
|
||||
590
server/alembic/versions/20260716_0016_commercial_metering.py
Normal file
590
server/alembic/versions/20260716_0016_commercial_metering.py
Normal file
@@ -0,0 +1,590 @@
|
||||
"""add tenant commercial contracts, entitlements, usage and cost ledgers
|
||||
|
||||
Revision ID: 20260716_0016
|
||||
Revises: 20260716_0015
|
||||
Create Date: 2026-07-16 23:10:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0016"
|
||||
down_revision: str | None = "20260716_0015"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0016 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_commercial_domain_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
counts = {
|
||||
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
|
||||
for table_name in (
|
||||
"tenant_commercial_plans",
|
||||
"tenant_subscriptions",
|
||||
"commercial_entitlements",
|
||||
"usage_meter_events",
|
||||
"commercial_cost_events",
|
||||
)
|
||||
}
|
||||
if any(counts.values()):
|
||||
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||||
raise RuntimeError(
|
||||
"cannot downgrade commercial metering: contracts or immutable facts exist "
|
||||
f"({summary})"
|
||||
)
|
||||
|
||||
|
||||
def _json_object_default() -> sa.TextClause:
|
||||
return sa.text("'{}'::json")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"tenant_commercial_plans",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("plan_code", sa.String(length=80), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("pricing_model", sa.String(length=24), nullable=False),
|
||||
sa.Column("billing_interval", sa.String(length=20), nullable=False),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("base_fee", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("included_seats", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"overage_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
|
||||
),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="draft"),
|
||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"contract_terms_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_json_object_default(),
|
||||
),
|
||||
sa.Column("created_by", sa.String(length=120), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"pricing_model IN ('subscription', 'usage', 'hybrid', 'pilot', 'custom')",
|
||||
name="ck_tenant_commercial_plans_pricing_model",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
|
||||
name="ck_tenant_commercial_plans_billing_interval",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('draft', 'active', 'retired')",
|
||||
name="ck_tenant_commercial_plans_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"base_fee >= 0 AND included_seats >= 0 AND version >= 1",
|
||||
name="ck_tenant_commercial_plans_values",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(plan_code)) > 0 AND length(trim(name)) > 0",
|
||||
name="ck_tenant_commercial_plans_keys",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(currency)) = 3", name="ck_tenant_commercial_plans_currency"
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"effective_to IS NULL OR effective_to > effective_from",
|
||||
name="ck_tenant_commercial_plans_effective_window",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "id", name="uq_tenant_commercial_plans_tenant_id"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"plan_code",
|
||||
"version",
|
||||
name="uq_tenant_commercial_plans_tenant_code_version",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_tenant_commercial_plans_active_code",
|
||||
"tenant_commercial_plans",
|
||||
["tenant_id", "plan_code"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("status = 'active'"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_commercial_plans_tenant_status",
|
||||
"tenant_commercial_plans",
|
||||
["tenant_id", "status", "effective_from"],
|
||||
)
|
||||
op.create_table(
|
||||
"tenant_subscriptions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("subscription_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("plan_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("current_period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("seats", sa.Integer(), nullable=False),
|
||||
sa.Column("base_fee_snapshot", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("billing_interval", sa.String(length=20), nullable=False),
|
||||
sa.Column("auto_renew", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("external_provider", sa.String(length=60), nullable=True),
|
||||
sa.Column("external_subscription_id", sa.String(length=160), nullable=True),
|
||||
sa.Column("canceled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column("created_by", sa.String(length=120), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('trialing', 'active', 'past_due', 'suspended', 'canceled', 'expired')",
|
||||
name="ck_tenant_subscriptions_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
|
||||
name="ck_tenant_subscriptions_billing_interval",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"seats > 0 AND base_fee_snapshot >= 0 AND version >= 1",
|
||||
name="ck_tenant_subscriptions_values",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(subscription_key)) > 0 AND length(trim(currency)) = 3",
|
||||
name="ck_tenant_subscriptions_keys",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"current_period_end > current_period_start",
|
||||
name="ck_tenant_subscriptions_period",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"ends_at IS NULL OR ends_at > starts_at",
|
||||
name="ck_tenant_subscriptions_contract_window",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(external_provider IS NULL AND external_subscription_id IS NULL) OR "
|
||||
"(external_provider IS NOT NULL AND external_subscription_id IS NOT NULL)",
|
||||
name="ck_tenant_subscriptions_external_pair",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'canceled' OR canceled_at IS NOT NULL",
|
||||
name="ck_tenant_subscriptions_cancellation",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "plan_id"],
|
||||
["tenant_commercial_plans.tenant_id", "tenant_commercial_plans.id"],
|
||||
name="fk_tenant_subscriptions_tenant_plan",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_tenant_subscriptions_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "subscription_key", name="uq_tenant_subscriptions_tenant_key"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"external_provider",
|
||||
"external_subscription_id",
|
||||
name="uq_tenant_subscriptions_external_ref",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_tenant_subscriptions_current",
|
||||
"tenant_subscriptions",
|
||||
["tenant_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"status IN ('trialing', 'active', 'past_due', 'suspended')"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_subscriptions_tenant_status_period",
|
||||
"tenant_subscriptions",
|
||||
["tenant_id", "status", "current_period_end"],
|
||||
)
|
||||
op.create_table(
|
||||
"commercial_entitlements",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("entitlement_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("metric_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("entitlement_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("unit", sa.String(length=40), nullable=False),
|
||||
sa.Column("included_quantity", sa.Numeric(20, 6), nullable=True),
|
||||
sa.Column("hard_limit_quantity", sa.Numeric(20, 6), nullable=True),
|
||||
sa.Column("reset_interval", sa.String(length=20), nullable=False),
|
||||
sa.Column("overage_policy", sa.String(length=16), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("config_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"entitlement_type IN ('feature', 'metered', 'unlimited')",
|
||||
name="ck_commercial_entitlements_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"reset_interval IN ('none', 'monthly', 'quarterly', 'annual', 'contract')",
|
||||
name="ck_commercial_entitlements_reset_interval",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"overage_policy IN ('block', 'allow', 'alert')",
|
||||
name="ck_commercial_entitlements_overage_policy",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'suspended', 'expired')",
|
||||
name="ck_commercial_entitlements_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"version >= 1 AND (included_quantity IS NULL OR included_quantity >= 0) "
|
||||
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity >= 0)",
|
||||
name="ck_commercial_entitlements_values",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(entitlement_type = 'unlimited' AND included_quantity IS NULL "
|
||||
"AND hard_limit_quantity IS NULL) OR "
|
||||
"(entitlement_type = 'feature' AND included_quantity IN (0, 1) "
|
||||
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity IN (0, 1))) OR "
|
||||
"(entitlement_type = 'metered' AND included_quantity IS NOT NULL "
|
||||
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity >= included_quantity))",
|
||||
name="ck_commercial_entitlements_quota_shape",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(entitlement_key)) > 0 AND length(trim(metric_key)) > 0 "
|
||||
"AND length(trim(unit)) > 0",
|
||||
name="ck_commercial_entitlements_keys",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"effective_to IS NULL OR effective_to > effective_from",
|
||||
name="ck_commercial_entitlements_effective_window",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id"],
|
||||
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
|
||||
name="fk_commercial_entitlements_tenant_subscription",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "id", name="uq_commercial_entitlements_tenant_id"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"id",
|
||||
name="uq_commercial_entitlements_tenant_subscription_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"entitlement_key",
|
||||
name="uq_commercial_entitlements_subscription_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_entitlements_subscription_status",
|
||||
"commercial_entitlements",
|
||||
["tenant_id", "subscription_id", "status"],
|
||||
)
|
||||
op.create_table(
|
||||
"usage_meter_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("entitlement_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("metric_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("quantity", sa.Numeric(20, 6), nullable=False),
|
||||
sa.Column("unit", sa.String(length=40), nullable=False),
|
||||
sa.Column("period_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("source_system", sa.String(length=80), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column("reversal_of_event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("subject_type", sa.String(length=60), nullable=True),
|
||||
sa.Column("subject_id", sa.String(length=160), nullable=True),
|
||||
sa.Column("actor_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("correlation_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("trace_id", sa.String(length=120), nullable=True),
|
||||
sa.Column(
|
||||
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column(
|
||||
"recorded_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"event_type IN ('usage', 'credit', 'adjustment', 'reversal')",
|
||||
name="ck_usage_meter_events_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(event_type = 'usage' AND quantity > 0) OR "
|
||||
"(event_type = 'credit' AND quantity < 0) OR "
|
||||
"(event_type IN ('adjustment', 'reversal') AND quantity <> 0)",
|
||||
name="ck_usage_meter_events_quantity",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(event_type = 'reversal' AND reversal_of_event_id IS NOT NULL) OR "
|
||||
"(event_type != 'reversal' AND reversal_of_event_id IS NULL)",
|
||||
name="ck_usage_meter_events_reversal",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(subject_type IS NULL AND subject_id IS NULL) OR "
|
||||
"(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
|
||||
name="ck_usage_meter_events_subject_pair",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"actor_type IN ('system', 'user', 'integration', 'admin')",
|
||||
name="ck_usage_meter_events_actor_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
|
||||
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
|
||||
"AND length(trim(idempotency_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0",
|
||||
name="ck_usage_meter_events_keys",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id"],
|
||||
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
|
||||
name="fk_usage_meter_events_tenant_subscription",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id", "entitlement_id"],
|
||||
[
|
||||
"commercial_entitlements.tenant_id",
|
||||
"commercial_entitlements.subscription_id",
|
||||
"commercial_entitlements.id",
|
||||
],
|
||||
name="fk_usage_meter_events_tenant_entitlement",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id", "entitlement_id", "reversal_of_event_id"],
|
||||
[
|
||||
"usage_meter_events.tenant_id",
|
||||
"usage_meter_events.subscription_id",
|
||||
"usage_meter_events.entitlement_id",
|
||||
"usage_meter_events.id",
|
||||
],
|
||||
name="fk_usage_meter_events_tenant_reversal",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_usage_meter_events_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"id",
|
||||
name="uq_usage_meter_events_tenant_subscription_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"entitlement_id",
|
||||
"id",
|
||||
name="uq_usage_meter_events_entitlement_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"idempotency_key",
|
||||
name="uq_usage_meter_events_source_request",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_usage_meter_events_quota_window",
|
||||
"usage_meter_events",
|
||||
["tenant_id", "subscription_id", "metric_key", "period_key", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_usage_meter_events_correlation",
|
||||
"usage_meter_events",
|
||||
["tenant_id", "correlation_id", "occurred_at"],
|
||||
)
|
||||
op.create_table(
|
||||
"commercial_cost_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("usage_event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("event_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("cost_category", sa.String(length=32), nullable=False),
|
||||
sa.Column("quantity", sa.Numeric(20, 6), nullable=False),
|
||||
sa.Column("unit", sa.String(length=40), nullable=False),
|
||||
sa.Column("unit_cost", sa.Numeric(20, 8), nullable=False),
|
||||
sa.Column("cost_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("original_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("reporting_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("reporting_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("fx_rate", sa.Numeric(20, 8), nullable=False),
|
||||
sa.Column("provider", sa.String(length=120), nullable=True),
|
||||
sa.Column("sku", sa.String(length=120), nullable=True),
|
||||
sa.Column("model_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("allocation_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("source_system", sa.String(length=80), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column("reversal_of_cost_event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("correlation_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("trace_id", sa.String(length=120), nullable=True),
|
||||
sa.Column(
|
||||
"metadata_json", sa.JSON(), nullable=False, server_default=_json_object_default()
|
||||
),
|
||||
sa.Column(
|
||||
"recorded_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"event_type IN ('incurred', 'credit', 'adjustment', 'reversal')",
|
||||
name="ck_commercial_cost_events_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"cost_category IN ('ai_inference', 'ocr', 'storage', 'connector', "
|
||||
"'support', 'implementation', 'infrastructure', 'payment', 'other')",
|
||||
name="ck_commercial_cost_events_category",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"quantity > 0 AND unit_cost >= 0 AND fx_rate > 0",
|
||||
name="ck_commercial_cost_events_values",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(event_type = 'incurred' AND cost_amount >= 0 AND reporting_amount >= 0) OR "
|
||||
"(event_type = 'credit' AND cost_amount <= 0 AND reporting_amount <= 0) OR "
|
||||
"(event_type IN ('adjustment', 'reversal') AND cost_amount <> 0 "
|
||||
"AND reporting_amount <> 0)",
|
||||
name="ck_commercial_cost_events_amount_direction",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(event_type = 'reversal' AND reversal_of_cost_event_id IS NOT NULL) OR "
|
||||
"(event_type != 'reversal' AND reversal_of_cost_event_id IS NULL)",
|
||||
name="ck_commercial_cost_events_reversal",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"usage_event_id IS NULL OR subscription_id IS NOT NULL",
|
||||
name="ck_commercial_cost_events_usage_pair",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(unit)) > 0 AND length(trim(source_system)) > 0 "
|
||||
"AND length(trim(idempotency_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0 "
|
||||
"AND length(trim(original_currency)) = 3 "
|
||||
"AND length(trim(reporting_currency)) = 3",
|
||||
name="ck_commercial_cost_events_keys",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id"],
|
||||
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
|
||||
name="fk_commercial_cost_events_tenant_subscription",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id", "usage_event_id"],
|
||||
[
|
||||
"usage_meter_events.tenant_id",
|
||||
"usage_meter_events.subscription_id",
|
||||
"usage_meter_events.id",
|
||||
],
|
||||
name="fk_commercial_cost_events_tenant_usage",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "reversal_of_cost_event_id"],
|
||||
["commercial_cost_events.tenant_id", "commercial_cost_events.id"],
|
||||
name="fk_commercial_cost_events_tenant_reversal",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "id", name="uq_commercial_cost_events_tenant_id"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_system",
|
||||
"idempotency_key",
|
||||
name="uq_commercial_cost_events_source_request",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_cost_events_tenant_period",
|
||||
"commercial_cost_events",
|
||||
["tenant_id", "occurred_at", "cost_category"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_cost_events_subscription_period",
|
||||
"commercial_cost_events",
|
||||
["tenant_id", "subscription_id", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_cost_events_allocation",
|
||||
"commercial_cost_events",
|
||||
["tenant_id", "allocation_key", "occurred_at"],
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION prevent_commercial_events_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
for table_name in ("usage_meter_events", "commercial_cost_events"):
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE TRIGGER trg_{table_name}_append_only
|
||||
BEFORE UPDATE OR DELETE ON {table_name}
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_commercial_events_mutation()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_commercial_domain_for_downgrade()
|
||||
for table_name in ("commercial_cost_events", "usage_meter_events"):
|
||||
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
|
||||
op.execute("DROP FUNCTION IF EXISTS prevent_commercial_events_mutation()")
|
||||
op.drop_table("commercial_cost_events")
|
||||
op.drop_table("usage_meter_events")
|
||||
op.drop_table("commercial_entitlements")
|
||||
op.drop_table("tenant_subscriptions")
|
||||
op.drop_table("tenant_commercial_plans")
|
||||
@@ -0,0 +1,375 @@
|
||||
"""add tenant-safe financial connector and reconciliation ledger
|
||||
|
||||
Revision ID: 20260716_0017
|
||||
Revises: 20260716_0016
|
||||
Create Date: 2026-07-16 20:00:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0017"
|
||||
down_revision: str | None = "20260716_0016"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0017 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_connector_domain_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
counts = {
|
||||
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
|
||||
for table_name in (
|
||||
"financial_connector_configs",
|
||||
"financial_connector_events",
|
||||
"payment_reconciliation_cases",
|
||||
"payment_reconciliation_events",
|
||||
)
|
||||
}
|
||||
if any(counts.values()):
|
||||
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||||
raise RuntimeError(
|
||||
"cannot downgrade financial connector: configurations or immutable facts exist "
|
||||
f"({summary})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"financial_connector_configs",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("environment", sa.String(length=16), nullable=False),
|
||||
sa.Column("key_version", sa.String(length=40), nullable=False),
|
||||
sa.Column("secret_ref", sa.String(length=180), nullable=False),
|
||||
sa.Column("allowed_event_types_json", sa.JSON(), nullable=False),
|
||||
sa.Column("clock_skew_seconds", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_code", sa.String(length=80), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=120), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"environment IN ('test', 'mock', 'staging', 'production')",
|
||||
name="ck_financial_connector_configs_environment",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'disabled', 'rotating')",
|
||||
name="ck_financial_connector_configs_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"clock_skew_seconds BETWEEN 30 AND 900",
|
||||
name="ck_financial_connector_configs_clock_skew",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(provider)) > 0 AND length(trim(key_version)) > 0 "
|
||||
"AND length(trim(secret_ref)) > 0",
|
||||
name="ck_financial_connector_configs_keys",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_financial_connector_configs_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider",
|
||||
"key_version",
|
||||
name="uq_financial_connector_configs_tenant_provider_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_configs_tenant_status",
|
||||
"financial_connector_configs",
|
||||
["tenant_id", "status", "provider"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"financial_connector_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("config_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("environment", sa.String(length=16), nullable=False),
|
||||
sa.Column("direction", sa.String(length=12), nullable=False),
|
||||
sa.Column("external_event_id", sa.String(length=160), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"received_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("key_version", sa.String(length=40), nullable=False),
|
||||
sa.Column("verification_level", sa.String(length=32), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=80), nullable=False),
|
||||
sa.Column("processing_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_code", sa.String(length=80), nullable=True),
|
||||
sa.Column("claim_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("origin_event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("external_reference_tail", sa.String(length=8), nullable=True),
|
||||
sa.Column("normalized_payload_json", sa.JSON(), nullable=False),
|
||||
sa.Column("response_json", sa.JSON(), nullable=False),
|
||||
sa.CheckConstraint("direction = 'inbound'", name="ck_financial_connector_events_direction"),
|
||||
sa.CheckConstraint(
|
||||
"event_type IN ('payment_settled', 'payment_failed', 'erp_posted', "
|
||||
"'erp_posting_failed', 'payment_refunded', 'payment_reversed')",
|
||||
name="ck_financial_connector_events_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"environment IN ('test', 'mock', 'staging', 'production')",
|
||||
name="ck_financial_connector_events_environment",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_level IN ('simulated', 'staging_verified', 'production_verified')",
|
||||
name="ck_financial_connector_events_verification",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"processing_status IN ('processed', 'exception', 'pending')",
|
||||
name="ck_financial_connector_events_processing_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(external_event_id)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) >= 16 "
|
||||
"AND length(trim(content_hash)) >= 16",
|
||||
name="ck_financial_connector_events_fingerprints",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(event_type IN ('payment_refunded', 'payment_reversed', "
|
||||
"'erp_posted', 'erp_posting_failed') "
|
||||
"AND (origin_event_id IS NOT NULL OR processing_status = 'exception')) "
|
||||
"OR (event_type IN ('payment_settled', 'payment_failed') "
|
||||
"AND origin_event_id IS NULL)",
|
||||
name="ck_financial_connector_events_origin",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "config_id"],
|
||||
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
||||
name="fk_financial_connector_events_tenant_config",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
name="fk_financial_connector_events_tenant_expense_case",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "origin_event_id"],
|
||||
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
||||
name="fk_financial_connector_events_tenant_origin",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_financial_connector_events_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider",
|
||||
"external_event_id",
|
||||
name="uq_financial_connector_events_external_id",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_events_tenant_received",
|
||||
"financial_connector_events",
|
||||
["tenant_id", "received_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_events_tenant_claim",
|
||||
"financial_connector_events",
|
||||
["tenant_id", "claim_id", "occurred_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"payment_reconciliation_cases",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("claim_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("expense_case_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("expected_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("actual_amount", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("amount_difference", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("expected_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("actual_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("expected_reference", sa.String(length=160), nullable=False),
|
||||
sa.Column("external_reference_tail", sa.String(length=8), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("exception_code", sa.String(length=80), nullable=True),
|
||||
sa.Column("erp_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("erp_document_tail", sa.String(length=8), nullable=True),
|
||||
sa.Column("erp_document_hash", sa.String(length=80), nullable=True),
|
||||
sa.Column("assigned_to", sa.String(length=120), nullable=True),
|
||||
sa.Column("last_connector_event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'matched', 'exception', 'confirmed', "
|
||||
"'rejected', 'reopened', 'closed')",
|
||||
name="ck_payment_reconciliation_cases_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"erp_status IN ('pending_posting', 'posted', 'posting_failed')",
|
||||
name="ck_payment_reconciliation_cases_erp_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expected_amount >= 0 AND actual_amount >= 0",
|
||||
name="ck_payment_reconciliation_cases_amounts",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(expected_currency)) = 3 AND length(trim(actual_currency)) = 3",
|
||||
name="ck_payment_reconciliation_cases_currencies",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
name="fk_payment_reconciliation_cases_tenant_expense_case",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "last_connector_event_id"],
|
||||
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
||||
name="fk_payment_reconciliation_cases_tenant_last_event",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_cases_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider",
|
||||
"claim_id",
|
||||
name="uq_payment_reconciliation_cases_tenant_provider_claim",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_payment_reconciliation_cases_tenant_status",
|
||||
"payment_reconciliation_cases",
|
||||
["tenant_id", "status", "updated_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"payment_reconciliation_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("reconciliation_case_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("connector_event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("action", sa.String(length=32), nullable=False),
|
||||
sa.Column("actor_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column("before_json", sa.JSON(), nullable=False),
|
||||
sa.Column("after_json", sa.JSON(), nullable=False),
|
||||
sa.Column("response_json", sa.JSON(), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("correlation_id", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"action IN ('auto_matched', 'exception_created', 'erp_posted', "
|
||||
"'erp_posting_failed', 'reopened', 'confirmed', 'rejected', 'closed')",
|
||||
name="ck_payment_reconciliation_events_action",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(request_fingerprint)) >= 16",
|
||||
name="ck_payment_reconciliation_events_fingerprint",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "connector_event_id"],
|
||||
["financial_connector_events.tenant_id", "financial_connector_events.id"],
|
||||
name="fk_payment_reconciliation_events_tenant_connector_event",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "reconciliation_case_id"],
|
||||
["payment_reconciliation_cases.tenant_id", "payment_reconciliation_cases.id"],
|
||||
name="fk_payment_reconciliation_events_tenant_case",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_events_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"connector_event_id",
|
||||
"action",
|
||||
name="uq_payment_reconciliation_events_connector_action",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_payment_reconciliation_events_tenant_case_time",
|
||||
"payment_reconciliation_events",
|
||||
["tenant_id", "reconciliation_case_id", "occurred_at"],
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION reject_financial_connector_append_only_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'financial connector facts are append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
)
|
||||
for table_name in ("financial_connector_events", "payment_reconciliation_events"):
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE TRIGGER trg_{table_name}_append_only
|
||||
BEFORE UPDATE OR DELETE ON {table_name}
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_connector_domain_for_downgrade()
|
||||
for table_name in ("payment_reconciliation_events", "financial_connector_events"):
|
||||
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
|
||||
op.execute("DROP FUNCTION IF EXISTS reject_financial_connector_append_only_mutation()")
|
||||
op.drop_index(
|
||||
"ix_payment_reconciliation_events_tenant_case_time",
|
||||
table_name="payment_reconciliation_events",
|
||||
)
|
||||
op.drop_table("payment_reconciliation_events")
|
||||
op.drop_index(
|
||||
"ix_payment_reconciliation_cases_tenant_status",
|
||||
table_name="payment_reconciliation_cases",
|
||||
)
|
||||
op.drop_table("payment_reconciliation_cases")
|
||||
op.drop_index(
|
||||
"ix_financial_connector_events_tenant_claim",
|
||||
table_name="financial_connector_events",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_financial_connector_events_tenant_received",
|
||||
table_name="financial_connector_events",
|
||||
)
|
||||
op.drop_table("financial_connector_events")
|
||||
op.drop_index(
|
||||
"ix_financial_connector_configs_tenant_status",
|
||||
table_name="financial_connector_configs",
|
||||
)
|
||||
op.drop_table("financial_connector_configs")
|
||||
@@ -0,0 +1,237 @@
|
||||
"""add append-only real release telemetry and review labels
|
||||
|
||||
Revision ID: 20260716_0018
|
||||
Revises: 20260716_0017
|
||||
Create Date: 2026-07-16 22:00:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0018"
|
||||
down_revision: str | None = "20260716_0017"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
_TELEMETRY_TABLES = (
|
||||
"agent_asset_release_observations",
|
||||
"agent_asset_release_labels",
|
||||
)
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0018 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_telemetry_domain_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
counts = {
|
||||
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
|
||||
for table_name in _TELEMETRY_TABLES
|
||||
}
|
||||
if any(counts.values()):
|
||||
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||||
raise RuntimeError(
|
||||
"cannot downgrade release telemetry: immutable observations or labels exist "
|
||||
f"({summary})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"agent_asset_release_observations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("asset_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("release_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("stage", sa.String(length=16), nullable=False),
|
||||
sa.Column("version", sa.String(length=30), nullable=False),
|
||||
sa.Column("rule_code", sa.String(length=100), nullable=False),
|
||||
sa.Column("business_stage", sa.String(length=40), nullable=False),
|
||||
sa.Column(
|
||||
"source_kind",
|
||||
sa.String(length=32),
|
||||
server_default="expense_claim_risk",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("source_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("candidate_hit", sa.Boolean(), nullable=False),
|
||||
sa.Column("baseline_hit", sa.Boolean(), nullable=True),
|
||||
sa.Column("runtime_status", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"failure_code",
|
||||
sa.String(length=40),
|
||||
server_default="none",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("idempotency_key", sa.String(length=80), nullable=False),
|
||||
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"stage IN ('shadow', 'canary', 'active')",
|
||||
name="ck_agent_asset_release_observations_stage",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"runtime_status IN ('completed', 'failed')",
|
||||
name="ck_agent_asset_release_observations_runtime_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"source_kind IN ('expense_claim_risk')",
|
||||
name="ck_agent_asset_release_observations_source_kind",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_agent_asset_release_observations_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_agent_asset_release_observations_tenant_idempotency",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
"asset_id",
|
||||
"release_id",
|
||||
"stage",
|
||||
"version",
|
||||
name="uq_agent_asset_release_observations_release_identity",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_release_observations_release",
|
||||
"agent_asset_release_observations",
|
||||
["tenant_id", "asset_id", "release_id", "stage", "version", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_release_observations_source",
|
||||
"agent_asset_release_observations",
|
||||
["tenant_id", "source_fingerprint"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"agent_asset_release_labels",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("observation_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("asset_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("release_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("stage", sa.String(length=16), nullable=False),
|
||||
sa.Column("version", sa.String(length=30), nullable=False),
|
||||
sa.Column("label", sa.String(length=24), nullable=False),
|
||||
sa.Column("verification_source", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_event_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=80), nullable=False),
|
||||
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"label IN ('confirmed', 'false_positive')",
|
||||
name="ck_agent_asset_release_labels_label",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"verification_source IN ('typed_risk_disposition', 'release_review')",
|
||||
name="ck_agent_asset_release_labels_source",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "observation_id", "asset_id", "release_id", "stage", "version"],
|
||||
[
|
||||
"agent_asset_release_observations.tenant_id",
|
||||
"agent_asset_release_observations.id",
|
||||
"agent_asset_release_observations.asset_id",
|
||||
"agent_asset_release_observations.release_id",
|
||||
"agent_asset_release_observations.stage",
|
||||
"agent_asset_release_observations.version",
|
||||
],
|
||||
name="fk_agent_asset_release_labels_release_observation",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_agent_asset_release_labels_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_agent_asset_release_labels_tenant_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_release_labels_observation_time",
|
||||
"agent_asset_release_labels",
|
||||
["tenant_id", "observation_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_release_labels_release",
|
||||
"agent_asset_release_labels",
|
||||
["tenant_id", "asset_id", "release_id", "stage", "version"],
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION reject_agent_asset_release_telemetry_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'agent asset release telemetry is append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
)
|
||||
for table_name in _TELEMETRY_TABLES:
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE TRIGGER trg_{table_name}_append_only
|
||||
BEFORE UPDATE OR DELETE ON {table_name}
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_agent_asset_release_telemetry_mutation();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_telemetry_domain_for_downgrade()
|
||||
for table_name in reversed(_TELEMETRY_TABLES):
|
||||
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
|
||||
op.execute("DROP FUNCTION IF EXISTS reject_agent_asset_release_telemetry_mutation()")
|
||||
op.drop_index(
|
||||
"ix_agent_asset_release_labels_release",
|
||||
table_name="agent_asset_release_labels",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_agent_asset_release_labels_observation_time",
|
||||
table_name="agent_asset_release_labels",
|
||||
)
|
||||
op.drop_table("agent_asset_release_labels")
|
||||
op.drop_index(
|
||||
"ix_agent_asset_release_observations_source",
|
||||
table_name="agent_asset_release_observations",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_agent_asset_release_observations_release",
|
||||
table_name="agent_asset_release_observations",
|
||||
)
|
||||
op.drop_table("agent_asset_release_observations")
|
||||
@@ -0,0 +1,178 @@
|
||||
"""add commercial runtime quota reservations
|
||||
|
||||
Revision ID: 20260716_0019
|
||||
Revises: 20260716_0018
|
||||
Create Date: 2026-07-16 22:10:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0019"
|
||||
down_revision: str | None = "20260716_0018"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0019 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_reservations_for_downgrade() -> None:
|
||||
count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text("SELECT COUNT(*) FROM commercial_runtime_reservations")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade commercial runtime reservations: "
|
||||
f"operational quota holds exist ({count})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"commercial_runtime_reservations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("entitlement_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=50), nullable=False),
|
||||
sa.Column("tool_call_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tool_type", sa.String(length=30), nullable=False),
|
||||
sa.Column("tool_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("quantity_basis", sa.String(length=20), nullable=False),
|
||||
sa.Column("reserved_quantity", sa.Numeric(20, 6), nullable=False),
|
||||
sa.Column("actual_quantity", sa.Numeric(20, 6), nullable=True),
|
||||
sa.Column("period_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("meter_config_json", sa.JSON(), nullable=False),
|
||||
sa.Column("resolution_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("settled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('reserved', 'committed', 'released', 'expired', "
|
||||
"'reconciliation_required', 'committed_reconciliation_required')",
|
||||
name="ck_commercial_runtime_reservations_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
|
||||
"'total_tokens', 'duration_ms')",
|
||||
name="ck_commercial_runtime_reservations_basis",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"reserved_quantity > 0 AND "
|
||||
"(actual_quantity IS NULL OR actual_quantity > 0)",
|
||||
name="ck_commercial_runtime_reservations_quantity",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expires_at > created_at",
|
||||
name="ck_commercial_runtime_reservations_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(status = 'reserved' AND actual_quantity IS NULL AND settled_at IS NULL "
|
||||
"AND resolution_code IS NULL) OR "
|
||||
"(status = 'committed' AND actual_quantity IS NOT NULL "
|
||||
"AND actual_quantity <= reserved_quantity AND settled_at IS NOT NULL "
|
||||
"AND resolution_code IS NULL) OR "
|
||||
"(status IN ('released', 'expired') AND actual_quantity IS NULL "
|
||||
"AND settled_at IS NOT NULL AND resolution_code IS NOT NULL) OR "
|
||||
"(status = 'reconciliation_required' AND settled_at IS NULL "
|
||||
"AND resolution_code IS NOT NULL) OR "
|
||||
"(status = 'committed_reconciliation_required' "
|
||||
"AND actual_quantity IS NOT NULL "
|
||||
"AND actual_quantity <= reserved_quantity "
|
||||
"AND settled_at IS NOT NULL AND resolution_code IS NOT NULL)",
|
||||
name="ck_commercial_runtime_reservations_state",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
|
||||
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
|
||||
"AND length(trim(period_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0",
|
||||
name="ck_commercial_runtime_reservations_keys",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id"],
|
||||
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
|
||||
name="fk_commercial_runtime_reservations_tenant_subscription",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id", "entitlement_id"],
|
||||
[
|
||||
"commercial_entitlements.tenant_id",
|
||||
"commercial_entitlements.subscription_id",
|
||||
"commercial_entitlements.id",
|
||||
],
|
||||
name="fk_commercial_runtime_reservations_tenant_entitlement",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_commercial_runtime_reservations_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tool_call_id",
|
||||
name="uq_commercial_runtime_reservations_tool_call",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_runtime_reservations_quota",
|
||||
"commercial_runtime_reservations",
|
||||
["tenant_id", "subscription_id", "entitlement_id", "period_key", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_runtime_reservations_expiry",
|
||||
"commercial_runtime_reservations",
|
||||
["status", "expires_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_runtime_reservations_run",
|
||||
"commercial_runtime_reservations",
|
||||
["tenant_id", "run_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_reservations_for_downgrade()
|
||||
op.drop_index(
|
||||
"ix_commercial_runtime_reservations_run",
|
||||
table_name="commercial_runtime_reservations",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_commercial_runtime_reservations_expiry",
|
||||
table_name="commercial_runtime_reservations",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_commercial_runtime_reservations_quota",
|
||||
table_name="commercial_runtime_reservations",
|
||||
)
|
||||
op.drop_table("commercial_runtime_reservations")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""add versioned financial connector config lifecycle audit
|
||||
|
||||
Revision ID: 20260716_0020
|
||||
Revises: 20260716_0019
|
||||
Create Date: 2026-07-16 22:40:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0020"
|
||||
down_revision: str | None = "20260716_0019"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0020 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_lifecycle_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
counts = {
|
||||
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
|
||||
for table_name in (
|
||||
"financial_connector_configs",
|
||||
"financial_connector_config_events",
|
||||
)
|
||||
}
|
||||
if any(counts.values()):
|
||||
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||||
raise RuntimeError(
|
||||
"cannot downgrade financial connector lifecycle: versioned configuration "
|
||||
f"state or immutable audit facts exist ({summary})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
# 0017 曾冗余保存完整 claim_reference。这里以受控迁移完成一次性脱敏,
|
||||
# 内容哈希和签名指纹仍足以证明原始回执,运行期触发器随后立即恢复。
|
||||
op.execute(
|
||||
"ALTER TABLE financial_connector_events "
|
||||
"DISABLE TRIGGER trg_financial_connector_events_append_only"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE financial_connector_events "
|
||||
"SET normalized_payload_json = "
|
||||
"(normalized_payload_json::jsonb - 'claim_reference')::json "
|
||||
"WHERE normalized_payload_json::jsonb ? 'claim_reference'"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE financial_connector_events SET response_json = "
|
||||
"jsonb_set(response_json::jsonb, '{projection_scope}', "
|
||||
"to_jsonb((CASE WHEN verification_level = 'production_verified' "
|
||||
"THEN 'canonical' "
|
||||
"WHEN COALESCE(response_json::jsonb ->> 'reconciliation_case_id', '') <> '' "
|
||||
"THEN 'legacy_nonproduction_effect_unknown' "
|
||||
"ELSE 'simulation_only' END)::text), true)::json "
|
||||
"WHERE NOT (response_json::jsonb ? 'projection_scope')"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE financial_connector_events "
|
||||
"ENABLE TRIGGER trg_financial_connector_events_append_only"
|
||||
)
|
||||
op.add_column(
|
||||
"financial_connector_configs",
|
||||
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_financial_connector_configs_version",
|
||||
"financial_connector_configs",
|
||||
"version >= 1",
|
||||
)
|
||||
op.alter_column(
|
||||
"financial_connector_configs",
|
||||
"version",
|
||||
existing_type=sa.Integer(),
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"financial_connector_config_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("config_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("action", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("request_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("expected_version", sa.Integer(), nullable=True),
|
||||
sa.Column("before_json", sa.JSON(), nullable=False),
|
||||
sa.Column("after_json", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"occurred_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"action IN ('created', 'activated', 'disabled', "
|
||||
"'rotation_started', 'rotation_replacement_created')",
|
||||
name="ck_financial_connector_config_events_action",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expected_version IS NULL OR expected_version >= 1",
|
||||
name="ck_financial_connector_config_events_expected_version",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
|
||||
"AND length(trim(reason)) > 0",
|
||||
name="ck_financial_connector_config_events_required_text",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "config_id"],
|
||||
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
||||
name="fk_financial_connector_config_events_tenant_config",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_financial_connector_config_events_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
"action",
|
||||
name="uq_financial_connector_config_events_tenant_request_action",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_config_events_tenant_config_time",
|
||||
"financial_connector_config_events",
|
||||
["tenant_id", "config_id", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_config_events_tenant_request",
|
||||
"financial_connector_config_events",
|
||||
["tenant_id", "request_id"],
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_financial_connector_config_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON financial_connector_config_events
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_lifecycle_for_downgrade()
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS trg_financial_connector_config_events_append_only "
|
||||
"ON financial_connector_config_events"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_financial_connector_config_events_tenant_request",
|
||||
table_name="financial_connector_config_events",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_financial_connector_config_events_tenant_config_time",
|
||||
table_name="financial_connector_config_events",
|
||||
)
|
||||
op.drop_table("financial_connector_config_events")
|
||||
op.drop_constraint(
|
||||
"ck_financial_connector_configs_version",
|
||||
"financial_connector_configs",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("financial_connector_configs", "version")
|
||||
@@ -0,0 +1,724 @@
|
||||
"""add immutable commercial billing periods and administration audit
|
||||
|
||||
Revision ID: 20260716_0021
|
||||
Revises: 20260716_0020
|
||||
Create Date: 2026-07-16 22:45:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0021"
|
||||
down_revision: str | None = "20260716_0020"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0021 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_billing_lifecycle_for_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
counts = {
|
||||
table_name: int(bind.scalar(sa.text(f"SELECT COUNT(*) FROM {table_name}")) or 0)
|
||||
for table_name in ("commercial_admin_events", "commercial_billing_periods")
|
||||
}
|
||||
if any(counts.values()):
|
||||
summary = ", ".join(f"{name}={count}" for name, count in counts.items())
|
||||
raise RuntimeError(
|
||||
"cannot downgrade commercial billing lifecycle: immutable periods or audit "
|
||||
f"facts exist ({summary})"
|
||||
)
|
||||
|
||||
|
||||
def _json_object_default() -> sa.TextClause:
|
||||
return sa.text("'{}'::json")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
_create_billing_periods()
|
||||
_create_admin_events()
|
||||
_backfill_current_periods_and_audit()
|
||||
_bind_usage_to_periods()
|
||||
_bind_costs_to_periods()
|
||||
_bind_reservations_to_periods()
|
||||
_create_period_overlap_guard()
|
||||
_create_append_only_triggers()
|
||||
|
||||
|
||||
def _create_billing_periods() -> None:
|
||||
op.create_table(
|
||||
"commercial_billing_periods",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("subscription_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("plan_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("period_sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("period_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="issued"),
|
||||
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("subscription_status_snapshot", sa.String(length=20), nullable=False),
|
||||
sa.Column("plan_code_snapshot", sa.String(length=80), nullable=False),
|
||||
sa.Column("plan_version_snapshot", sa.Integer(), nullable=False),
|
||||
sa.Column("pricing_model_snapshot", sa.String(length=24), nullable=False),
|
||||
sa.Column("billing_interval", sa.String(length=20), nullable=False),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("base_fee_snapshot", sa.Numeric(20, 4), nullable=False),
|
||||
sa.Column("seats_snapshot", sa.Integer(), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=120), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status = 'issued'",
|
||||
name="ck_commercial_billing_periods_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"subscription_status_snapshot IN ('trialing', 'active', 'past_due', "
|
||||
"'suspended', 'canceled', 'expired')",
|
||||
name="ck_commercial_billing_periods_subscription_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"pricing_model_snapshot IN ('subscription', 'usage', 'hybrid', 'pilot', 'custom')",
|
||||
name="ck_commercial_billing_periods_pricing_model",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
|
||||
name="ck_commercial_billing_periods_interval",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"source IN ('subscription_created', 'auto_renew', 'migration_backfill')",
|
||||
name="ck_commercial_billing_periods_source",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"period_sequence >= 1 AND period_end > period_start "
|
||||
"AND plan_version_snapshot >= 1 AND base_fee_snapshot >= 0 "
|
||||
"AND seats_snapshot > 0",
|
||||
name="ck_commercial_billing_periods_values",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(period_key)) > 0 AND length(trim(plan_code_snapshot)) > 0 "
|
||||
"AND length(trim(currency)) = 3 AND length(trim(idempotency_key)) > 0 "
|
||||
"AND length(trim(created_by)) > 0",
|
||||
name="ck_commercial_billing_periods_keys",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "subscription_id"],
|
||||
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
|
||||
name="fk_commercial_billing_periods_tenant_subscription",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "plan_id"],
|
||||
["tenant_commercial_plans.tenant_id", "tenant_commercial_plans.id"],
|
||||
name="fk_commercial_billing_periods_tenant_plan",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_commercial_billing_periods_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"id",
|
||||
name="uq_commercial_billing_periods_tenant_subscription_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"period_sequence",
|
||||
name="uq_commercial_billing_periods_subscription_sequence",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"period_key",
|
||||
name="uq_commercial_billing_periods_subscription_key",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"period_start",
|
||||
name="uq_commercial_billing_periods_subscription_start",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subscription_id",
|
||||
"idempotency_key",
|
||||
name="uq_commercial_billing_periods_subscription_request",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_billing_periods_tenant_window",
|
||||
"commercial_billing_periods",
|
||||
["tenant_id", "period_start", "period_end"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_billing_periods_subscription_window",
|
||||
"commercial_billing_periods",
|
||||
["tenant_id", "subscription_id", "period_start", "period_end"],
|
||||
)
|
||||
|
||||
|
||||
def _create_admin_events() -> None:
|
||||
op.create_table(
|
||||
"commercial_admin_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("request_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("action", sa.String(length=48), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=24), nullable=False),
|
||||
sa.Column("resource_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("resource_version", sa.Integer(), nullable=False),
|
||||
sa.Column("before_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
|
||||
sa.Column("after_json", sa.JSON(), nullable=False, server_default=_json_object_default()),
|
||||
sa.Column(
|
||||
"occurred_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"actor_type IN ('user', 'system', 'migration')",
|
||||
name="ck_commercial_admin_events_actor_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"action IN ('plan_created', 'plan_activated', 'plan_retired', "
|
||||
"'subscription_created', 'subscription_activated', "
|
||||
"'subscription_transitioned', 'entitlement_created', "
|
||||
"'entitlement_updated', 'entitlement_activated', "
|
||||
"'billing_period_created', 'subscription_rolled_over', "
|
||||
"'legacy_state_imported')",
|
||||
name="ck_commercial_admin_events_action",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"resource_type IN ('plan', 'subscription', 'entitlement', 'billing_period')",
|
||||
name="ck_commercial_admin_events_resource_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"resource_version >= 1",
|
||||
name="ck_commercial_admin_events_resource_version",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
|
||||
"AND length(trim(reason)) > 0 AND length(trim(resource_id)) > 0",
|
||||
name="ck_commercial_admin_events_required_text",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "id", name="uq_commercial_admin_events_tenant_id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
"action",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
name="uq_commercial_admin_events_request_resource",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_admin_events_tenant_time",
|
||||
"commercial_admin_events",
|
||||
["tenant_id", "occurred_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_admin_events_tenant_resource",
|
||||
"commercial_admin_events",
|
||||
["tenant_id", "resource_type", "resource_id", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_admin_events_tenant_request",
|
||||
"commercial_admin_events",
|
||||
["tenant_id", "request_id"],
|
||||
)
|
||||
|
||||
|
||||
def _backfill_current_periods_and_audit() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO commercial_billing_periods (
|
||||
id, tenant_id, subscription_id, plan_id, period_sequence, period_key,
|
||||
status, period_start, period_end, subscription_status_snapshot,
|
||||
plan_code_snapshot, plan_version_snapshot, pricing_model_snapshot,
|
||||
billing_interval, currency, base_fee_snapshot, seats_snapshot,
|
||||
source, idempotency_key, created_by
|
||||
)
|
||||
SELECT
|
||||
substr(digest, 1, 8) || '-' || substr(digest, 9, 4) || '-' ||
|
||||
substr(digest, 13, 4) || '-' || substr(digest, 17, 4) || '-' ||
|
||||
substr(digest, 21, 12),
|
||||
subscription.tenant_id,
|
||||
subscription.id,
|
||||
subscription.plan_id,
|
||||
1,
|
||||
'bp-' || substr(digest, 1, 24),
|
||||
'issued',
|
||||
subscription.current_period_start,
|
||||
subscription.current_period_end,
|
||||
subscription.status,
|
||||
plan.plan_code,
|
||||
plan.version,
|
||||
plan.pricing_model,
|
||||
subscription.billing_interval,
|
||||
subscription.currency,
|
||||
subscription.base_fee_snapshot,
|
||||
subscription.seats,
|
||||
'migration_backfill',
|
||||
'migration-0021:' || subscription.id,
|
||||
'migration:20260716_0021'
|
||||
FROM tenant_subscriptions AS subscription
|
||||
JOIN tenant_commercial_plans AS plan
|
||||
ON plan.tenant_id = subscription.tenant_id
|
||||
AND plan.id = subscription.plan_id
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT md5(
|
||||
subscription.tenant_id || ':' || subscription.id || ':' ||
|
||||
subscription.current_period_start::text || ':' ||
|
||||
subscription.current_period_end::text
|
||||
) AS digest
|
||||
) AS identity
|
||||
"""
|
||||
)
|
||||
for resource_type, table_name, version_column in (
|
||||
("plan", "tenant_commercial_plans", "version"),
|
||||
("subscription", "tenant_subscriptions", "version"),
|
||||
("entitlement", "commercial_entitlements", "version"),
|
||||
("billing_period", "commercial_billing_periods", "period_sequence"),
|
||||
):
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO commercial_admin_events (
|
||||
id, tenant_id, actor_type, actor_id, request_id, reason, action,
|
||||
resource_type, resource_id, resource_version, before_json, after_json
|
||||
)
|
||||
SELECT
|
||||
substr(digest, 1, 8) || '-' || substr(digest, 9, 4) || '-' ||
|
||||
substr(digest, 13, 4) || '-' || substr(digest, 17, 4) || '-' ||
|
||||
substr(digest, 21, 12),
|
||||
resource.tenant_id,
|
||||
'migration',
|
||||
'migration:20260716_0021',
|
||||
'migration:0021:{resource_type}:' || resource.id,
|
||||
'0021 建立商业管理审计基线,不推断迁移前操作人或原始请求。',
|
||||
'legacy_state_imported',
|
||||
'{resource_type}',
|
||||
resource.id,
|
||||
resource.{version_column},
|
||||
'{{}}'::json,
|
||||
json_build_object('id', resource.id, 'version', resource.{version_column})
|
||||
FROM {table_name} AS resource
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT md5(
|
||||
resource.tenant_id || ':audit:{resource_type}:' || resource.id
|
||||
) AS digest
|
||||
) AS identity
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _bind_usage_to_periods() -> None:
|
||||
op.add_column(
|
||||
"usage_meter_events",
|
||||
sa.Column("billing_period_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"usage_meter_events",
|
||||
sa.Column("quota_period_key", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"usage_meter_events",
|
||||
"period_key",
|
||||
existing_type=sa.String(length=32),
|
||||
type_=sa.String(length=64),
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE usage_meter_events AS usage
|
||||
SET billing_period_id = period.id,
|
||||
quota_period_key = usage.period_key,
|
||||
period_key = period.period_key
|
||||
FROM commercial_billing_periods AS period
|
||||
WHERE period.tenant_id = usage.tenant_id
|
||||
AND period.subscription_id = usage.subscription_id
|
||||
AND usage.occurred_at >= period.period_start
|
||||
AND usage.occurred_at < period.period_end
|
||||
"""
|
||||
)
|
||||
_fail_if_unbound(
|
||||
"usage_meter_events",
|
||||
"billing_period_id IS NULL OR quota_period_key IS NULL",
|
||||
"existing usage facts do not map to one immutable current billing period",
|
||||
)
|
||||
op.alter_column(
|
||||
"usage_meter_events", "billing_period_id", existing_type=sa.String(36), nullable=False
|
||||
)
|
||||
op.alter_column(
|
||||
"usage_meter_events", "quota_period_key", existing_type=sa.String(64), nullable=False
|
||||
)
|
||||
op.drop_constraint("ck_usage_meter_events_keys", "usage_meter_events", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_usage_meter_events_keys",
|
||||
"usage_meter_events",
|
||||
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
|
||||
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
|
||||
"AND length(trim(quota_period_key)) > 0 "
|
||||
"AND length(trim(idempotency_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_usage_meter_events_tenant_billing_period",
|
||||
"usage_meter_events",
|
||||
"commercial_billing_periods",
|
||||
["tenant_id", "subscription_id", "billing_period_id"],
|
||||
["tenant_id", "subscription_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.drop_index("ix_usage_meter_events_quota_window", table_name="usage_meter_events")
|
||||
op.create_index(
|
||||
"ix_usage_meter_events_quota_window",
|
||||
"usage_meter_events",
|
||||
["tenant_id", "subscription_id", "metric_key", "quota_period_key", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_usage_meter_events_billing_period",
|
||||
"usage_meter_events",
|
||||
["tenant_id", "billing_period_id", "occurred_at"],
|
||||
)
|
||||
|
||||
|
||||
def _bind_costs_to_periods() -> None:
|
||||
op.add_column(
|
||||
"commercial_cost_events",
|
||||
sa.Column("billing_period_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE commercial_cost_events AS cost
|
||||
SET billing_period_id = usage.billing_period_id
|
||||
FROM usage_meter_events AS usage
|
||||
WHERE usage.tenant_id = cost.tenant_id
|
||||
AND usage.subscription_id = cost.subscription_id
|
||||
AND usage.id = cost.usage_event_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE commercial_cost_events AS cost
|
||||
SET billing_period_id = period.id
|
||||
FROM commercial_billing_periods AS period
|
||||
WHERE cost.subscription_id IS NOT NULL
|
||||
AND cost.billing_period_id IS NULL
|
||||
AND period.tenant_id = cost.tenant_id
|
||||
AND period.subscription_id = cost.subscription_id
|
||||
AND cost.occurred_at >= period.period_start
|
||||
AND cost.occurred_at < period.period_end
|
||||
"""
|
||||
)
|
||||
_fail_if_unbound(
|
||||
"commercial_cost_events",
|
||||
"subscription_id IS NOT NULL AND billing_period_id IS NULL",
|
||||
"existing subscription cost facts do not map to one immutable billing period",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_commercial_cost_events_billing_period_pair",
|
||||
"commercial_cost_events",
|
||||
"(subscription_id IS NULL AND billing_period_id IS NULL) OR "
|
||||
"(subscription_id IS NOT NULL AND billing_period_id IS NOT NULL)",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_commercial_cost_events_tenant_billing_period",
|
||||
"commercial_cost_events",
|
||||
"commercial_billing_periods",
|
||||
["tenant_id", "subscription_id", "billing_period_id"],
|
||||
["tenant_id", "subscription_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_commercial_cost_events_subscription_period",
|
||||
table_name="commercial_cost_events",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_cost_events_subscription_period",
|
||||
"commercial_cost_events",
|
||||
["tenant_id", "subscription_id", "billing_period_id", "occurred_at"],
|
||||
)
|
||||
|
||||
|
||||
def _bind_reservations_to_periods() -> None:
|
||||
op.add_column(
|
||||
"commercial_runtime_reservations",
|
||||
sa.Column("billing_period_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"commercial_runtime_reservations",
|
||||
sa.Column("quota_period_key", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"commercial_runtime_reservations",
|
||||
"period_key",
|
||||
existing_type=sa.String(length=32),
|
||||
type_=sa.String(length=64),
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE commercial_runtime_reservations AS reservation
|
||||
SET billing_period_id = period.id,
|
||||
quota_period_key = reservation.period_key,
|
||||
period_key = period.period_key
|
||||
FROM commercial_billing_periods AS period
|
||||
WHERE period.tenant_id = reservation.tenant_id
|
||||
AND period.subscription_id = reservation.subscription_id
|
||||
AND reservation.created_at >= period.period_start
|
||||
AND reservation.created_at < period.period_end
|
||||
"""
|
||||
)
|
||||
_fail_if_unbound(
|
||||
"commercial_runtime_reservations",
|
||||
"billing_period_id IS NULL OR quota_period_key IS NULL",
|
||||
"existing runtime reservations do not map to one immutable billing period",
|
||||
)
|
||||
op.alter_column(
|
||||
"commercial_runtime_reservations",
|
||||
"billing_period_id",
|
||||
existing_type=sa.String(36),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"commercial_runtime_reservations",
|
||||
"quota_period_key",
|
||||
existing_type=sa.String(64),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_commercial_runtime_reservations_keys",
|
||||
"commercial_runtime_reservations",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_commercial_runtime_reservations_keys",
|
||||
"commercial_runtime_reservations",
|
||||
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
|
||||
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
|
||||
"AND length(trim(period_key)) > 0 AND length(trim(quota_period_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_commercial_runtime_reservations_tenant_billing_period",
|
||||
"commercial_runtime_reservations",
|
||||
"commercial_billing_periods",
|
||||
["tenant_id", "subscription_id", "billing_period_id"],
|
||||
["tenant_id", "subscription_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_commercial_runtime_reservations_quota",
|
||||
table_name="commercial_runtime_reservations",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_runtime_reservations_quota",
|
||||
"commercial_runtime_reservations",
|
||||
["tenant_id", "subscription_id", "entitlement_id", "quota_period_key", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_runtime_reservations_billing_period",
|
||||
"commercial_runtime_reservations",
|
||||
["tenant_id", "billing_period_id", "status"],
|
||||
)
|
||||
|
||||
|
||||
def _fail_if_unbound(table_name: str, predicate: str, message: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM {table_name} WHERE {predicate}) THEN
|
||||
RAISE EXCEPTION '{message}';
|
||||
END IF;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _create_append_only_triggers() -> None:
|
||||
for table_name in ("commercial_billing_periods", "commercial_admin_events"):
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE TRIGGER trg_{table_name}_append_only
|
||||
BEFORE UPDATE OR DELETE ON {table_name}
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_commercial_events_mutation()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _create_period_overlap_guard() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION prevent_commercial_billing_period_overlap()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(
|
||||
hashtextextended(NEW.tenant_id || ':' || NEW.subscription_id, 0)
|
||||
);
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM commercial_billing_periods AS existing
|
||||
WHERE existing.tenant_id = NEW.tenant_id
|
||||
AND existing.subscription_id = NEW.subscription_id
|
||||
AND tstzrange(
|
||||
existing.period_start,
|
||||
existing.period_end,
|
||||
'[)'
|
||||
) && tstzrange(NEW.period_start, NEW.period_end, '[)')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'commercial billing period overlaps an issued period';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_commercial_billing_periods_no_overlap
|
||||
BEFORE INSERT ON commercial_billing_periods
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_commercial_billing_period_overlap()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_billing_lifecycle_for_downgrade()
|
||||
_unbind_reservations()
|
||||
_unbind_costs()
|
||||
_unbind_usage()
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS trg_commercial_billing_periods_no_overlap "
|
||||
"ON commercial_billing_periods"
|
||||
)
|
||||
for table_name in ("commercial_admin_events", "commercial_billing_periods"):
|
||||
op.execute(f"DROP TRIGGER IF EXISTS trg_{table_name}_append_only ON {table_name}")
|
||||
op.drop_table("commercial_admin_events")
|
||||
op.drop_table("commercial_billing_periods")
|
||||
op.execute("DROP FUNCTION IF EXISTS prevent_commercial_billing_period_overlap()")
|
||||
|
||||
|
||||
def _unbind_reservations() -> None:
|
||||
op.drop_index(
|
||||
"ix_commercial_runtime_reservations_billing_period",
|
||||
table_name="commercial_runtime_reservations",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_commercial_runtime_reservations_quota",
|
||||
table_name="commercial_runtime_reservations",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_runtime_reservations_quota",
|
||||
"commercial_runtime_reservations",
|
||||
["tenant_id", "subscription_id", "entitlement_id", "period_key", "status"],
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_commercial_runtime_reservations_tenant_billing_period",
|
||||
"commercial_runtime_reservations",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_commercial_runtime_reservations_keys",
|
||||
"commercial_runtime_reservations",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_commercial_runtime_reservations_keys",
|
||||
"commercial_runtime_reservations",
|
||||
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
|
||||
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
|
||||
"AND length(trim(period_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0",
|
||||
)
|
||||
op.drop_column("commercial_runtime_reservations", "quota_period_key")
|
||||
op.drop_column("commercial_runtime_reservations", "billing_period_id")
|
||||
op.alter_column(
|
||||
"commercial_runtime_reservations",
|
||||
"period_key",
|
||||
existing_type=sa.String(length=64),
|
||||
type_=sa.String(length=32),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def _unbind_costs() -> None:
|
||||
op.drop_index(
|
||||
"ix_commercial_cost_events_subscription_period",
|
||||
table_name="commercial_cost_events",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_commercial_cost_events_subscription_period",
|
||||
"commercial_cost_events",
|
||||
["tenant_id", "subscription_id", "occurred_at"],
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_commercial_cost_events_tenant_billing_period",
|
||||
"commercial_cost_events",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_commercial_cost_events_billing_period_pair",
|
||||
"commercial_cost_events",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("commercial_cost_events", "billing_period_id")
|
||||
|
||||
|
||||
def _unbind_usage() -> None:
|
||||
op.drop_index("ix_usage_meter_events_billing_period", table_name="usage_meter_events")
|
||||
op.drop_index("ix_usage_meter_events_quota_window", table_name="usage_meter_events")
|
||||
op.create_index(
|
||||
"ix_usage_meter_events_quota_window",
|
||||
"usage_meter_events",
|
||||
["tenant_id", "subscription_id", "metric_key", "period_key", "occurred_at"],
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_usage_meter_events_tenant_billing_period",
|
||||
"usage_meter_events",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_constraint("ck_usage_meter_events_keys", "usage_meter_events", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_usage_meter_events_keys",
|
||||
"usage_meter_events",
|
||||
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
|
||||
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
|
||||
"AND length(trim(idempotency_key)) > 0 "
|
||||
"AND length(trim(request_fingerprint)) > 0",
|
||||
)
|
||||
op.drop_column("usage_meter_events", "quota_period_key")
|
||||
op.drop_column("usage_meter_events", "billing_period_id")
|
||||
op.alter_column(
|
||||
"usage_meter_events",
|
||||
"period_key",
|
||||
existing_type=sa.String(length=64),
|
||||
type_=sa.String(length=32),
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""add durable financial connector operational events
|
||||
|
||||
Revision ID: 20260716_0022
|
||||
Revises: 20260716_0021
|
||||
Create Date: 2026-07-17 00:30:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0022"
|
||||
down_revision: str | None = "20260716_0021"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0022 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_operational_events_for_downgrade() -> None:
|
||||
count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text("SELECT COUNT(*) FROM financial_connector_operational_events")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade financial connector operational events: "
|
||||
f"immutable operational facts exist (financial_connector_operational_events={count})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"financial_connector_operational_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("config_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=False),
|
||||
sa.Column("environment", sa.String(length=16), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("reason_code", sa.String(length=80), nullable=False),
|
||||
sa.Column("request_fingerprint", sa.String(length=76), nullable=False),
|
||||
sa.Column("external_event_fingerprint", sa.String(length=76), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=71), nullable=False),
|
||||
sa.Column(
|
||||
"occurred_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"event_type IN ('replay', 'auth_failure', 'payload_conflict')",
|
||||
name="ck_financial_connector_operational_events_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"environment IN ('test', 'mock', 'staging', 'production')",
|
||||
name="ck_financial_connector_operational_events_environment",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(provider)) > 0 AND length(trim(reason_code)) > 0",
|
||||
name="ck_financial_connector_operational_events_required_text",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(request_fingerprint) = 76 "
|
||||
"AND request_fingerprint LIKE 'hmac-sha256:%' "
|
||||
"AND length(external_event_fingerprint) = 76 "
|
||||
"AND external_event_fingerprint LIKE 'hmac-sha256:%' "
|
||||
"AND length(idempotency_key) = 71 "
|
||||
"AND idempotency_key LIKE 'sha256:%'",
|
||||
name="ck_financial_connector_operational_events_fingerprints",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "config_id"],
|
||||
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
|
||||
name="fk_financial_connector_operational_events_tenant_config",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_financial_connector_operational_events_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_financial_connector_operational_events_tenant_request",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_operational_events_tenant_config_time",
|
||||
"financial_connector_operational_events",
|
||||
["tenant_id", "config_id", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_operational_events_tenant_type_time",
|
||||
"financial_connector_operational_events",
|
||||
["tenant_id", "event_type", "occurred_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_financial_connector_operational_events_tenant_provider_time",
|
||||
"financial_connector_operational_events",
|
||||
["tenant_id", "provider", "occurred_at"],
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER trg_financial_connector_operational_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON financial_connector_operational_events
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_financial_connector_append_only_mutation()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_operational_events_for_downgrade()
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS "
|
||||
"trg_financial_connector_operational_events_append_only "
|
||||
"ON financial_connector_operational_events"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_financial_connector_operational_events_tenant_provider_time",
|
||||
table_name="financial_connector_operational_events",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_financial_connector_operational_events_tenant_type_time",
|
||||
table_name="financial_connector_operational_events",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_financial_connector_operational_events_tenant_config_time",
|
||||
table_name="financial_connector_operational_events",
|
||||
)
|
||||
op.drop_table("financial_connector_operational_events")
|
||||
@@ -0,0 +1,206 @@
|
||||
"""add blind negative audit samples and recall ground-truth labels
|
||||
|
||||
Revision ID: 20260716_0023
|
||||
Revises: 20260716_0022
|
||||
Create Date: 2026-07-16 23:50:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0023"
|
||||
down_revision: str | None = "20260716_0022"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
_AUDIT_SAMPLE_TABLE = "agent_asset_release_audit_samples"
|
||||
_LABEL_TABLE = "agent_asset_release_labels"
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260716_0023 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_lossless_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
audit_sample_count = int(
|
||||
bind.scalar(sa.text(f"SELECT COUNT(*) FROM {_AUDIT_SAMPLE_TABLE}")) or 0
|
||||
)
|
||||
extended_label_count = int(
|
||||
bind.scalar(
|
||||
sa.text(
|
||||
f"SELECT COUNT(*) FROM {_LABEL_TABLE} "
|
||||
"WHERE label IN ('risk_present', 'risk_absent') "
|
||||
"OR verification_source = 'blind_release_review'"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if audit_sample_count or extended_label_count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade release blind audit: immutable audit evidence exists "
|
||||
f"(audit_samples={audit_sample_count}, extended_labels={extended_label_count})"
|
||||
)
|
||||
|
||||
|
||||
def _replace_label_constraints(*, expanded: bool) -> None:
|
||||
if not expanded:
|
||||
op.drop_constraint(
|
||||
"ck_agent_asset_release_labels_semantics",
|
||||
_LABEL_TABLE,
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_agent_asset_release_labels_label",
|
||||
_LABEL_TABLE,
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_agent_asset_release_labels_source",
|
||||
_LABEL_TABLE,
|
||||
type_="check",
|
||||
)
|
||||
if expanded:
|
||||
label_values = (
|
||||
"label IN ('confirmed', 'false_positive', 'risk_present', 'risk_absent')"
|
||||
)
|
||||
source_values = (
|
||||
"verification_source IN ('typed_risk_disposition', 'release_review', "
|
||||
"'blind_release_review')"
|
||||
)
|
||||
else:
|
||||
label_values = "label IN ('confirmed', 'false_positive')"
|
||||
source_values = (
|
||||
"verification_source IN ('typed_risk_disposition', 'release_review')"
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_agent_asset_release_labels_label",
|
||||
_LABEL_TABLE,
|
||||
label_values,
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_agent_asset_release_labels_source",
|
||||
_LABEL_TABLE,
|
||||
source_values,
|
||||
)
|
||||
if expanded:
|
||||
op.create_check_constraint(
|
||||
"ck_agent_asset_release_labels_semantics",
|
||||
_LABEL_TABLE,
|
||||
"(verification_source = 'blind_release_review' "
|
||||
"AND label IN ('risk_present', 'risk_absent')) OR "
|
||||
"(verification_source IN ('typed_risk_disposition', 'release_review') "
|
||||
"AND label IN ('confirmed', 'false_positive'))",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
_replace_label_constraints(expanded=True)
|
||||
op.create_table(
|
||||
_AUDIT_SAMPLE_TABLE,
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("observation_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("asset_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("release_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("stage", sa.String(length=16), nullable=False),
|
||||
sa.Column("version", sa.String(length=30), nullable=False),
|
||||
sa.Column("stratum", sa.String(length=40), nullable=False),
|
||||
sa.Column("sampling_probability_ppm", sa.Integer(), nullable=False),
|
||||
sa.Column("selection_score_ppm", sa.Integer(), nullable=False),
|
||||
sa.Column("source_reference_encrypted", sa.Text(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=80), nullable=False),
|
||||
sa.Column("payload_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"stratum IN ('candidate_positive_census', "
|
||||
"'candidate_disagreement_census', 'candidate_negative_random')",
|
||||
name="ck_agent_asset_release_audit_samples_stratum",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"sampling_probability_ppm BETWEEN 1 AND 1000000",
|
||||
name="ck_agent_asset_release_audit_samples_probability",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"selection_score_ppm BETWEEN 0 AND 999999",
|
||||
name="ck_agent_asset_release_audit_samples_score",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
[
|
||||
"tenant_id",
|
||||
"observation_id",
|
||||
"asset_id",
|
||||
"release_id",
|
||||
"stage",
|
||||
"version",
|
||||
],
|
||||
[
|
||||
"agent_asset_release_observations.tenant_id",
|
||||
"agent_asset_release_observations.id",
|
||||
"agent_asset_release_observations.asset_id",
|
||||
"agent_asset_release_observations.release_id",
|
||||
"agent_asset_release_observations.stage",
|
||||
"agent_asset_release_observations.version",
|
||||
],
|
||||
name="fk_agent_asset_release_audit_samples_observation",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_agent_asset_release_audit_samples_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"observation_id",
|
||||
name="uq_agent_asset_release_audit_samples_observation",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_agent_asset_release_audit_samples_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_release_audit_samples_release",
|
||||
_AUDIT_SAMPLE_TABLE,
|
||||
["tenant_id", "asset_id", "release_id", "stage", "version", "created_at"],
|
||||
)
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE TRIGGER trg_{_AUDIT_SAMPLE_TABLE}_append_only
|
||||
BEFORE UPDATE OR DELETE ON {_AUDIT_SAMPLE_TABLE}
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_agent_asset_release_telemetry_mutation();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_lossless_downgrade()
|
||||
op.execute(
|
||||
f"DROP TRIGGER IF EXISTS trg_{_AUDIT_SAMPLE_TABLE}_append_only "
|
||||
f"ON {_AUDIT_SAMPLE_TABLE}"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_agent_asset_release_audit_samples_release",
|
||||
table_name=_AUDIT_SAMPLE_TABLE,
|
||||
)
|
||||
op.drop_table(_AUDIT_SAMPLE_TABLE)
|
||||
_replace_label_constraints(expanded=False)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""expand commercial runtime resource quantity bases
|
||||
|
||||
Revision ID: 20260717_0024
|
||||
Revises: 20260716_0023
|
||||
Create Date: 2026-07-17 09:30:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260717_0024"
|
||||
down_revision: str | None = "20260716_0023"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_CONSTRAINT = "ck_commercial_runtime_reservations_basis"
|
||||
_LEGACY_BASES = (
|
||||
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
|
||||
"'total_tokens', 'duration_ms')"
|
||||
)
|
||||
_RESOURCE_BASES = (
|
||||
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
|
||||
"'total_tokens', 'duration_ms', 'bytes', 'pages', 'objects', 'events')"
|
||||
)
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260717_0024 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_no_resource_reservations_for_downgrade() -> None:
|
||||
count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM commercial_runtime_reservations "
|
||||
"WHERE quantity_basis IN ('bytes', 'pages', 'objects', 'events')"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade commercial resource quantity bases: "
|
||||
f"resource reservations exist ({count})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.drop_constraint(
|
||||
_CONSTRAINT,
|
||||
"commercial_runtime_reservations",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
_CONSTRAINT,
|
||||
"commercial_runtime_reservations",
|
||||
_RESOURCE_BASES,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_no_resource_reservations_for_downgrade()
|
||||
op.drop_constraint(
|
||||
_CONSTRAINT,
|
||||
"commercial_runtime_reservations",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
_CONSTRAINT,
|
||||
"commercial_runtime_reservations",
|
||||
_LEGACY_BASES,
|
||||
)
|
||||
@@ -0,0 +1,639 @@
|
||||
"""establish trusted tenant identity foundation
|
||||
|
||||
Revision ID: 20260717_0025
|
||||
Revises: 20260717_0024
|
||||
Create Date: 2026-07-17 10:20:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260717_0025"
|
||||
down_revision: str | None = "20260717_0024"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_DEFAULT_TENANT = "default"
|
||||
_TENANT_SOURCE_TABLES = (
|
||||
"auth_sessions",
|
||||
"expense_cases",
|
||||
"commercial_billing_periods",
|
||||
"tenant_subscriptions",
|
||||
"financial_connector_configs",
|
||||
"agent_asset_release_observations",
|
||||
)
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260717_0025 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return _inspector().has_table(table_name)
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {
|
||||
str(item["name"]) for item in _inspector().get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
inspector = _inspector()
|
||||
names = {
|
||||
str(item.get("name") or "")
|
||||
for item in (
|
||||
*inspector.get_unique_constraints(table_name),
|
||||
*inspector.get_foreign_keys(table_name),
|
||||
*inspector.get_check_constraints(table_name),
|
||||
)
|
||||
}
|
||||
return constraint_name in names
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
return index_name in {
|
||||
str(item.get("name") or "") for item in _inspector().get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _drop_unique_for_columns(table_name: str, columns: tuple[str, ...]) -> None:
|
||||
if not _has_table(table_name):
|
||||
return
|
||||
for item in _inspector().get_unique_constraints(table_name):
|
||||
if tuple(item.get("column_names") or ()) != columns:
|
||||
continue
|
||||
name = str(item.get("name") or "")
|
||||
if name:
|
||||
op.drop_constraint(name, table_name, type_="unique")
|
||||
|
||||
|
||||
def _drop_foreign_keys_for_columns(
|
||||
table_name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
if not _has_table(table_name):
|
||||
return
|
||||
for item in _inspector().get_foreign_keys(table_name):
|
||||
if tuple(item.get("constrained_columns") or ()) != columns:
|
||||
continue
|
||||
name = str(item.get("name") or "")
|
||||
if name:
|
||||
op.drop_constraint(name, table_name, type_="foreignkey")
|
||||
|
||||
|
||||
def _ensure_unique(
|
||||
table_name: str,
|
||||
name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
if not _constraint_exists(table_name, name):
|
||||
op.create_unique_constraint(name, table_name, list(columns))
|
||||
|
||||
|
||||
def _ensure_index(
|
||||
table_name: str,
|
||||
name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
if not _index_exists(table_name, name):
|
||||
op.create_index(name, table_name, list(columns))
|
||||
|
||||
|
||||
def _ensure_tenant_column(table_name: str) -> None:
|
||||
if not _has_table(table_name):
|
||||
return
|
||||
if not _has_column(table_name, "tenant_id"):
|
||||
op.add_column(
|
||||
table_name,
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
if table_name == "expense_claims" and _has_table("expense_case_links"):
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE expense_claims AS claim SET tenant_id = COALESCE(("
|
||||
"SELECT link.tenant_id FROM expense_case_links AS link "
|
||||
"WHERE link.resource_type = 'expense_claim' "
|
||||
"AND link.resource_id = claim.id ORDER BY link.created_at ASC LIMIT 1"
|
||||
"), :default_tenant) WHERE claim.tenant_id IS NULL"
|
||||
).bindparams(default_tenant=_DEFAULT_TENANT)
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"UPDATE {table_name} SET tenant_id = :default_tenant "
|
||||
"WHERE tenant_id IS NULL"
|
||||
).bindparams(default_tenant=_DEFAULT_TENANT)
|
||||
)
|
||||
op.alter_column(
|
||||
table_name,
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
|
||||
def _create_tenant_registry() -> None:
|
||||
if not _has_table("tenants"):
|
||||
op.create_table(
|
||||
"tenants",
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("tenant_code", sa.String(length=64), nullable=False),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'suspended', 'disabled')",
|
||||
name="ck_tenants_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"length(trim(tenant_id)) > 0 AND length(trim(tenant_code)) > 0 "
|
||||
"AND length(trim(name)) > 0",
|
||||
name="ck_tenants_identity",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("tenant_id"),
|
||||
sa.UniqueConstraint("tenant_code", name="uq_tenants_tenant_code"),
|
||||
)
|
||||
op.create_index("ix_tenants_status", "tenants", ["status"])
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO tenants (tenant_id, tenant_code, name, status) VALUES "
|
||||
"('default', 'default', '默认企业', 'active'), "
|
||||
"('platform', 'platform', '平台管理域', 'active') "
|
||||
"ON CONFLICT DO NOTHING"
|
||||
)
|
||||
)
|
||||
for table_name in _TENANT_SOURCE_TABLES:
|
||||
if not _has_column(table_name, "tenant_id"):
|
||||
continue
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"INSERT INTO tenants (tenant_id, tenant_code, name, status) "
|
||||
f"SELECT DISTINCT trim(tenant_id), trim(tenant_id), trim(tenant_id), "
|
||||
f"'active' FROM {table_name} "
|
||||
"WHERE tenant_id IS NOT NULL AND length(trim(tenant_id)) > 0 "
|
||||
"ON CONFLICT DO NOTHING"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _tenantize_organizations() -> None:
|
||||
table = "organization_units"
|
||||
if not _has_table(table):
|
||||
return
|
||||
_ensure_tenant_column(table)
|
||||
_drop_unique_for_columns(table, ("unit_code",))
|
||||
_drop_foreign_keys_for_columns(table, ("parent_id",))
|
||||
_ensure_unique(table, "uq_organization_units_tenant_id", ("tenant_id", "id"))
|
||||
_ensure_unique(
|
||||
table,
|
||||
"uq_organization_units_tenant_code",
|
||||
("tenant_id", "unit_code"),
|
||||
)
|
||||
if not _constraint_exists(table, "fk_organization_units_tenant"):
|
||||
op.create_foreign_key(
|
||||
"fk_organization_units_tenant",
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
if not _constraint_exists(table, "fk_organization_units_tenant_parent"):
|
||||
op.create_foreign_key(
|
||||
"fk_organization_units_tenant_parent",
|
||||
table,
|
||||
table,
|
||||
["tenant_id", "parent_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
_ensure_index(
|
||||
table,
|
||||
"ix_organization_units_tenant_name",
|
||||
("tenant_id", "name"),
|
||||
)
|
||||
|
||||
|
||||
def _tenantize_employees() -> None:
|
||||
table = "employees"
|
||||
if not _has_table(table):
|
||||
return
|
||||
_ensure_tenant_column(table)
|
||||
_drop_unique_for_columns(table, ("employee_no",))
|
||||
_drop_unique_for_columns(table, ("email",))
|
||||
_drop_foreign_keys_for_columns(table, ("organization_unit_id",))
|
||||
_drop_foreign_keys_for_columns(table, ("manager_id",))
|
||||
_ensure_unique(table, "uq_employees_tenant_id", ("tenant_id", "id"))
|
||||
_ensure_unique(
|
||||
table,
|
||||
"uq_employees_tenant_employee_no",
|
||||
("tenant_id", "employee_no"),
|
||||
)
|
||||
_ensure_unique(table, "uq_employees_tenant_email", ("tenant_id", "email"))
|
||||
if not _constraint_exists(table, "fk_employees_tenant"):
|
||||
op.create_foreign_key(
|
||||
"fk_employees_tenant",
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
if _has_table("organization_units") and not _constraint_exists(
|
||||
table,
|
||||
"fk_employees_tenant_organization_unit",
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_employees_tenant_organization_unit",
|
||||
table,
|
||||
"organization_units",
|
||||
["tenant_id", "organization_unit_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
if not _constraint_exists(table, "fk_employees_tenant_manager"):
|
||||
op.create_foreign_key(
|
||||
"fk_employees_tenant_manager",
|
||||
table,
|
||||
table,
|
||||
["tenant_id", "manager_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
_ensure_index(table, "ix_employees_tenant_status", ("tenant_id", "employment_status"))
|
||||
_ensure_index(table, "ix_employees_tenant_name", ("tenant_id", "name"))
|
||||
|
||||
|
||||
def _tenantize_financial_records() -> None:
|
||||
if _has_table("expense_claims"):
|
||||
table = "expense_claims"
|
||||
_ensure_tenant_column(table)
|
||||
_drop_unique_for_columns(table, ("claim_no",))
|
||||
_drop_foreign_keys_for_columns(table, ("employee_id",))
|
||||
_drop_foreign_keys_for_columns(table, ("department_id",))
|
||||
_ensure_unique(table, "uq_expense_claims_tenant_id", ("tenant_id", "id"))
|
||||
_ensure_unique(
|
||||
table,
|
||||
"uq_expense_claims_tenant_claim_no",
|
||||
("tenant_id", "claim_no"),
|
||||
)
|
||||
if not _constraint_exists(table, "fk_expense_claims_tenant"):
|
||||
op.create_foreign_key(
|
||||
"fk_expense_claims_tenant",
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
if _has_table("employees") and not _constraint_exists(
|
||||
table,
|
||||
"fk_expense_claims_tenant_employee",
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_expense_claims_tenant_employee",
|
||||
table,
|
||||
"employees",
|
||||
["tenant_id", "employee_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
if _has_table("organization_units") and not _constraint_exists(
|
||||
table,
|
||||
"fk_expense_claims_tenant_department",
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_expense_claims_tenant_department",
|
||||
table,
|
||||
"organization_units",
|
||||
["tenant_id", "department_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
_ensure_index(table, "ix_expense_claims_tenant_status", ("tenant_id", "status"))
|
||||
_ensure_index(
|
||||
table,
|
||||
"ix_expense_claims_tenant_occurred",
|
||||
("tenant_id", "occurred_at"),
|
||||
)
|
||||
for table, number_column, constraint_name, dimension_column, index_name in (
|
||||
(
|
||||
"accounts_receivable",
|
||||
"receivable_no",
|
||||
"uq_accounts_receivable_tenant_no",
|
||||
"customer_id",
|
||||
"ix_accounts_receivable_tenant_customer",
|
||||
),
|
||||
(
|
||||
"accounts_payable",
|
||||
"payable_no",
|
||||
"uq_accounts_payable_tenant_no",
|
||||
"vendor_id",
|
||||
"ix_accounts_payable_tenant_vendor",
|
||||
),
|
||||
):
|
||||
if not _has_table(table):
|
||||
continue
|
||||
_ensure_tenant_column(table)
|
||||
_drop_unique_for_columns(table, (number_column,))
|
||||
_ensure_unique(table, constraint_name, ("tenant_id", number_column))
|
||||
tenant_fk = f"fk_{table}_tenant"
|
||||
if not _constraint_exists(table, tenant_fk):
|
||||
op.create_foreign_key(
|
||||
tenant_fk,
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
_ensure_index(table, index_name, ("tenant_id", dimension_column))
|
||||
|
||||
|
||||
def _ensure_memberships() -> None:
|
||||
if not _has_table("employees"):
|
||||
return
|
||||
if not _has_table("tenant_memberships"):
|
||||
op.create_table(
|
||||
"tenant_memberships",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("employee_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'inactive')",
|
||||
name="ck_tenant_memberships_status",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenants.tenant_id"],
|
||||
name="fk_tenant_memberships_tenant",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "employee_id"],
|
||||
["employees.tenant_id", "employees.id"],
|
||||
name="fk_tenant_memberships_tenant_employee",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"employee_id",
|
||||
name="uq_tenant_memberships_tenant_employee",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_memberships_employee_active",
|
||||
"tenant_memberships",
|
||||
["employee_id", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_memberships_tenant_active",
|
||||
"tenant_memberships",
|
||||
["tenant_id", "status"],
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO tenant_memberships "
|
||||
"(id, tenant_id, employee_id, status, is_primary) "
|
||||
"SELECT gen_random_uuid()::text, employee.tenant_id, employee.id, "
|
||||
"CASE WHEN employee.employment_status = '停用' THEN 'inactive' "
|
||||
"ELSE 'active' END, true FROM employees AS employee "
|
||||
"ON CONFLICT (tenant_id, employee_id) DO NOTHING"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bind_auth_sessions() -> None:
|
||||
if not _has_table("auth_sessions"):
|
||||
return
|
||||
if not _constraint_exists("auth_sessions", "fk_auth_sessions_tenant"):
|
||||
op.create_foreign_key(
|
||||
"fk_auth_sessions_tenant",
|
||||
"auth_sessions",
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
_create_tenant_registry()
|
||||
_tenantize_organizations()
|
||||
_tenantize_employees()
|
||||
_tenantize_financial_records()
|
||||
_ensure_memberships()
|
||||
_bind_auth_sessions()
|
||||
|
||||
|
||||
def _require_safe_downgrade() -> None:
|
||||
for table_name in (
|
||||
"organization_units",
|
||||
"employees",
|
||||
"expense_claims",
|
||||
"accounts_receivable",
|
||||
"accounts_payable",
|
||||
):
|
||||
if not _has_column(table_name, "tenant_id"):
|
||||
continue
|
||||
count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text(
|
||||
f"SELECT COUNT(*) FROM {table_name} "
|
||||
"WHERE tenant_id <> :default_tenant"
|
||||
).bindparams(default_tenant=_DEFAULT_TENANT)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade tenant identity foundation: "
|
||||
f"{table_name} contains non-default tenant data ({count})"
|
||||
)
|
||||
|
||||
|
||||
def _restore_legacy_financial_constraints() -> None:
|
||||
if _has_table("expense_claims"):
|
||||
table = "expense_claims"
|
||||
for name in (
|
||||
"fk_expense_claims_tenant_department",
|
||||
"fk_expense_claims_tenant_employee",
|
||||
"fk_expense_claims_tenant",
|
||||
"uq_expense_claims_tenant_claim_no",
|
||||
"uq_expense_claims_tenant_id",
|
||||
):
|
||||
if _constraint_exists(table, name):
|
||||
op.drop_constraint(name, table)
|
||||
for name in (
|
||||
"ix_expense_claims_tenant_occurred",
|
||||
"ix_expense_claims_tenant_status",
|
||||
):
|
||||
if _index_exists(table, name):
|
||||
op.drop_index(name, table_name=table)
|
||||
_ensure_unique(table, "uq_expense_claims_claim_no", ("claim_no",))
|
||||
if _has_table("employees"):
|
||||
op.create_foreign_key(
|
||||
"fk_expense_claims_employee_id",
|
||||
table,
|
||||
"employees",
|
||||
["employee_id"],
|
||||
["id"],
|
||||
)
|
||||
if _has_table("organization_units"):
|
||||
op.create_foreign_key(
|
||||
"fk_expense_claims_department_id",
|
||||
table,
|
||||
"organization_units",
|
||||
["department_id"],
|
||||
["id"],
|
||||
)
|
||||
op.drop_column(table, "tenant_id")
|
||||
for table, constraint_name, number_column, index_name in (
|
||||
(
|
||||
"accounts_receivable",
|
||||
"uq_accounts_receivable_tenant_no",
|
||||
"receivable_no",
|
||||
"ix_accounts_receivable_tenant_customer",
|
||||
),
|
||||
(
|
||||
"accounts_payable",
|
||||
"uq_accounts_payable_tenant_no",
|
||||
"payable_no",
|
||||
"ix_accounts_payable_tenant_vendor",
|
||||
),
|
||||
):
|
||||
if not _has_column(table, "tenant_id"):
|
||||
continue
|
||||
tenant_fk = f"fk_{table}_tenant"
|
||||
if _constraint_exists(table, tenant_fk):
|
||||
op.drop_constraint(tenant_fk, table, type_="foreignkey")
|
||||
if _constraint_exists(table, constraint_name):
|
||||
op.drop_constraint(constraint_name, table, type_="unique")
|
||||
if _index_exists(table, index_name):
|
||||
op.drop_index(index_name, table_name=table)
|
||||
_ensure_unique(table, f"uq_{table}_{number_column}", (number_column,))
|
||||
op.drop_column(table, "tenant_id")
|
||||
|
||||
|
||||
def _restore_legacy_employee_constraints() -> None:
|
||||
if _has_table("employees"):
|
||||
table = "employees"
|
||||
for name in (
|
||||
"fk_employees_tenant_manager",
|
||||
"fk_employees_tenant_organization_unit",
|
||||
"fk_employees_tenant",
|
||||
"uq_employees_tenant_email",
|
||||
"uq_employees_tenant_employee_no",
|
||||
"uq_employees_tenant_id",
|
||||
):
|
||||
if _constraint_exists(table, name):
|
||||
op.drop_constraint(name, table)
|
||||
for name in ("ix_employees_tenant_name", "ix_employees_tenant_status"):
|
||||
if _index_exists(table, name):
|
||||
op.drop_index(name, table_name=table)
|
||||
_ensure_unique(table, "uq_employees_employee_no", ("employee_no",))
|
||||
_ensure_unique(table, "uq_employees_email", ("email",))
|
||||
if _has_table("organization_units"):
|
||||
op.create_foreign_key(
|
||||
"fk_employees_organization_unit_id",
|
||||
table,
|
||||
"organization_units",
|
||||
["organization_unit_id"],
|
||||
["id"],
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_employees_manager_id",
|
||||
table,
|
||||
table,
|
||||
["manager_id"],
|
||||
["id"],
|
||||
)
|
||||
op.drop_column(table, "tenant_id")
|
||||
if _has_table("organization_units"):
|
||||
table = "organization_units"
|
||||
for name in (
|
||||
"fk_organization_units_tenant_parent",
|
||||
"fk_organization_units_tenant",
|
||||
"uq_organization_units_tenant_code",
|
||||
"uq_organization_units_tenant_id",
|
||||
):
|
||||
if _constraint_exists(table, name):
|
||||
op.drop_constraint(name, table)
|
||||
if _index_exists(table, "ix_organization_units_tenant_name"):
|
||||
op.drop_index("ix_organization_units_tenant_name", table_name=table)
|
||||
_ensure_unique(table, "uq_organization_units_unit_code", ("unit_code",))
|
||||
op.create_foreign_key(
|
||||
"fk_organization_units_parent_id",
|
||||
table,
|
||||
table,
|
||||
["parent_id"],
|
||||
["id"],
|
||||
)
|
||||
op.drop_column(table, "tenant_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_safe_downgrade()
|
||||
if _has_table("auth_sessions") and _constraint_exists(
|
||||
"auth_sessions",
|
||||
"fk_auth_sessions_tenant",
|
||||
):
|
||||
op.drop_constraint(
|
||||
"fk_auth_sessions_tenant",
|
||||
"auth_sessions",
|
||||
type_="foreignkey",
|
||||
)
|
||||
if _has_table("tenant_memberships"):
|
||||
op.drop_table("tenant_memberships")
|
||||
_restore_legacy_financial_constraints()
|
||||
_restore_legacy_employee_constraints()
|
||||
if _has_table("tenants"):
|
||||
if _index_exists("tenants", "ix_tenants_status"):
|
||||
op.drop_index("ix_tenants_status", table_name="tenants")
|
||||
op.drop_table("tenants")
|
||||
@@ -0,0 +1,444 @@
|
||||
"""add structural tenant and platform scope to Agent assets
|
||||
|
||||
Revision ID: 20260717_0026
|
||||
Revises: 20260717_0025
|
||||
Create Date: 2026-07-17 14:30:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260717_0026"
|
||||
down_revision: str | None = "20260717_0025"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_ASSET_TABLE = "agent_assets"
|
||||
_ASSET_CHILD_TABLES = (
|
||||
"agent_asset_versions",
|
||||
"agent_asset_reviews",
|
||||
"agent_asset_test_runs",
|
||||
"agent_asset_rule_feedback",
|
||||
)
|
||||
_OWNERSHIP_CHILD_TABLES = (
|
||||
"agent_asset_versions",
|
||||
"agent_asset_reviews",
|
||||
)
|
||||
_ONLYOFFICE_SESSION_TABLE = "agent_asset_onlyoffice_sessions"
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260717_0026 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _has_complete_agent_asset_schema() -> bool:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
table_names = (_ASSET_TABLE, *_ASSET_CHILD_TABLES)
|
||||
existing = {table_name for table_name in table_names if inspector.has_table(table_name)}
|
||||
if not existing:
|
||||
return False
|
||||
missing = set(table_names) - existing
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"cannot migrate partial Agent asset schema; missing tables: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _create_onlyoffice_session_table() -> None:
|
||||
if sa.inspect(op.get_bind()).has_table(_ONLYOFFICE_SESSION_TABLE):
|
||||
return
|
||||
op.create_table(
|
||||
_ONLYOFFICE_SESSION_TABLE,
|
||||
sa.Column("jti", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("resource_scope", sa.String(length=16), nullable=False),
|
||||
sa.Column("asset_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("document_key", sa.String(length=200), nullable=False),
|
||||
sa.Column("document_version", sa.String(length=30), nullable=False),
|
||||
sa.Column("document_fingerprint", sa.String(length=160), nullable=False),
|
||||
sa.Column("audience", sa.String(length=80), nullable=False),
|
||||
sa.Column("writable", sa.Boolean(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("actor", sa.String(length=160), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(resource_scope = 'platform' AND tenant_id = 'platform') OR "
|
||||
"(resource_scope = 'tenant' AND tenant_id <> 'platform')",
|
||||
name="ck_agent_asset_onlyoffice_sessions_scope_tenant",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'processing', 'consumed', 'failed', 'revoked')",
|
||||
name="ck_agent_asset_onlyoffice_sessions_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR "
|
||||
"(status IN ('processing', 'failed') AND claimed_at IS NOT NULL "
|
||||
"AND consumed_at IS NULL) OR "
|
||||
"(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR "
|
||||
"(status = 'revoked' AND consumed_at IS NULL)",
|
||||
name="ck_agent_asset_onlyoffice_sessions_lifecycle",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenants.tenant_id"],
|
||||
name="fk_agent_asset_onlyoffice_sessions_tenant",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("jti"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_onlyoffice_sessions_tenant_asset",
|
||||
_ONLYOFFICE_SESSION_TABLE,
|
||||
["tenant_id", "resource_scope", "asset_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_asset_onlyoffice_sessions_status_expiry",
|
||||
_ONLYOFFICE_SESSION_TABLE,
|
||||
["status", "expires_at"],
|
||||
)
|
||||
|
||||
|
||||
def _add_scope_columns(table_name: str) -> None:
|
||||
op.add_column(table_name, sa.Column("tenant_id", sa.String(length=64), nullable=True))
|
||||
op.add_column(table_name, sa.Column("scope", sa.String(length=16), nullable=True))
|
||||
|
||||
|
||||
def _assert_known_asset_tenants() -> None:
|
||||
unknown = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM agent_assets AS asset
|
||||
LEFT JOIN tenants AS tenant
|
||||
ON tenant.tenant_id = NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), '')
|
||||
WHERE NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), '') IS NOT NULL
|
||||
AND tenant.tenant_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if unknown:
|
||||
raise RuntimeError(
|
||||
"cannot migrate Agent assets: config_json contains tenant ids absent from tenants "
|
||||
f"(unknown_assets={unknown})"
|
||||
)
|
||||
|
||||
|
||||
def _backfill_scope() -> None:
|
||||
_assert_known_asset_tenants()
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE agent_assets
|
||||
SET tenant_id = COALESCE(
|
||||
NULLIF(BTRIM(config_json ->> 'tenant_id'), ''),
|
||||
'platform'
|
||||
),
|
||||
scope = CASE
|
||||
WHEN NULLIF(BTRIM(config_json ->> 'tenant_id'), '') IS NULL
|
||||
THEN 'platform'
|
||||
ELSE 'tenant'
|
||||
END
|
||||
"""
|
||||
)
|
||||
for table_name in _OWNERSHIP_CHILD_TABLES:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name} AS child
|
||||
SET tenant_id = asset.tenant_id,
|
||||
scope = asset.scope
|
||||
FROM agent_assets AS asset
|
||||
WHERE child.asset_id = asset.id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE agent_asset_test_runs AS child
|
||||
SET tenant_id = COALESCE(
|
||||
NULLIF(BTRIM(child.input_json ->> 'target_tenant_id'), ''),
|
||||
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
|
||||
asset.tenant_id
|
||||
),
|
||||
scope = CASE
|
||||
WHEN COALESCE(
|
||||
NULLIF(BTRIM(child.input_json ->> 'target_tenant_id'), ''),
|
||||
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
|
||||
asset.tenant_id
|
||||
) = 'platform' THEN 'platform'
|
||||
ELSE 'tenant'
|
||||
END
|
||||
FROM agent_assets AS asset
|
||||
WHERE child.asset_id = asset.id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE agent_asset_rule_feedback AS child
|
||||
SET tenant_id = COALESCE(
|
||||
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
|
||||
asset.tenant_id
|
||||
),
|
||||
scope = CASE
|
||||
WHEN COALESCE(
|
||||
NULLIF(BTRIM(asset.config_json ->> 'tenant_id'), ''),
|
||||
asset.tenant_id
|
||||
) = 'platform' THEN 'platform'
|
||||
ELSE 'tenant'
|
||||
END
|
||||
FROM agent_assets AS asset
|
||||
WHERE child.asset_id = asset.id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _set_scope_not_null_and_defaults(table_name: str) -> None:
|
||||
op.alter_column(
|
||||
table_name,
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="platform",
|
||||
)
|
||||
op.alter_column(
|
||||
table_name,
|
||||
"scope",
|
||||
existing_type=sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="platform",
|
||||
)
|
||||
|
||||
|
||||
def _assert_known_scoped_tenants() -> None:
|
||||
bind = op.get_bind()
|
||||
for table_name in (_ASSET_TABLE, *_ASSET_CHILD_TABLES):
|
||||
unknown = int(
|
||||
bind.scalar(
|
||||
sa.text(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM {table_name} AS scoped
|
||||
LEFT JOIN tenants AS tenant ON tenant.tenant_id = scoped.tenant_id
|
||||
WHERE tenant.tenant_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if unknown:
|
||||
raise RuntimeError(
|
||||
f"cannot migrate {table_name}: scoped tenant is absent from tenants "
|
||||
f"(unknown_rows={unknown})"
|
||||
)
|
||||
|
||||
|
||||
def _create_scope_check(table_name: str) -> None:
|
||||
op.create_check_constraint(
|
||||
f"ck_{table_name}_scope_tenant",
|
||||
table_name,
|
||||
"(scope = 'platform' AND tenant_id = 'platform') OR "
|
||||
"(scope = 'tenant' AND tenant_id <> 'platform')",
|
||||
)
|
||||
|
||||
|
||||
def _drop_legacy_code_uniqueness() -> None:
|
||||
op.execute("ALTER TABLE agent_assets DROP CONSTRAINT IF EXISTS agent_assets_code_key")
|
||||
op.execute("DROP INDEX IF EXISTS ix_agent_assets_code")
|
||||
op.create_index("ix_agent_assets_code", _ASSET_TABLE, ["code"], unique=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
has_agent_asset_schema = _has_complete_agent_asset_schema()
|
||||
_create_onlyoffice_session_table()
|
||||
if not has_agent_asset_schema:
|
||||
return
|
||||
_add_scope_columns(_ASSET_TABLE)
|
||||
for table_name in _ASSET_CHILD_TABLES:
|
||||
_add_scope_columns(table_name)
|
||||
_backfill_scope()
|
||||
_set_scope_not_null_and_defaults(_ASSET_TABLE)
|
||||
for table_name in _ASSET_CHILD_TABLES:
|
||||
_set_scope_not_null_and_defaults(table_name)
|
||||
_assert_known_scoped_tenants()
|
||||
|
||||
_drop_legacy_code_uniqueness()
|
||||
op.create_unique_constraint(
|
||||
"uq_agent_assets_tenant_scope_id",
|
||||
_ASSET_TABLE,
|
||||
["tenant_id", "scope", "id"],
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_agent_assets_tenant_scope_code",
|
||||
_ASSET_TABLE,
|
||||
["tenant_id", "scope", "code"],
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_agent_assets_tenant",
|
||||
_ASSET_TABLE,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
_create_scope_check(_ASSET_TABLE)
|
||||
op.create_index(
|
||||
"ix_agent_assets_scope_tenant",
|
||||
_ASSET_TABLE,
|
||||
["scope", "tenant_id"],
|
||||
)
|
||||
|
||||
for table_name in _ASSET_CHILD_TABLES:
|
||||
op.create_foreign_key(
|
||||
f"fk_{table_name}_tenant",
|
||||
table_name,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
_create_scope_check(table_name)
|
||||
op.create_index(
|
||||
f"ix_{table_name}_tenant_asset",
|
||||
table_name,
|
||||
["tenant_id", "scope", "asset_id"],
|
||||
)
|
||||
for table_name in _OWNERSHIP_CHILD_TABLES:
|
||||
op.create_foreign_key(
|
||||
f"fk_{table_name}_tenant_asset",
|
||||
table_name,
|
||||
_ASSET_TABLE,
|
||||
["tenant_id", "scope", "asset_id"],
|
||||
["tenant_id", "scope", "id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def _require_lossless_downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tenant_assets = int(
|
||||
bind.scalar(
|
||||
sa.text("SELECT COUNT(*) FROM agent_assets WHERE scope = 'tenant'")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
tenant_evidence = sum(
|
||||
int(
|
||||
bind.scalar(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table_name} WHERE scope = 'tenant'")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
for table_name in ("agent_asset_test_runs", "agent_asset_rule_feedback")
|
||||
)
|
||||
if tenant_assets or tenant_evidence:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade Agent asset tenant security: tenant-owned facts exist "
|
||||
f"(tenant_assets={tenant_assets}, tenant_evidence={tenant_evidence})"
|
||||
)
|
||||
|
||||
|
||||
def _require_no_onlyoffice_sessions() -> None:
|
||||
if not sa.inspect(op.get_bind()).has_table(_ONLYOFFICE_SESSION_TABLE):
|
||||
return
|
||||
session_count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text(f"SELECT COUNT(*) FROM {_ONLYOFFICE_SESSION_TABLE}")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if session_count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade Agent asset tenant security: ONLYOFFICE session evidence "
|
||||
f"exists (sessions={session_count})"
|
||||
)
|
||||
|
||||
|
||||
def _drop_onlyoffice_session_table() -> None:
|
||||
if not sa.inspect(op.get_bind()).has_table(_ONLYOFFICE_SESSION_TABLE):
|
||||
return
|
||||
op.drop_index(
|
||||
"ix_agent_asset_onlyoffice_sessions_status_expiry",
|
||||
table_name=_ONLYOFFICE_SESSION_TABLE,
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_agent_asset_onlyoffice_sessions_tenant_asset",
|
||||
table_name=_ONLYOFFICE_SESSION_TABLE,
|
||||
)
|
||||
op.drop_table(_ONLYOFFICE_SESSION_TABLE)
|
||||
|
||||
|
||||
def _drop_constraint_if_exists(
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
) -> None:
|
||||
"""删除本迁移负责的约束,兼容模型建表产生的不同外键名称。"""
|
||||
preparer = op.get_bind().dialect.identifier_preparer
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE {preparer.quote(table_name)} "
|
||||
f"DROP CONSTRAINT IF EXISTS {preparer.quote(constraint_name)}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
has_agent_asset_schema = _has_complete_agent_asset_schema()
|
||||
_require_no_onlyoffice_sessions()
|
||||
_drop_onlyoffice_session_table()
|
||||
if not has_agent_asset_schema:
|
||||
return
|
||||
_require_lossless_downgrade()
|
||||
for table_name in reversed(_OWNERSHIP_CHILD_TABLES):
|
||||
_drop_constraint_if_exists(
|
||||
table_name,
|
||||
f"fk_{table_name}_tenant_asset",
|
||||
)
|
||||
for table_name in reversed(_ASSET_CHILD_TABLES):
|
||||
_drop_constraint_if_exists(
|
||||
table_name,
|
||||
f"fk_{table_name}_tenant",
|
||||
)
|
||||
op.drop_index(f"ix_{table_name}_tenant_asset", table_name=table_name)
|
||||
_drop_constraint_if_exists(
|
||||
table_name,
|
||||
f"ck_{table_name}_scope_tenant",
|
||||
)
|
||||
op.drop_index("ix_agent_assets_scope_tenant", table_name=_ASSET_TABLE)
|
||||
_drop_constraint_if_exists(_ASSET_TABLE, "ck_agent_assets_scope_tenant")
|
||||
_drop_constraint_if_exists(_ASSET_TABLE, "fk_agent_assets_tenant")
|
||||
_drop_constraint_if_exists(
|
||||
_ASSET_TABLE,
|
||||
"uq_agent_assets_tenant_scope_code",
|
||||
)
|
||||
_drop_constraint_if_exists(
|
||||
_ASSET_TABLE,
|
||||
"uq_agent_assets_tenant_scope_id",
|
||||
)
|
||||
op.drop_index("ix_agent_assets_code", table_name=_ASSET_TABLE)
|
||||
op.create_index("ix_agent_assets_code", _ASSET_TABLE, ["code"], unique=True)
|
||||
for table_name in reversed(_ASSET_CHILD_TABLES):
|
||||
op.drop_column(table_name, "scope")
|
||||
op.drop_column(table_name, "tenant_id")
|
||||
op.drop_column(_ASSET_TABLE, "scope")
|
||||
op.drop_column(_ASSET_TABLE, "tenant_id")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""add tenant-bound one-time OnlyOffice sessions
|
||||
|
||||
Revision ID: 20260717_0027
|
||||
Revises: 20260717_0026
|
||||
Create Date: 2026-07-17 10:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260717_0027"
|
||||
down_revision: str | None = "20260717_0026"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260717_0027 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _require_empty_for_downgrade() -> None:
|
||||
count = int(
|
||||
op.get_bind().scalar(
|
||||
sa.text("SELECT COUNT(*) FROM knowledge_onlyoffice_sessions")
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if count:
|
||||
raise RuntimeError(
|
||||
"cannot downgrade knowledge tenant security: OnlyOffice session evidence exists "
|
||||
f"(knowledge_onlyoffice_sessions={count})"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
op.create_table(
|
||||
"knowledge_onlyoffice_sessions",
|
||||
sa.Column("jti", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("resource_scope", sa.String(length=16), nullable=False),
|
||||
sa.Column("document_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("document_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("document_version", sa.Integer(), nullable=False),
|
||||
sa.Column("audience", sa.String(length=80), nullable=False),
|
||||
sa.Column("editable", sa.Boolean(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=100), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"resource_scope IN ('tenant', 'platform')",
|
||||
name="ck_knowledge_onlyoffice_sessions_scope",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'processing', 'consumed', 'failed', 'revoked')",
|
||||
name="ck_knowledge_onlyoffice_sessions_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"tenant_id IS NOT NULL AND "
|
||||
"(resource_scope = 'tenant' OR "
|
||||
"(resource_scope = 'platform' AND editable = false))",
|
||||
name="ck_knowledge_onlyoffice_sessions_scope_tenant",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR "
|
||||
"(status IN ('processing', 'failed') AND claimed_at IS NOT NULL "
|
||||
"AND consumed_at IS NULL) OR "
|
||||
"(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR "
|
||||
"(status = 'revoked' AND consumed_at IS NULL)",
|
||||
name="ck_knowledge_onlyoffice_sessions_lifecycle",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenants.tenant_id"],
|
||||
name="fk_knowledge_onlyoffice_sessions_tenant",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("jti"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_knowledge_onlyoffice_sessions_tenant_document",
|
||||
"knowledge_onlyoffice_sessions",
|
||||
["tenant_id", "document_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_knowledge_onlyoffice_sessions_status_expiry",
|
||||
"knowledge_onlyoffice_sessions",
|
||||
["status", "expires_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_require_empty_for_downgrade()
|
||||
op.drop_index(
|
||||
"ix_knowledge_onlyoffice_sessions_status_expiry",
|
||||
table_name="knowledge_onlyoffice_sessions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_knowledge_onlyoffice_sessions_tenant_document",
|
||||
table_name="knowledge_onlyoffice_sessions",
|
||||
)
|
||||
op.drop_table("knowledge_onlyoffice_sessions")
|
||||
@@ -0,0 +1,484 @@
|
||||
"""tenant-scope Hermes profiles, reports, and scheduled work
|
||||
|
||||
Revision ID: 20260717_0028
|
||||
Revises: 20260717_0027
|
||||
Create Date: 2026-07-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260717_0028"
|
||||
down_revision: str | None = "20260717_0027"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
dialect_name = op.get_bind().dialect.name
|
||||
if dialect_name != "postgresql":
|
||||
raise RuntimeError(
|
||||
"20260717_0028 only supports PostgreSQL; "
|
||||
f"refusing to mutate {dialect_name} without transactional constraint DDL"
|
||||
)
|
||||
|
||||
|
||||
def _inspector() -> sa.Inspector:
|
||||
return sa.inspect(op.get_bind())
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return _inspector().has_table(table_name)
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {str(item["name"]) for item in _inspector().get_columns(table_name)}
|
||||
|
||||
|
||||
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = _inspector()
|
||||
names = {
|
||||
str(item.get("name") or "")
|
||||
for item in (
|
||||
*inspector.get_unique_constraints(table_name),
|
||||
*inspector.get_foreign_keys(table_name),
|
||||
*inspector.get_check_constraints(table_name),
|
||||
)
|
||||
}
|
||||
return constraint_name in names
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return index_name in {
|
||||
str(item.get("name") or "") for item in _inspector().get_indexes(table_name)
|
||||
}
|
||||
|
||||
|
||||
def _drop_foreign_keys_for_columns(
|
||||
table_name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
if not _has_table(table_name):
|
||||
return
|
||||
for item in _inspector().get_foreign_keys(table_name):
|
||||
if tuple(item.get("constrained_columns") or ()) != columns:
|
||||
continue
|
||||
name = str(item.get("name") or "")
|
||||
if name:
|
||||
op.drop_constraint(name, table_name, type_="foreignkey")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_require_postgresql()
|
||||
_scope_profile_snapshots()
|
||||
_scope_hermes_task_tables()
|
||||
_scope_hermes_risk_reports()
|
||||
_create_finance_report_tables()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_require_postgresql()
|
||||
_assert_safe_downgrade()
|
||||
if _has_table("tenant_finance_report_runs"):
|
||||
op.drop_table("tenant_finance_report_runs")
|
||||
if _has_table("tenant_finance_report_configs"):
|
||||
op.drop_table("tenant_finance_report_configs")
|
||||
|
||||
_downgrade_risk_reports()
|
||||
_downgrade_task_tables()
|
||||
_downgrade_profile_snapshots()
|
||||
|
||||
|
||||
def _scope_profile_snapshots() -> None:
|
||||
table = "employee_behavior_profile_snapshots"
|
||||
if not _has_table(table):
|
||||
return
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False, server_default="default"),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_employee_behavior_profiles_tenant",
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_employee_behavior_profiles_tenant_id",
|
||||
table,
|
||||
["tenant_id", "id"],
|
||||
)
|
||||
if _has_table("employees"):
|
||||
op.create_foreign_key(
|
||||
"fk_employee_behavior_profiles_tenant_employee",
|
||||
table,
|
||||
"employees",
|
||||
["tenant_id", "subject_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
if _index_exists(table, "ix_employee_behavior_profile_latest"):
|
||||
op.drop_index("ix_employee_behavior_profile_latest", table_name=table)
|
||||
op.create_index(
|
||||
"ix_employee_behavior_profile_latest",
|
||||
table,
|
||||
[
|
||||
"tenant_id",
|
||||
"subject_id",
|
||||
"profile_type",
|
||||
"window_days",
|
||||
"expense_type_scope",
|
||||
"calculated_at",
|
||||
],
|
||||
)
|
||||
op.alter_column(table, "tenant_id", server_default=None)
|
||||
|
||||
|
||||
def _scope_hermes_task_tables() -> None:
|
||||
config_table = "hermes_task_configs"
|
||||
log_table = "hermes_task_execution_logs"
|
||||
for table, fk_name in (
|
||||
(config_table, "fk_hermes_task_configs_tenant"),
|
||||
(log_table, "fk_hermes_task_execution_logs_tenant"),
|
||||
):
|
||||
if not _has_table(table):
|
||||
continue
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="default",
|
||||
),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
fk_name,
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.alter_column(table, "tenant_id", server_default=None)
|
||||
|
||||
if _has_table(config_table):
|
||||
op.create_unique_constraint(
|
||||
"uq_hermes_task_configs_tenant_id",
|
||||
config_table,
|
||||
["tenant_id", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_hermes_task_configs_tenant_enabled",
|
||||
config_table,
|
||||
["tenant_id", "is_enabled"],
|
||||
)
|
||||
if _has_table(log_table):
|
||||
op.create_unique_constraint(
|
||||
"uq_hermes_task_execution_logs_tenant_id",
|
||||
log_table,
|
||||
["tenant_id", "id"],
|
||||
)
|
||||
if _has_table(config_table):
|
||||
_drop_foreign_keys_for_columns(log_table, ("config_id",))
|
||||
op.create_foreign_key(
|
||||
"fk_hermes_task_logs_tenant_config",
|
||||
log_table,
|
||||
config_table,
|
||||
["tenant_id", "config_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_hermes_task_logs_tenant_started",
|
||||
log_table,
|
||||
["tenant_id", "started_at"],
|
||||
)
|
||||
|
||||
|
||||
def _scope_hermes_risk_reports() -> None:
|
||||
table = "hermes_risk_reports"
|
||||
if not _has_table(table):
|
||||
return
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False, server_default="default"),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_hermes_risk_reports_tenant",
|
||||
table,
|
||||
"tenants",
|
||||
["tenant_id"],
|
||||
["tenant_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_hermes_risk_reports_tenant_id",
|
||||
table,
|
||||
["tenant_id", "id"],
|
||||
)
|
||||
if _has_table("expense_claims"):
|
||||
_drop_foreign_keys_for_columns(table, ("claim_id",))
|
||||
op.create_foreign_key(
|
||||
"fk_hermes_risk_reports_tenant_claim",
|
||||
table,
|
||||
"expense_claims",
|
||||
["tenant_id", "claim_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
if _has_table("hermes_task_execution_logs"):
|
||||
_drop_foreign_keys_for_columns(table, ("execution_log_id",))
|
||||
op.create_foreign_key(
|
||||
"fk_hermes_risk_reports_tenant_log",
|
||||
table,
|
||||
"hermes_task_execution_logs",
|
||||
["tenant_id", "execution_log_id"],
|
||||
["tenant_id", "id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_hermes_risk_reports_tenant_status",
|
||||
table,
|
||||
["tenant_id", "status"],
|
||||
)
|
||||
op.alter_column(table, "tenant_id", server_default=None)
|
||||
|
||||
|
||||
def _create_finance_report_tables() -> None:
|
||||
if not _has_table("tenant_finance_report_configs"):
|
||||
op.create_table(
|
||||
"tenant_finance_report_configs",
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="disabled"),
|
||||
sa.Column("delivery_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("recipients_json", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("updated_by", sa.String(length=100), nullable=False, server_default=""),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'disabled')",
|
||||
name="ck_tenant_finance_report_configs_status",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenants.tenant_id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("tenant_id"),
|
||||
)
|
||||
if not _has_table("tenant_finance_report_runs"):
|
||||
op.create_table(
|
||||
"tenant_finance_report_runs",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("report_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("period_start", sa.Date(), nullable=False),
|
||||
sa.Column("period_end", sa.Date(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=180), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="running"),
|
||||
sa.Column("agent_run_id", sa.String(length=50), nullable=True),
|
||||
sa.Column("storage_key", sa.String(length=512), nullable=False, server_default=""),
|
||||
sa.Column("result_json", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"started_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"report_type IN ('weekly', 'quarterly', 'annual')",
|
||||
name="ck_tenant_finance_report_runs_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('running', 'succeeded', 'failed')",
|
||||
name="ck_tenant_finance_report_runs_status",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenants.tenant_id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_tenant_finance_report_runs_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_finance_report_runs_period",
|
||||
"tenant_finance_report_runs",
|
||||
["tenant_id", "report_type", "period_start", "period_end"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_finance_report_runs_agent_run_id",
|
||||
"tenant_finance_report_runs",
|
||||
["agent_run_id"],
|
||||
)
|
||||
|
||||
|
||||
def _assert_safe_downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
for table in (
|
||||
"employee_behavior_profile_snapshots",
|
||||
"hermes_task_configs",
|
||||
"hermes_task_execution_logs",
|
||||
"hermes_risk_reports",
|
||||
):
|
||||
if not _has_column(table, "tenant_id"):
|
||||
continue
|
||||
count = connection.execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table} WHERE tenant_id <> 'default'")
|
||||
).scalar_one()
|
||||
if int(count or 0) > 0:
|
||||
raise RuntimeError(f"Refusing downgrade: {table} contains non-default tenant rows.")
|
||||
for table in ("tenant_finance_report_configs", "tenant_finance_report_runs"):
|
||||
if not _has_table(table):
|
||||
continue
|
||||
count = connection.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar_one()
|
||||
if int(count or 0) > 0:
|
||||
raise RuntimeError(f"Refusing downgrade: {table} contains data.")
|
||||
|
||||
|
||||
def _drop_constraint_if_exists(table: str, name: str, type_: str) -> None:
|
||||
if _constraint_exists(table, name):
|
||||
op.drop_constraint(name, table, type_=type_)
|
||||
|
||||
|
||||
def _drop_index_if_exists(table: str, name: str) -> None:
|
||||
if _index_exists(table, name):
|
||||
op.drop_index(name, table_name=table)
|
||||
|
||||
|
||||
def _downgrade_risk_reports() -> None:
|
||||
table = "hermes_risk_reports"
|
||||
if not _has_column(table, "tenant_id"):
|
||||
return
|
||||
_drop_index_if_exists(table, "ix_hermes_risk_reports_tenant_status")
|
||||
_drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant_log", "foreignkey")
|
||||
_drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant_claim", "foreignkey")
|
||||
_drop_constraint_if_exists(table, "uq_hermes_risk_reports_tenant_id", "unique")
|
||||
_drop_constraint_if_exists(table, "fk_hermes_risk_reports_tenant", "foreignkey")
|
||||
if _has_table("expense_claims"):
|
||||
op.create_foreign_key(
|
||||
"hermes_risk_reports_claim_id_fkey",
|
||||
table,
|
||||
"expense_claims",
|
||||
["claim_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
if _has_table("hermes_task_execution_logs"):
|
||||
op.create_foreign_key(
|
||||
"hermes_risk_reports_execution_log_id_fkey",
|
||||
table,
|
||||
"hermes_task_execution_logs",
|
||||
["execution_log_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
op.drop_column(table, "tenant_id")
|
||||
|
||||
|
||||
def _downgrade_task_tables() -> None:
|
||||
config_table = "hermes_task_configs"
|
||||
log_table = "hermes_task_execution_logs"
|
||||
if _has_column(log_table, "tenant_id"):
|
||||
_drop_index_if_exists(log_table, "ix_hermes_task_logs_tenant_started")
|
||||
_drop_constraint_if_exists(
|
||||
log_table,
|
||||
"fk_hermes_task_logs_tenant_config",
|
||||
"foreignkey",
|
||||
)
|
||||
_drop_constraint_if_exists(
|
||||
log_table,
|
||||
"uq_hermes_task_execution_logs_tenant_id",
|
||||
"unique",
|
||||
)
|
||||
_drop_constraint_if_exists(
|
||||
log_table,
|
||||
"fk_hermes_task_execution_logs_tenant",
|
||||
"foreignkey",
|
||||
)
|
||||
if _has_table(config_table):
|
||||
op.create_foreign_key(
|
||||
"hermes_task_execution_logs_config_id_fkey",
|
||||
log_table,
|
||||
config_table,
|
||||
["config_id"],
|
||||
["id"],
|
||||
)
|
||||
op.drop_column(log_table, "tenant_id")
|
||||
if _has_column(config_table, "tenant_id"):
|
||||
_drop_index_if_exists(config_table, "ix_hermes_task_configs_tenant_enabled")
|
||||
_drop_constraint_if_exists(
|
||||
config_table,
|
||||
"uq_hermes_task_configs_tenant_id",
|
||||
"unique",
|
||||
)
|
||||
_drop_constraint_if_exists(
|
||||
config_table,
|
||||
"fk_hermes_task_configs_tenant",
|
||||
"foreignkey",
|
||||
)
|
||||
op.drop_column(config_table, "tenant_id")
|
||||
|
||||
|
||||
def _downgrade_profile_snapshots() -> None:
|
||||
table = "employee_behavior_profile_snapshots"
|
||||
if not _has_column(table, "tenant_id"):
|
||||
return
|
||||
_drop_index_if_exists(table, "ix_employee_behavior_profile_latest")
|
||||
_drop_constraint_if_exists(
|
||||
table,
|
||||
"fk_employee_behavior_profiles_tenant_employee",
|
||||
"foreignkey",
|
||||
)
|
||||
_drop_constraint_if_exists(
|
||||
table,
|
||||
"uq_employee_behavior_profiles_tenant_id",
|
||||
"unique",
|
||||
)
|
||||
_drop_constraint_if_exists(
|
||||
table,
|
||||
"fk_employee_behavior_profiles_tenant",
|
||||
"foreignkey",
|
||||
)
|
||||
op.drop_column(table, "tenant_id")
|
||||
op.create_index(
|
||||
"ix_employee_behavior_profile_latest",
|
||||
table,
|
||||
[
|
||||
"subject_id",
|
||||
"profile_type",
|
||||
"window_days",
|
||||
"expense_type_scope",
|
||||
"calculated_at",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user