"""「这个 $0 是真的 0,还是我们没取到数」—— 每个云订阅的账单覆盖判定。 ## 为什么需要它 成本页上的一个 `$0.00` 有两种截然不同的含义: 1. 这个订阅这个月**真的没花钱**(计费已关、资源已清空、新建订阅)。 2. 我们**没有拿到它的账单**(导出表变了、凭据失效、厂商接口断流)。 两者在页面上长得一模一样,而后果相反:第一种什么都不用做,第二种是 「这条数字已经不能用了」。历史上唯一的信号是 ``summary.data_available``(= 这个月有没有 fact 行),而它对两种情况 **都是 false**,所以读者只能靠猜。 真实事故(2026-09-07 查实,本模块的由来):`gcp-digital-bi` / `gcp-digital-infra` / `gcp-digital-bi-dev` 三个项目在 2026-08-14 被挪到 一个新的账单账号(`0125F7-277DBE-14DC04`),而 GCP 集成的 ``billing_bq_table`` 里只有旧账单账号的三张导出表。于是这三个订阅 **8/13 之后零行入库**:9 月页面显示 `$0.00`、8 月显示 `$1,700` (真实是整月,被砍在 8/13),而同一个 GCP 集成的其余 34 个项目 一路同步到 9/05。**25 天里没有任何告警**:日环比尖刺告警要有「今天」 才能比、``cost_chain_crosscheck`` 是两条链路互查而这次两条一起断、 闲置扫描在「30 天一行都没有」时刻意弃权。 ## 判定所用的信号 单一真源。任何要说「这个订阅的花费是 X」的读侧都应当同时说得出 「X 完不完整」,判定就从这里取。 * ``integration_last_fact`` —— **同一个云**最近一条 fact 的日期。这是 「取数链路还活着」的证据:它到了 9/05,就说明 BQ/API 那一跳今天是通的, 于是同一个集成下某个订阅停在 8/13 只能是这个订阅自己的问题。 用整条集成而不是全平台,因为每个云的结算延迟不同。 * ``scope_last_fact`` —— 这个订阅最近一条 fact 的日期(窗口内 / 历史)。 * **连续计费性** —— 该订阅在「最后一条账单往前 14 天」里有多少天有行。 这是本模块唯一能把「真的停了」和「取不到了」分开的信号:日均型订阅 (ECS/BQ/存储常驻)每天都有行,缺一天就是缺口;而一个只在跑批时才 花钱的订阅本来就是断续的,对它下「缺口」结论是误报。 **达不到连续阈值时判 ``indeterminate`` —— 如实说判不了,不猜。** ## 判不出来的那一半,不假装判得出 有一类我们**从数据上无法区分**:一个连续计费的订阅被真的停掉计费 (GCP `billingEnabled: false`)。它的形状与「导出表配漏了」完全相同 —— 两条链路都源自同一个厂商导出,没有独立的第二个信源可以复核。生产上 `greg-kubernetes` / `dbaas-key` / `dbaas-central` / `dbaas-staging` / `gcp-dm-dev` 就是这一类(`billingEnabled: false`,那个 0 是真的)。 所以 ``gap`` 的文案**只陈述观测到的事实**(「同云已同步至 X,本订阅 Y 之后无账单行」)并列出两种可能,不断言「我们取数失败了」。判定给出 方向,不替读者下结论 —— 这条边界是有意的,别把 ``gap`` 的文案改成 断言式。 """ from __future__ import annotations from dataclasses import dataclass from datetime import date, timedelta from decimal import Decimal from typing import Any from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import CmdbCostFact # ── 五个状态 ──────────────────────────────────────────────────────── # 数据齐:这个月该有的天都有行 ⇒ 显示的数字(包括 $0)可以直接用。 COVERAGE_COMPLETE = "complete" # 缺口:同一个云已同步到更近的日期,而这个连续计费的订阅在更早的 # 某天之后没有账单行 ⇒ 数字偏低或为 0,不能当结论用。 COVERAGE_GAP = "gap" # 整条链路滞后:这个云的 fact 根本没覆盖到所查月份 ⇒ 不是订阅的问题。 COVERAGE_PIPELINE_STALE = "pipeline_stale" # 这个订阅从来没有过账单数据(新建 / 从未计费)⇒ 无从判断。 COVERAGE_NO_HISTORY = "no_history" # 断了,但这个订阅本来就是断续计费的 ⇒ 如实说判不了。 COVERAGE_INDETERMINATE = "indeterminate" COVERAGE_STATUSES = frozenset({ COVERAGE_COMPLETE, COVERAGE_GAP, COVERAGE_PIPELINE_STALE, COVERAGE_NO_HISTORY, COVERAGE_INDETERMINATE, }) # 连续计费性的判定窗口:最后一条账单往前 14 天里,至少 12 天有行才算 # 「每天都在花钱」。12/14 而不是 14/14 —— 生产上一个常驻型订阅偶尔会 # 有某天恰好没有任何计费行(跨日结算把当天的量并进了前一天),要求 # 满分会把这种正常抖动判成断续型,从而对真缺口弃权。 _CONTINUITY_WINDOW_DAYS = 14 _CONTINUITY_MIN_DAYS = 12 # 尾部容差:落后 1 天是正常的入库延迟(厂商 T+1 结算),不算缺口。 # 抽屉里另有一条 ``dataLag`` 提示负责「落后 ≥2 天」的常规滞后,本模块 # 只负责「这个订阅比同云其他订阅落后得多」这件事。 _STALE_TOLERANCE_DAYS = 1 @dataclass(frozen=True) class CoverageSignals: """判定的全部输入。纯数据,便于表驱动测试(无需 DB)。""" month_start: date # 已 clamp 到今天(当月)或月末(往月)—— 与 ``_month_bounds`` 一致。 month_end: date # 同一集成最近一条 fact 的日期;None = 这个云一条都没有。 integration_last_fact: date | None # 本订阅在所查月份内最近一条 fact 的日期。 scope_last_fact_in_window: date | None # 连续性判定的锚点 = 本订阅在**所查窗口结束前**最近一条 fact 的日期 # (窗口内有就是它,窗口内没有则回看历史)。`None` = 从来没有过账单。 # 刻意不是「全时段最大日期」:查一个往月时,锚点必须落在**它当时 # 停下来的那一天**,连续性窗口才会覆盖「停之前那 14 天」;用全时段 # 最大值会把窗口挪到数据恢复之后,把一个真缺口判成断续型。 scope_last_fact_anchor: date | None # 「最后一条账单往前 _CONTINUITY_WINDOW_DAYS 天」里有行的天数。 continuity_days: int # 同一窗口的日均金额(当前口径),用于估算缺口金额;None = 算不出。 prior_daily_avg: Decimal | None @dataclass(frozen=True) class CoverageVerdict: status: str # 本订阅在窗口内实际有数据到哪天(None = 窗口内一天都没有)。 covered_through: date | None # 按同云链路,窗口内**本应**有数据到哪天。 expected_through: date | None # expected_through 与 covered_through 之间缺的天数(窗口内)。 missing_days: int prior_daily_avg: Decimal | None # missing_days × prior_daily_avg。**估算**,文案必须标明。 estimated_missing: Decimal | None # 机器可读的判定依据,用于日志 / MCP / 排查,不直接给用户看。 reason: str @property def is_trustworthy(self) -> bool: """总额能不能当结论用。``indeterminate`` 算能用 —— 我们没有 任何证据说它不完整,只是没有证据说它完整。""" return self.status in { COVERAGE_COMPLETE, COVERAGE_NO_HISTORY, COVERAGE_INDETERMINATE, } def as_dict(self) -> dict[str, Any]: return { "status": self.status, "covered_through": ( self.covered_through.isoformat() if self.covered_through else None ), "expected_through": ( self.expected_through.isoformat() if self.expected_through else None ), "missing_days": self.missing_days, "prior_daily_avg_usd": ( float(self.prior_daily_avg) if self.prior_daily_avg is not None else None ), "estimated_missing_usd": ( float(self.estimated_missing) if self.estimated_missing is not None else None ), "reason": self.reason, } def classify_coverage(signals: CoverageSignals) -> CoverageVerdict: """纯函数:把信号折成一个状态。模块 docstring 是规则的解释。""" s = signals def _verdict( status: str, reason: str, *, covered_through: date | None = None, expected_through: date | None = None, missing_days: int = 0, ) -> CoverageVerdict: avg = s.prior_daily_avg estimated = ( (avg * Decimal(missing_days)) if (avg is not None and missing_days > 0 and status == COVERAGE_GAP) else None ) return CoverageVerdict( status=status, covered_through=covered_through, expected_through=expected_through, missing_days=missing_days, prior_daily_avg=avg, estimated_missing=estimated, reason=reason, ) # ① 这个云一条 fact 都没有,或最近一条都到不了所查月份 —— 整条链路 # 的问题,不是这个订阅的问题。先判它,否则下面每一个订阅都会被 # 判成 gap(一个云挂了会把它下面所有订阅同时标红,噪声压倒信号)。 if s.integration_last_fact is None: return _verdict( COVERAGE_PIPELINE_STALE, "integration_has_no_facts", ) if s.integration_last_fact < s.month_start: return _verdict( COVERAGE_PIPELINE_STALE, "integration_last_fact_before_month", expected_through=s.integration_last_fact, ) # 同云链路在窗口内推进到了哪天。往月取月末(链路早已越过它), # 当月取 min(今天, 链路最后一天)。 expected_through = min(s.month_end, s.integration_last_fact) # ② 这个订阅历史上从来没有账单 —— 新建、或从未开通计费。没有基线, # 连续性无从谈起,如实说「无账单数据」。 if s.scope_last_fact_anchor is None: return _verdict( COVERAGE_NO_HISTORY, "scope_never_had_facts", expected_through=expected_through, ) covered_through = s.scope_last_fact_in_window if covered_through is not None and covered_through >= ( expected_through - timedelta(days=_STALE_TOLERANCE_DAYS) ): # ③ 跟上了同云链路(容差内)—— 数字可以用,$0 就是真的 $0。 return _verdict( COVERAGE_COMPLETE, "scope_current_with_integration", covered_through=covered_through, expected_through=expected_through, ) # ④ 落后了。缺的天数按窗口算:窗口内有数据就从它的次日算起, # 一天都没有就从月初算起。 gap_from = ( covered_through + timedelta(days=1) if covered_through is not None else s.month_start ) missing_days = max(0, (expected_through - gap_from).days + 1) # ⑤ 只有「连续计费型」才敢下缺口结论。断续型订阅缺几天是常态。 if s.continuity_days < _CONTINUITY_MIN_DAYS: return _verdict( COVERAGE_INDETERMINATE, "scope_bills_sporadically", covered_through=covered_through, expected_through=expected_through, missing_days=missing_days, ) return _verdict( COVERAGE_GAP, "scope_stalled_while_integration_current", covered_through=covered_through, expected_through=expected_through, missing_days=missing_days, ) async def collect_coverage_signals( db: AsyncSession, *, integration_id: str, amount_col: Any, fact_where: list[Any], month_start: date, month_end: date, ) -> CoverageSignals: """跑四个小查询取信号。 ``fact_where`` / ``amount_col`` 直接沿用调用方(抽屉)已经算好的 scope 路由与口径表达式 —— 覆盖判定必须和页面上那个数字问的是**同一 批行**,否则会出现「页面 $0、判定说完整」这种自相矛盾。 集成新鲜度那一跳只回看 45 天:``ix_facts_date_measure`` 以 ``date`` 为首列,带上界的范围扫描能剪分区;而「45 天内一条都没有」本身就 已经足够判 ``pipeline_stale``(真断这么久是另一个量级的事故)。 """ if not fact_where: # RG 型 scope 缺 rg_name —— 调用方已经把 fact_where 置空, # 任何金额都取不到,判定同样应当弃权而不是报「完整」。 return CoverageSignals( month_start=month_start, month_end=month_end, integration_last_fact=None, scope_last_fact_in_window=None, scope_last_fact_anchor=None, continuity_days=0, prior_daily_avg=None, ) integration_last_fact = (await db.execute( select(func.max(CmdbCostFact.date)).where( CmdbCostFact.integration_id == integration_id, CmdbCostFact.date >= month_start - timedelta(days=45), ) )).scalar() scope_last_in_window = (await db.execute( select(func.max(CmdbCostFact.date)).where( and_( *fact_where, CmdbCostFact.date >= month_start, CmdbCostFact.date <= month_end, ) ) )).scalar() scope_last_anchor = scope_last_in_window if scope_last_anchor is None: # 回看历史必须**以窗口末尾为上界**。不带这个上界的话,一个 9 月 # 才开始计费的订阅在查 8 月时会拿到 9 月的锚点、被判成「8 月缺 # 了 31 天」—— 一个当时还不存在的订阅的假缺口。带上界之后这种 # 情况锚点为 None,如实落 no_history。 scope_last_anchor = (await db.execute( select(func.max(CmdbCostFact.date)).where( and_(*fact_where, CmdbCostFact.date <= month_end), ) )).scalar() continuity_days = 0 prior_daily_avg: Decimal | None = None if scope_last_anchor is not None: window_start = scope_last_anchor - timedelta( days=_CONTINUITY_WINDOW_DAYS - 1, ) row = (await db.execute( select( func.count(func.distinct(CmdbCostFact.date)), func.coalesce(func.sum(amount_col), 0), ).where( and_( *fact_where, CmdbCostFact.date >= window_start, CmdbCostFact.date <= scope_last_anchor, ) ) )).one() continuity_days = int(row[0] or 0) if continuity_days > 0: # 日均按**有行的天数**除,不按 14 除 —— 缺口金额估算的是 # 「这个订阅在花钱的日子里每天花多少」,用 14 做分母会把 # 断续型订阅的估算系统性压低。 prior_daily_avg = ( Decimal(str(row[1] or 0)) / Decimal(continuity_days) ).quantize(Decimal("0.0001")) return CoverageSignals( month_start=month_start, month_end=month_end, integration_last_fact=integration_last_fact, scope_last_fact_in_window=scope_last_in_window, scope_last_fact_anchor=scope_last_anchor, continuity_days=continuity_days, prior_daily_avg=prior_daily_avg, ) async def assess_scope_coverage( db: AsyncSession, *, integration_id: str, amount_col: Any, fact_where: list[Any], month_start: date, month_end: date, ) -> CoverageVerdict: """取信号 + 判定。读侧只需要调这一个。""" return classify_coverage(await collect_coverage_signals( db, integration_id=integration_id, amount_col=amount_col, fact_where=fact_where, month_start=month_start, month_end=month_end, ))