"""置信度评分系统 — 财务Bot分析结论管理 账套维度(2026-09-23 账套维度收口批,任务 0-P1-project-20260923-entity-dimension-convergence §二③ 读侧止血 + §三④ 账套化): 处置前:三个端点均无 `get_entity_id` ⇒ e1 / e2 双 token 拿到**逐字节相同**的返回体 (实测 2 行存量:`id=1 TEST001` / `id=2 kpi_code=NULL`)。 处置后:读/写/删均按会话账套判定归属;`entity_id` 列未落地时读侧退化为 「`kpi_code` JOIN `kpi_definitions.entity_id`」止血口径(DDL 与代码可分两步上线)。 归属三级(**任何一级都不猜账套**): ① `analysis_results.entity_id`(列已落地且非 NULL) ② 该行 `kpi_code` 在 `kpi_definitions` 中**唯一归属单账套**时所指向的 `entity_id` ③ 判不出归属(`kpi_code` 为空 / 库内无该码 / 该码跨账套二义 / 列为 NULL)→ 进 `unattributed` 登记池,**不返回给任何账套**;删除端点对其一律 404。 """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func, inspect from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_auth from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog from app.utils.alert_entity_scope import month_period_clause from typing import Optional, List from datetime import datetime router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"], dependencies=[Depends(require_auth)], ) # `analysis_results.entity_id` 列存在性缓存(键 = engine 对象 id;DDL 前后同一进程内可能变化, # 故只缓存判定结果、不做进程级常量:迁移后重启服务即刷新) _ENTITY_COL_CACHE: dict = {} def _has_entity_column(db) -> bool: """`analysis_results.entity_id` 是否已落地(DDL 迁移 2026-09-23 未执行时为 False)。 读侧止血必须先于 DDL 合入 ⇒ 不能无条件引用该列;列不在时按 `kpi_code` JOIN 判归属。 """ bind = db.get_bind() key = id(bind) cached = _ENTITY_COL_CACHE.get(key) if cached is None: try: cols = {c["name"] for c in inspect(bind).get_columns(AnalysisResult.__tablename__)} cached = "entity_id" in cols except Exception: # noqa: BLE001 —— 结构探不通时按"列不存在"的保守口径走 join cached = False _ENTITY_COL_CACHE[key] = cached return cached def _kpi_owners(db, codes: List[Optional[str]]) -> dict: """`kpi_code` → 拥有该码的账套集合(同码可跨账套存在,故为集合)""" codes = [c for c in codes if c] if not codes: return {} owners: dict = {} rows = ( db.query(KPIDefinition.kpi_code, KPIDefinition.entity_id) .filter(KPIDefinition.kpi_code.in_(codes)) .all() ) for code, eid in rows: if eid is not None: owners.setdefault(code, set()).add(eid) return owners def _row_owners(db, r, owners_map: dict) -> set: """单行归属账套集合;空集 = 判不出归属(不猜账套) 列口径:`entity_id` 非 NULL 即为唯一归属。 join 口径(列未落地 / 列为 NULL 的历史行):只有该 `kpi_code` 在 `kpi_definitions` 中**唯一归属单账套**时才认定——同码跨账套(库内 22 个码属两账套)属归属二义, 按「不猜账套」进登记池,宁可不可见也不误判给他账套。 """ if _has_entity_column(db): eid = getattr(r, "entity_id", None) if eid is not None: return {eid} if r.kpi_code: owners = owners_map.get(r.kpi_code, set()) return owners if len(owners) == 1 else set() return set() def _to_dict(r) -> dict: return { "id": r.id, "period": r.period, "结论": r.conclusion, "置信度": f"{r.confidence}%", "数据来源": r.data_source, "计算逻辑": r.calculation_logic, "可比基准": r.comparable_benchmark, "局限": r.limitations, "kpi_code": r.kpi_code, "kpi_name": r.kpi_name, "has_actual": bool(r.has_actual), "has_target": bool(r.has_target), "has_trend": bool(r.has_trend), "has_review": bool(r.has_review), "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None, } def _unattributed_dict(r) -> dict: """无归属登记项:只回标识与元信息,不回结论正文(登记而非共享)""" return { "id": r.id, "period": r.period, "kpi_code": r.kpi_code, "kpi_name": r.kpi_name, "confidence": r.confidence, "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None, } def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int: """基于数据完整度自动计算置信度""" if has_review: return 95 if has_actual and has_target and has_trend: return 85 if has_actual and has_target: return 70 if has_actual: return 50 return 30 # 无实际值,基于推测 @router.get("/result") async def get_analysis_results( period: Optional[str] = Query(None, description="期间 YYYY-MM"), kpi_code: Optional[str] = Query(None, description="KPI编码"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """查询分析结论(按期间和/或KPI编码过滤)— 账套隔离(2026-09-23) 返回体字段:原有字段全保留(`total` 语义收窄为「本账套可见行数」,`results` 仍为列表), 新增 `entity_id` / `unattributed_total` / `unattributed` / `entity_scope`(均为附加字段,前端契约不破坏)。 无归属行(存量 id=1/2)进 `unattributed`:**对任何账套都不进 `results`**,只作登记。 """ query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc()) if period: query = query.filter(AnalysisResult.period == period) if kpi_code: query = query.filter(AnalysisResult.kpi_code == kpi_code) results = query.all() owners_map = _kpi_owners(db, [r.kpi_code for r in results]) mine, unattributed = [], [] for r in results: owners = _row_owners(db, r, owners_map) if not owners: unattributed.append(r) elif entity_id in owners: mine.append(r) return { "total": len(mine), "period": period, "entity_id": entity_id, "entity_scope": "column" if _has_entity_column(db) else "kpi_join", "unattributed_total": len(unattributed), "unattributed": [_unattributed_dict(r) for r in unattributed], "results": [_to_dict(r) for r in mine], } @router.post("/result") def create_analysis_result(data: dict = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """提交分析结果(自动计算置信度)— 账套维度:本次写入挂**会话账套** `entity_id` 列未落地时(DDL 迁移前)先把会话账套写进 `OperationLog.detail.entity_id`, 列落地后直接写列(响应始终回 `entity_id`)。 """ if not data: data = {} period = data.get("period", "") conclusion = data.get("conclusion", "") data_source = data.get("data_source", "") calculation_logic = data.get("calculation", "") comparable_benchmark = data.get("comparable_benchmark") limitations = data.get("limitations") kpi_code = data.get("kpi_code") kpi_name = data.get("kpi_name") has_actual = data.get("has_actual", False) has_target = data.get("has_target", False) has_trend = data.get("has_trend", False) has_review = data.get("has_review", False) confidence = _calc_confidence(has_actual, has_target, has_trend, has_review) has_column = _has_entity_column(db) result = AnalysisResult( period=period, conclusion=conclusion, confidence=confidence, data_source=data_source, calculation_logic=calculation_logic, comparable_benchmark=comparable_benchmark, limitations=limitations, kpi_code=kpi_code, kpi_name=kpi_name, has_actual=1 if has_actual else 0, has_target=1 if has_target else 0, has_trend=1 if has_trend else 0, has_review=1 if has_review else 0, ) if has_column: result.entity_id = entity_id db.add(result) db.commit() db.refresh(result) if not has_column: # DDL 未落地前的过渡留痕:会话账套只存在于日志,不猜列 import json db.add(OperationLog( user_id=None, action="create", target_type="analysis_result", target_id=result.id, detail=json.dumps({"entity_id": entity_id, "kpi_code": kpi_code, "period": period, "note": "analysis_results.entity_id 列未落地,账套暂存于日志"}, ensure_ascii=False), )) db.commit() return { "id": result.id, "period": result.period, "结论": result.conclusion, "置信度": f"{result.confidence}%", "数据来源": result.data_source, "计算逻辑": result.calculation_logic, "可比基准": result.comparable_benchmark, "局限": result.limitations, "confidence_score": result.confidence, "entity_id": entity_id, "entity_persisted": "column" if has_column else "operation_log", "message": "分析结论已保存", } @router.delete("/result/{result_id}") async def delete_analysis_result( result_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """删除分析结论 — 归属校验:非本账套 或 无归属一律 **404**(不引入 403,避免探测面)""" result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first() if not result: raise HTTPException(404, "分析结论不存在") owners = _row_owners(db, result, _kpi_owners(db, [result.kpi_code])) if entity_id not in owners: # 他账套 id 与无归属行走同一文案(不暴露"该行存在但不属于你") raise HTTPException(404, "分析结论不存在") db.delete(result) db.commit() return {"message": "已删除"} @router.post("/auto-calculate") async def auto_calculate_confidence( period: str = Query(..., description="期间 YYYY-MM"), kpi_code: str = Query(..., description="KPI编码"), db: Session = Depends(get_db), ): """根据KPI数据完整性自动生成置信度评分""" # 查找KPI定义 kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() if not kpi: raise HTTPException(404, f"KPI编码 {kpi_code} 不存在") # 查找该期间的实际值 value = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, KPIValue.period == period, ).first() # 查找历史数据(趋势) trend_values = db.query(KPIValue).filter( KPIValue.kpi_id == kpi.id, month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(6).all() has_actual = value is not None and value.actual_value is not None has_target = kpi.target_value is not None has_trend = len(trend_values) >= 2 has_review = False confidence = _calc_confidence(has_actual, has_target, has_trend, has_review) return { "kpi_code": kpi_code, "kpi_name": kpi.kpi_name, "period": period, "has_actual": has_actual, "has_target": has_target, "has_trend": has_trend, "has_review": has_review, "confidence": confidence, "confidence_label": f"{confidence}%", "数据完备度": { "10%": "无数据", "50%": "有实际值", "70%": "有实际值+目标值", "85%": "有实际值+目标值+历史趋势", "95%": "有全部数据+人工复核", }.get(str(confidence), "基于推测"), }