fix(fetch-caliber): 取数层排除半年/预测伪期间 + subjects 响应补 entity_id

R3 期间口径(仅 YYYY-MM)延伸到取数层:新增 alert_entity_scope.month_period_clause()(跨库可移植 SQL 等价式),应用到 15 个文件 26 处「按 period 倒序取最新值/最近N期」位点;dashboard 原 MySQL-only REGEXP 正则收口到同单一事实源。

GET /api/cma/subjects 响应增补 entity_id(NULL=全局标准准则科目 / N=账套私有明细),既有 10 字段语义与名称不变。

证据:pending-tasks/_reports/yanxue-20260924-cma-fetch-caliber-fixes-fullstack.md(改前/改后 A/B:F_NET_PROFIT 2026-H2/-55.0 → 2026-08/-10.46)。新增 7 例回归测试;全量 1 failed(预存在) 1473 passed,基线同项。
This commit is contained in:
Hermes CI Fix
2026-09-26 02:42:04 +08:00
parent 52b693ddbb
commit 242f3cf5ec
17 changed files with 282 additions and 18 deletions
+14 -4
View File
@@ -8,6 +8,7 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
from app.utils.cache import get as cache_get, set as cache_set from app.utils.cache import get as cache_get, set as cache_set
from app.utils.alert_entity_scope import month_period_clause
import json, hashlib, httpx, os, urllib.request, urllib.parse import json, hashlib, httpx, os, urllib.request, urllib.parse
from datetime import datetime, date from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"], router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
@@ -102,6 +103,7 @@ def generate_rule_suggestions(db: Session, entity_id: int,
KPIValue.entity_id == entity_id, KPIValue.entity_id == entity_id,
KPIValue.entity_id.is_(None), KPIValue.entity_id.is_(None),
), ),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first() ).order_by(KPIValue.period.desc()).first()
if not latest or latest.actual_value is None: if not latest or latest.actual_value is None:
continue continue
@@ -286,7 +288,9 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
KPIDefinition.status == "active").all() KPIDefinition.status == "active").all()
kpi_summary = [] kpi_summary = []
for k in kpis: for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
kpi_summary.append({ kpi_summary.append({
"name": k.kpi_name, "name": k.kpi_name,
"code": k.kpi_code, "code": k.kpi_code,
@@ -455,7 +459,9 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
).all() ).all()
kpi_summary = [] kpi_summary = []
for k in kpis: for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
kpi_summary.append({ kpi_summary.append({
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension, "name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
"target": k.target_value, "actual": latest.actual_value if latest else None, "target": k.target_value, "actual": latest.actual_value if latest else None,
@@ -505,7 +511,9 @@ async def ask_question(
).all() ).all()
kpi_context = [] kpi_context = []
for k in kpis: for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first() latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first() alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first()
kpi_context.append( kpi_context.append(
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}" f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}"
@@ -573,7 +581,9 @@ async def review_plans(
for p in plans: for p in plans:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
kpi_name = kpi.kpi_name if kpi else "未知" kpi_name = kpi.kpi_name if kpi else "未知"
latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).order_by(KPIValue.period.desc()).first() latest = db.query(KPIValue).filter(
KPIValue.kpi_id == p.kpi_id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
plan_text.append( plan_text.append(
f"- {p.title}\n" f"- {p.title}\n"
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n" f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n"
+2
View File
@@ -20,6 +20,7 @@ from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth from app.auth_middleware import require_auth
from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog
from app.utils.alert_entity_scope import month_period_clause
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
@@ -287,6 +288,7 @@ async def auto_calculate_confidence(
# 查找历史数据(趋势) # 查找历史数据(趋势)
trend_values = db.query(KPIValue).filter( trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.kpi_id == kpi.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all() ).order_by(KPIValue.period.desc()).limit(6).all()
has_actual = value is not None and value.actual_value is not None has_actual = value is not None and value.actual_value is not None
+2 -1
View File
@@ -14,6 +14,7 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
from app.utils.oplog import log_op from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
logger = logging.getLogger("bi-reports") logger = logging.getLogger("bi-reports")
@@ -230,7 +231,7 @@ def analyze_data(data: dict, db: Session = Depends(get_db)):
if period_end: if period_end:
val_query = val_query.filter(KPIValue.period <= period_end) val_query = val_query.filter(KPIValue.period <= period_end)
latest = val_query.order_by(KPIValue.period.desc()).first() latest = val_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
# 获取趋势数据 # 获取趋势数据
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all() trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
+5 -3
View File
@@ -21,7 +21,7 @@ from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllo
from app.models import KPICausality from app.models import KPICausality
from app.risk_levels import risk_level from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源) # 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period, month_period_clause
from app.utils.oplog import log_op, SCOPE_GLOBAL from app.utils.oplog import log_op, SCOPE_GLOBAL
import json import json
@@ -135,7 +135,8 @@ def bot_kpis(
results = [] results = []
for k in kpis: for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)\ latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period))\
.order_by(KPIValue.period.desc()).first() .order_by(KPIValue.period.desc()).first()
results.append({ results.append({
"id": k.id, "name": k.kpi_name, "code": k.kpi_code, "id": k.id, "name": k.kpi_name, "code": k.kpi_code,
@@ -166,7 +167,8 @@ def bot_kpi_history(
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi or kpi.entity_id != entity_id: if not kpi or kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\ values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period))\
.order_by(KPIValue.period.desc()).limit(limit).all() .order_by(KPIValue.period.desc()).limit(limit).all()
return { return {
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit}, "kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
+2
View File
@@ -8,6 +8,7 @@ from app.database import get_db
from app.auth_middleware import require_auth from app.auth_middleware import require_auth
from app.deps import get_entity_id from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue from app.models import KPIDefinition, KPIValue
from app.utils.alert_entity_scope import month_period_clause
from app.risk_levels import risk_level from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"], router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
@@ -88,6 +89,7 @@ def list_bot_kpis(
) )
if period: if period:
val_query = val_query.filter(KPIValue.period == period) val_query = val_query.filter(KPIValue.period == period)
val_query = val_query.filter(month_period_clause(KPIValue.period))
latest_val = val_query.order_by(KPIValue.period.desc()).first() latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None current_val = latest_val.actual_value if latest_val else None
+2
View File
@@ -12,6 +12,7 @@ from sqlalchemy import or_
import pandas as pd import pandas as pd
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request from app.deps import get_entity_id, resolve_entity_for_request
from app.utils.alert_entity_scope import month_period_clause
from app.auth_middleware import require_role from app.auth_middleware import require_role
from app.models import CashPlan from app.models import CashPlan
from app.utils.cash_forecast_engine import ( from app.utils.cash_forecast_engine import (
@@ -974,6 +975,7 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> t
dv = db.query(KPIValue).filter( dv = db.query(KPIValue).filter(
KPIValue.kpi_id == debt_kpi.id, KPIValue.kpi_id == debt_kpi.id,
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first() ).order_by(KPIValue.period.desc()).first()
if dv and dv.actual_value is not None: if dv and dv.actual_value is not None:
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元 short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
+4 -1
View File
@@ -8,6 +8,7 @@ from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.alert_entity_scope import month_period_clause
import logging import logging
logger = logging.getLogger("cma.customer") logger = logging.getLogger("cma.customer")
@@ -86,7 +87,7 @@ def list_customer_kpis(
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else: else:
latest = base_query.order_by(KPIValue.period.desc()).first() latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
# 最新预警 # 最新预警
alert = db.query(KPIAlert).filter( alert = db.query(KPIAlert).filter(
@@ -127,6 +128,7 @@ def list_customer_kpis(
# 最近6期趋势数据 # 最近6期趋势数据
period_q = db.query(KPIValue).filter( period_q = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all() ).order_by(KPIValue.period.desc()).limit(6).all()
period_values = [ period_values = [
{"period": v.period, "value": v.actual_value} {"period": v.period, "value": v.actual_value}
@@ -171,6 +173,7 @@ def get_kpi_trend(
# 获取最近N期数据 # 获取最近N期数据
values = db.query(KPIValue).filter( values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.kpi_id == kpi_id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(months).all() ).order_by(KPIValue.period.desc()).limit(months).all()
trend_data = [ trend_data = [
+8 -3
View File
@@ -11,6 +11,7 @@ from app.models import KPIDefinition, KPIValue, KPIAlert, User, VoucherDetail
from app.utils.cache import get as cache_get, set as cache_set from app.utils.cache import get as cache_get, set as cache_set
from app.utils.voucher_guard import apply_voucher_guard from app.utils.voucher_guard import apply_voucher_guard
from app.utils.threshold_parser import eval_threshold from app.utils.threshold_parser import eval_threshold
from app.utils.alert_entity_scope import month_period_clause
# 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。 # 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。 # 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
from app.api.kpis import REVERSE_INDICATORS from app.api.kpis import REVERSE_INDICATORS
@@ -113,6 +114,7 @@ def get_bep_card(
KPIValue.kpi_id == kpis[code].id, KPIValue.kpi_id == kpis[code].id,
KPIValue.source_type == "derived", KPIValue.source_type == "derived",
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
latest[code] = {"period": v.period, "value": v.actual_value} if v else None latest[code] = {"period": v.period, "value": v.actual_value} if v else None
@@ -333,7 +335,7 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else: else:
latest = base_query.order_by(KPIValue.period.desc()).first() latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter( alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id, KPIAlert.kpi_id == k.id,
@@ -396,6 +398,7 @@ def get_my_kpis(
trend_values = db.query(KPIValue).filter( trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all() ).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)] trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
@@ -446,6 +449,7 @@ def get_finance_analysis(
trend_values = db.query(KPIValue).filter( trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all() ).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)] trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
@@ -578,7 +582,8 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。 # H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把 # 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
# 季度汇总行/半年度预估值(forecast)当成"最新"。 # 季度汇总行/半年度预估值(forecast)当成"最新"。
_MONTH_PERIOD_REGEX = r"^[0-9]{4}-(0[1-9]|1[0-2])$" # 期间口径(R3 正则 / SQL 等价式)已于 2026-09-26 收口到 app/utils/alert_entity_scope.py
# (is_month_period / month_period_clause),此处不再保留第二份副本。
# 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority) # 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority)
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径) # 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
@@ -600,7 +605,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
"""该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。""" """该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。"""
monthly = ( monthly = (
db.query(KPIValue) db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id, KPIValue.period.op("REGEXP")(_MONTH_PERIOD_REGEX)) .filter(KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period))
.order_by( .order_by(
KPIValue.period.desc(), KPIValue.period.desc(),
_MAIN_VALUE_PRIORITY, _MAIN_VALUE_PRIORITY,
+3
View File
@@ -14,6 +14,7 @@ from app.deps import assert_entity_scope, get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
from app.api.kpis import kpi_to_dict from app.api.kpis import kpi_to_dict
from app.utils.alert_entity_scope import month_period_clause
logger = logging.getLogger("data-quality") logger = logging.getLogger("data-quality")
@@ -74,6 +75,7 @@ def run_quality_check(db: Session = Depends(get_db), entity_id: int = Depends(ge
values = db.query(KPIValue).filter( values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(12).all() ).order_by(KPIValue.period.desc()).limit(12).all()
# 1. 检查数据缺失 # 1. 检查数据缺失
@@ -365,6 +367,7 @@ def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_en
latest_val = db.query(KPIValue).filter( latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first() ).order_by(KPIValue.period.desc()).first()
if latest_val and latest_val.calculated_at: if latest_val and latest_val.calculated_at:
if latest_val.calculated_at < stale_threshold: if latest_val.calculated_at < stale_threshold:
+3 -1
View File
@@ -13,6 +13,7 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
from app.utils.oplog import log_op from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.services.causality_verification import ( from app.services.causality_verification import (
ALL_STATUSES, ALL_STATUSES,
ALL_SOURCE_TYPES, ALL_SOURCE_TYPES,
@@ -147,7 +148,7 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int
) )
if period: if period:
query_values = query_values.filter(KPIValue.period == period) query_values = query_values.filter(KPIValue.period == period)
latest = query_values.order_by(KPIValue.period.desc()).first() latest = query_values.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
if latest: if latest:
current_value = latest.actual_value current_value = latest.actual_value
@@ -188,6 +189,7 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int
tgt_val = db.query(KPIValue).filter( tgt_val = db.query(KPIValue).filter(
KPIValue.kpi_id == target_id, KPIValue.kpi_id == target_id,
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first() ).order_by(KPIValue.period.desc()).first()
predicted_value = None predicted_value = None
+6 -1
View File
@@ -11,6 +11,7 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
from app.utils.oplog import log_op from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import ( from app.utils.threshold_parser import (
calc_level as _tp_calc_level, calc_level as _tp_calc_level,
@@ -200,6 +201,7 @@ def get_kpi_score(
) )
if period: if period:
val_query = val_query.filter(KPIValue.period == period) val_query = val_query.filter(KPIValue.period == period)
val_query = val_query.filter(month_period_clause(KPIValue.period))
latest_val = val_query.order_by(KPIValue.period.desc()).first() latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None current_val = latest_val.actual_value if latest_val else None
@@ -291,6 +293,7 @@ def get_kpi_glossary(
latest_val = db.query(KPIValue).filter( latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first() ).order_by(KPIValue.period.desc()).first()
current_value = latest_val.actual_value if latest_val else None current_value = latest_val.actual_value if latest_val else None
@@ -568,7 +571,9 @@ def list_kpi_values(
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id: if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all() vals = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).all()
return { return {
"data": [{ "data": [{
"id": v.id, "id": v.id,
+2 -1
View File
@@ -11,6 +11,7 @@ from app.utils.cash_forecast_engine import (
) )
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
from app.utils.alert_entity_scope import month_period_clause
from app.auth_middleware import require_auth_401 from app.auth_middleware import require_auth_401
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -772,7 +773,7 @@ def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
) )
if period: if period:
q = q.filter(KPIValue.period == period) q = q.filter(KPIValue.period == period)
rows = q.order_by(KPIValue.period.desc()).all() rows = q.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).all()
entity = {"entity": ent.name} entity = {"entity": ent.name}
seen = set() seen = set()
+4 -1
View File
@@ -20,6 +20,7 @@ from app.database import get_db
from app.auth_middleware import require_role, require_auth from app.auth_middleware import require_role, require_auth
from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory from app.models import KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, User, Subject, ActionPlan, OperationLog, ReportHistory
from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi from app.utils.deviation_engine import calc_period_deviation, calc_period_diff, calc_deviation, get_budget_for_kpi
from app.utils.alert_entity_scope import month_period_clause
from app.utils.voucher_guard import apply_voucher_guard from app.utils.voucher_guard import apply_voucher_guard
from app.services import report_reclass from app.services import report_reclass
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
@@ -243,7 +244,8 @@ def get_kpi_trends(
results = [] results = []
for kpi in kpis: for kpi in kpis:
values = db.query(KPIValue).filter( values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id KPIValue.kpi_id == kpi.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(months).all() ).order_by(KPIValue.period.desc()).limit(months).all()
values.reverse() values.reverse()
@@ -2387,6 +2389,7 @@ def _fetch_kpi_data(db: Session, entity_id: int = 1) -> list:
latest = db.query(KPIValue).filter( latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None), KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first() ).order_by(KPIValue.period.desc()).first()
alerts = db.query(KPIAlert).filter( alerts = db.query(KPIAlert).filter(
+4 -1
View File
@@ -28,7 +28,9 @@ def list_subjects(
账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目** 账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目**
(`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。 (`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。
行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。 行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。
返回体字段不删不加必填(前端契约不变)。 2026-09-26(yanxue-20260924-cma-fetch-caliber-fixes):响应**增补 `entity_id`**
(`NULL` = 全局标准准则科目;`= 会话账套` = 本账套私有明细)——原响应不含该字段,
共享层(51 行)与酣客私有(156 行)在界面上不可区分。既有 10 个字段语义与名称不变。
""" """
query = db.query(Subject).filter( query = db.query(Subject).filter(
Subject.is_active == 1, Subject.is_active == 1,
@@ -59,6 +61,7 @@ def list_subjects(
"new_standard_category": s.new_standard_category, "new_standard_category": s.new_standard_category,
"is_active": s.is_active, "is_active": s.is_active,
"remark": s.remark, "remark": s.remark,
"entity_id": s.entity_id,
"updated_at": s.updated_at.isoformat() if s.updated_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None,
} }
for s in subjects for s in subjects
+2 -1
View File
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func, and_ from sqlalchemy import func, and_
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue, UserEntity from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue, UserEntity
from app.utils.alert_entity_scope import month_period_clause
logger = logging.getLogger("cma.knowledge") logger = logging.getLogger("cma.knowledge")
@@ -165,7 +166,7 @@ async def _kpi_snapshot(db: Session, entity_id: int = 1) -> list[dict]:
for k in kpis: for k in kpis:
latest = ( latest = (
db.query(KPIValue) db.query(KPIValue)
.filter(KPIValue.kpi_id == k.id) .filter(KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period))
.order_by(KPIValue.period.desc()) .order_by(KPIValue.period.desc())
.first() .first()
) )
+30
View File
@@ -87,6 +87,36 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool:
return str(period) >= window_lower_period(now) return str(period) >= window_lower_period(now)
def month_period_clause(column):
"""R3 的 SQL 等价式:`column` 必须落在规整月 `YYYY-MM`(供**取数层**查询使用)。
为什么取数层必须是 SQL 侧(而不是取完再在 Python 里挑):
取数层的动作是「`ORDER BY period DESC` 取最新一行」(`.first()` / `.limit(N)`)。
伪期间 `2026-H2` 的**字符串序大于任何同年月份**('H' > '0'),所以只要它留在排序里,
「最新实际期」就被它顶掉——取到的是**预测值/半年值**,而且 Python 侧再过滤也救不回来
(最新那一行已经不是月期间,第二行才是),`.first()` 的结果直接变成错值或空。
故本口径必须在 **SQL 侧**参与排序前的筛选。
跨库可移植:只用 `LENGTH` / `SUBSTR` / `BETWEEN` / `IN`(MySQL、SQLite、PostgreSQL 同名可用),
**不用 `REGEXP`**——SQLite 测试库未注册 REGEXP,SQL 侧正则会让单测与生产走两套语义。
与 Python 侧 `is_month_period` 同源等价:长度 7 + 第 5 位 `-` + 月份两位数字且 01–12
(`2026-13` 之类非法月同样排除)。禁止在调用点另写一份长度/正则判断。
"""
from sqlalchemy import and_, func, or_ # 局部导入:本模块顶层不依赖 sqlalchemy 表达式 API
tens = func.substr(column, 6, 1)
ones = func.substr(column, 7, 1)
return and_(
func.length(column) == 7,
func.substr(column, 5, 1) == "-",
or_(
and_(tens == "0", ones.between("1", "9")),
and_(tens == "1", ones.in_(["0", "1", "2"])),
),
)
# ── 裁定 2b(2026-09-17):「各 KPI 自己的最近可用期间」,单一实现 ── # ── 裁定 2b(2026-09-17):「各 KPI 自己的最近可用期间」,单一实现 ──
# #
# 2026-09-25(ASK-20260924-15):原实现只在 `scripts/alert_generator.resolve_latest_period`, # 2026-09-25(ASK-20260924-15):原实现只在 `scripts/alert_generator.resolve_latest_period`,
@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
"""yanxue-20260924-cma-fetch-caliber-fixes-fullstack
取数/接口层两处口径缺陷的回归用例(2026-09-26,wecom-fullstack):
1. **伪期间排序陷阱**:`kpi_values` 里的半年/预测伪期间(`2026-H2` 等)字符串序**大于**同年任何
月份('H' > '0')⇒ 任何 `ORDER BY period DESC` 取「最新值」的查询会命中伪期间,结论变成预测值。
本单把 R3 期间口径(仅 `YYYY-MM`)延伸到取数层:`app.utils.alert_entity_scope.month_period_clause()`。
改前本文件的前两个用例必红(返回 2026-H2 / -55.0)。
2. **`GET /api/cma/subjects` 响应缺 `entity_id`**:共享层(`entity_id IS NULL`)与账套私有行
在界面上不可区分。本单只加字段,既有 10 个字段语义与名称不变。
"""
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.conftest import (
TEST_USER_PASSWORD, auth_header, create_test_kpi, create_test_user, get_token_for_user,
)
from app.models import Entity, KPIValue, Subject
from app.utils.alert_entity_scope import is_month_period, month_period_clause
GLOSSARY = "/api/cma/kpis/glossary"
SUBJECTS = "/api/cma/subjects"
# R3 判据样本:规整月 / 半年期 / 年期间 / 纯年份 / 非法月 / 近似格式
# (`kpi_values.period` 为 NOT NULL,空值/None 由 is_month_period 直接断言,不入库)
PERIOD_SAMPLES = [
"2026-08", "2026-09", "2026-01", "2026-12", "2025-12",
"2026-H1", "2026-H2", "2025-FY", "2026-Q2",
"2025", "2021", "2026-13", "2026-00", "2026-8", "2026-08-31",
]
def _mk_value(db: Session, kpi_id: int, period, value, entity_id: int = 1,
source_type: str = "manual", data_status: str = "verified") -> KPIValue:
v = KPIValue(entity_id=entity_id, kpi_id=kpi_id, period=period, actual_value=value,
source_type=source_type, data_status=data_status)
db.add(v)
db.commit()
db.refresh(v)
return v
def _token(client: TestClient, db: Session, entity_id: int = 1) -> str:
"""按账套登录(1=酣客 / 2=博海):确保企业存在 + 授权 + 取 token"""
from app.models import UserEntity
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent:
db.add(Entity(id=entity_id, name=f"企业{entity_id}", short_name=f"E{entity_id}", status="active"))
db.commit()
user = create_test_user(db)
if not db.query(UserEntity).filter(UserEntity.user_id == user.id,
UserEntity.entity_id == entity_id).first():
db.add(UserEntity(user_id=user.id, entity_id=entity_id))
db.commit()
return get_token_for_user(client, "testadmin", TEST_USER_PASSWORD)
class TestMonthPeriodClause:
"""SQL 等价式(取数层过滤)必须与 Python 侧 R3 判据逐值一致"""
def test_clause_equivalent_to_python_regex(self, db: Session):
kpi = create_test_kpi(db, kpi_code="T_PC_001")
for i, p in enumerate(PERIOD_SAMPLES):
_mk_value(db, kpi.id, p, float(i + 1))
rows = db.query(KPIValue.period).filter(
KPIValue.kpi_id == kpi.id, month_period_clause(KPIValue.period)
).all()
sql_side = sorted(r[0] for r in rows)
py_side = sorted(p for p in PERIOD_SAMPLES if is_month_period(p))
assert sql_side == py_side, f"SQL 侧={sql_side} Python 侧={py_side}"
assert "2026-H2" not in sql_side and "2026-H1" not in sql_side
assert "2025-FY" not in sql_side and "2025" not in sql_side and "2026-13" not in sql_side
assert len(sql_side) == 5
def test_null_and_empty_are_not_month_periods(self):
"""空值/None:Python 侧判据(列 NOT NULL,不入库)"""
assert is_month_period(None) is False
assert is_month_period("") is False
assert is_month_period(" 2026-08") is False
class TestLatestActualPeriodOnFetchLayer:
"""取数层「最新实际值」不得被伪期间顶掉(改前必红)"""
def test_glossary_current_value_skips_forecast_pseudo_period(self, client: TestClient, db: Session):
token = _token(client, db, 1)
kpi = create_test_kpi(db, kpi_code="F_NET_PROFIT", kpi_name="净利润", entity_id=1)
# 复刻生产证据(_evidence/hanke-data-gap-20260924/p11.out:56-57)
_mk_value(db, kpi.id, "2026-08", -10.46)
_mk_value(db, kpi.id, "2026-H1", -90.86)
_mk_value(db, kpi.id, "2026-H2", -55.0, source_type="forecast", data_status="estimated")
resp = client.get(GLOSSARY, headers=auth_header(token))
assert resp.status_code == 200, resp.text[:300]
item = [g for g in resp.json()["glossary"] if g["kpi_code"] == "F_NET_PROFIT"][0]
assert item["latest_period"] == "2026-08", f"latest_period={item['latest_period']}"
assert item["current_value"] == -10.46, f"current_value={item['current_value']}"
def test_glossary_kpi_with_only_pseudo_periods_has_no_current_value(self, client: TestClient, db: Session):
"""只有伪期间数据 → 当前值必须是「无」,不得拿预测值/半年值充数"""
token = _token(client, db, 1)
kpi = create_test_kpi(db, kpi_code="T_PC_ONLY_PSEUDO", entity_id=1)
_mk_value(db, kpi.id, "2026-H2", 455.0, source_type="forecast", data_status="estimated")
_mk_value(db, kpi.id, "2026-H1", 713.27)
resp = client.get(GLOSSARY, headers=auth_header(token))
assert resp.status_code == 200, resp.text[:300]
item = [g for g in resp.json()["glossary"] if g["kpi_code"] == "T_PC_ONLY_PSEUDO"][0]
assert item["latest_period"] is None, f"latest_period={item['latest_period']}"
assert item["current_value"] is None, f"current_value={item['current_value']}"
def test_dashboard_latest_kpi_value_helper(self, db: Session):
"""dashboard.latest_kpi_value(原 MySQL-only REGEXP,已收口到单一事实源)"""
from app.api.dashboard import latest_kpi_value
kpi = create_test_kpi(db, kpi_code="T_PC_HELPER")
_mk_value(db, kpi.id, "2026-06", 6.0)
_mk_value(db, kpi.id, "2026-H2", 99.0, source_type="forecast", data_status="estimated")
got = latest_kpi_value(db, kpi.id)
assert got is not None and got.period == "2026-06", f"period={getattr(got, 'period', None)}"
assert got.actual_value == 6.0
class TestSubjectsEntityIdField:
"""subjects 响应补 entity_id,既有 10 字段语义与名称不变"""
ORIGINAL_KEYS = {"id", "subject_code", "subject_name", "parent_code", "level", "category",
"new_standard_category", "is_active", "remark", "updated_at"}
def _seed(self, db: Session):
db.add(Subject(entity_id=None, subject_code="1001", subject_name="库存现金",
level=1, category="asset", is_active=1))
db.add(Subject(entity_id=1, subject_code="100106", subject_name="酒窖备用金",
level=1, category="asset", is_active=1, remark="zxt-sync"))
db.add(Subject(entity_id=2, subject_code="100108", subject_name="友赞商户",
level=1, category="asset", is_active=1, remark="zxt-sync"))
db.commit()
def test_response_contains_entity_id_and_keeps_original_fields(self, client: TestClient, db: Session):
token = _token(client, db, 1)
self._seed(db)
resp = client.get(SUBJECTS, headers=auth_header(token))
assert resp.status_code == 200, resp.text[:300]
data = resp.json()["data"]
assert data, "responses 为空"
for item in data:
assert self.ORIGINAL_KEYS.issubset(set(item.keys())), sorted(item.keys())
assert "entity_id" in item, sorted(item.keys())
by_code = {i["subject_code"]: i["entity_id"] for i in data}
assert by_code["1001"] is None # 全局标准准则科目
assert by_code["100106"] == 1 # 会话账套(酣客)私有
assert "100108" not in by_code # 他账套私有不可见(分层读取口径不变)
def test_bohai_session_sees_only_global(self, client: TestClient, db: Session):
self._seed(db)
ent = db.query(Entity).filter(Entity.id == 2).first()
if not ent:
db.add(Entity(id=2, name="企业2", short_name="E2", status="active"))
db.commit()
from app.models import UserEntity
user = create_test_user(db)
db.add(UserEntity(user_id=user.id, entity_id=2))
db.commit()
login = client.post("/api/cma/auth/login", json={
"username": "testadmin", "password": TEST_USER_PASSWORD, "entity_id": 2,
})
assert login.status_code == 200, login.text[:300]
token = login.json()["token"]
resp = client.get(SUBJECTS, headers=auth_header(token))
assert resp.status_code == 200, resp.text[:300]
data = resp.json()["data"]
by_code = {i["subject_code"]: i["entity_id"] for i in data}
assert by_code["1001"] is None
assert by_code["100108"] == 2
assert all(i["entity_id"] in (None, 2) for i in data)