feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -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