Files
cma-management/backend/app/api/analysis_results.py
T
Hermes CI Fix fb4609be14
ci/woodpecker/push/main Pipeline failed
feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
- ① kpi_templates instantiate 构造点显式传会话账套(原缺 entity_id ⇒ 静默落 e1),kpi_code 判重按账套收紧
- ② kpi_subject_map 3 个读取点加护栏注释(不加列、不改语义)
- ③ subjects 分层:读口 union(NULL=全局标准准则科目 / N=账套私有),UPSERT 唯一键改 (entity_id, subject_code)
- ④ analysis_results 账套化:读/写/删三端点按会话账套判定归属(列未落地时 kpi_code JOIN 止血),
     无归属行进 unattributed 登记、删除一律 404
- 模型层按现网 DDL 补齐:KPIDefinition(template_id/is_system + uk_entity_kpi)、AnalysisResult.entity_id、Subject.entity_id
- 新增 migrate_{subjects,analysis_results}_entity_id_20260923.py(--check/--apply/--down)、
  22 条对账用例 test_entity_dimension_convergence_20260923.py

DDL 已由 ops 受控通道落地(subjects 21:31:56 / analysis_results 21:31:59 CST,审批 1e9b4d9a/5ffb1640)
本提交为切片提交,未 push(发布须另取放行)
2026-09-23 21:39:24 +08:00

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""置信度评分系统 — 财务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 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,
).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), "基于推测"),
}