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")
|
||||
Reference in New Issue
Block a user