ci/woodpecker/push/main Pipeline failed
- ① 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(发布须另取放行)
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""置信度评分系统 — 财务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), "基于推测"),
|
||
}
|