"""Add ``cmdb_cost_facts.auto_owner_source`` so the dashboard can distinguish rule-driven attribution from scope-owner fallback. Revision ID: 145 Revises: 144 Create Date: 2026-05-10 Why: * PR #240 left attribution wholly rule-driven: the ingest walked ``cmdb_cost_allocation_rules`` in priority order and stamped the first match's ``cost_owner_person_id`` onto the fact row. When no rule matched, the fact landed with ``cost_owner_person_id=NULL`` and surfaced as "未分摊" in the dashboard. * Production has 0 allocation rules — admins haven't authored any, and the auto-grant flows that *would* create rules (Azure OpenAI, Bedrock principal-tag) have not run for the existing 4 keys. Net: 10,502 facts (~$7,332.99 / 90d) were 100% unallocated even though every fact had ``scope_id`` set and every active scope had ``owner_person_id`` populated. The chain fact → scope → owner → department was already complete in the data; the rule engine simply didn't walk it. * The fallback added in the same PR as this migration walks that chain when the rule ladder misses, but the dashboard needs to be honest about *which path* attributed each fact: rule-driven (admin-authored override), scope-owner-fallback (subscription / project / sub-account default owner), or unattributed (no scope_id or scope had no owner). Without this column, the recompute summary cannot tell the operator "X rules matched, Y facts attributed via fallback, Z still unattributed". Schema: * ``auto_owner_source VARCHAR(16) NOT NULL DEFAULT 'none'`` — chosen over ENUM so a future addition (``iam_owner``, ``tag_owner``, ``rg_owner`` for Azure RG-level overrides) doesn't require an ALTER TYPE on TiDB. Allowed values are documented in the column doc and validated at the service layer; storing as a free string matches the broader codebase convention. * Default ``'none'`` so existing rows backfill safely without scanning every row — TiDB handles this as a metadata-only DDL. * Index on ``(auto_owner_source, date)`` because the dashboard's "未分摊 KPI tooltip" needs to count facts where source='none' in the chosen window without a full table scan; existing ``ix_facts_owner_date`` already covers ``cost_owner_person_id`` but not the source dimension. Downgrade: drop the index and the column. No data loss because the column is purely a derived classification — recompute_allocation re-stamps it from scratch on next run. """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "145" down_revision = "144" branch_labels = None depends_on = None def _table_exists(bind: sa.engine.Connection, name: str) -> bool: inspector = sa.inspect(bind) return name in inspector.get_table_names() def _column_exists( bind: sa.engine.Connection, table: str, column: str, ) -> bool: inspector = sa.inspect(bind) if table not in inspector.get_table_names(): return False return any(c["name"] == column for c in inspector.get_columns(table)) def _index_exists( bind: sa.engine.Connection, table: str, index: str, ) -> bool: inspector = sa.inspect(bind) if table not in inspector.get_table_names(): return False return any(ix["name"] == index for ix in inspector.get_indexes(table)) def upgrade() -> None: bind = op.get_bind() if not _table_exists(bind, "cmdb_cost_facts"): return if not _column_exists(bind, "cmdb_cost_facts", "auto_owner_source"): op.add_column( "cmdb_cost_facts", sa.Column( "auto_owner_source", sa.String(16), nullable=False, server_default=sa.text("'none'"), ), ) if not _index_exists(bind, "cmdb_cost_facts", "ix_facts_source_date"): op.create_index( "ix_facts_source_date", "cmdb_cost_facts", ["auto_owner_source", "date"], ) def downgrade() -> None: bind = op.get_bind() if not _table_exists(bind, "cmdb_cost_facts"): return if _index_exists(bind, "cmdb_cost_facts", "ix_facts_source_date"): op.drop_index("ix_facts_source_date", table_name="cmdb_cost_facts") if _column_exists(bind, "cmdb_cost_facts", "auto_owner_source"): op.drop_column("cmdb_cost_facts", "auto_owner_source")