diff --git a/backend/app/api/ai_analysis.py b/backend/app/api/ai_analysis.py index 3399936a..d80d369b 100644 --- a/backend/app/api/ai_analysis.py +++ b/backend/app/api/ai_analysis.py @@ -8,6 +8,7 @@ from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role 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.alert_entity_scope import month_period_clause import json, hashlib, httpx, os, urllib.request, urllib.parse from datetime import datetime, date 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.is_(None), ), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).first() if not latest or latest.actual_value is None: continue @@ -286,7 +288,9 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get KPIDefinition.status == "active").all() kpi_summary = [] 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({ "name": k.kpi_name, "code": k.kpi_code, @@ -455,7 +459,9 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe ).all() kpi_summary = [] 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({ "name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension, "target": k.target_value, "actual": latest.actual_value if latest else None, @@ -505,7 +511,9 @@ async def ask_question( ).all() kpi_context = [] 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() kpi_context.append( 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: kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first() 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( f"- {p.title}\n" f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n" diff --git a/backend/app/api/analysis_results.py b/backend/app/api/analysis_results.py index 2646fe33..dbf4659c 100644 --- a/backend/app/api/analysis_results.py +++ b/backend/app/api/analysis_results.py @@ -20,6 +20,7 @@ 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 @@ -287,6 +288,7 @@ async def auto_calculate_confidence( # 查找历史数据(趋势) 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 diff --git a/backend/app/api/bi_reports.py b/backend/app/api/bi_reports.py index 050b1625..c9dbb539 100644 --- a/backend/app/api/bi_reports.py +++ b/backend/app/api/bi_reports.py @@ -14,6 +14,7 @@ from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality from app.utils.oplog import log_op +from app.utils.alert_entity_scope import month_period_clause logger = logging.getLogger("bi-reports") @@ -230,7 +231,7 @@ def analyze_data(data: dict, db: Session = Depends(get_db)): if 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() diff --git a/backend/app/api/bot_bridge.py b/backend/app/api/bot_bridge.py index ac9ed024..c8bd3c1f 100644 --- a/backend/app/api/bot_bridge.py +++ b/backend/app/api/bot_bridge.py @@ -21,7 +21,7 @@ from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllo from app.models import KPICausality from app.risk_levels import risk_level # 裁定 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 import json @@ -135,7 +135,8 @@ def bot_kpis( results = [] 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() results.append({ "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() if not kpi or kpi.entity_id != entity_id: 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() return { "kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit}, diff --git a/backend/app/api/bot_kpis.py b/backend/app/api/bot_kpis.py index 4df5163b..151db227 100644 --- a/backend/app/api/bot_kpis.py +++ b/backend/app/api/bot_kpis.py @@ -8,6 +8,7 @@ from app.database import get_db from app.auth_middleware import require_auth from app.deps import get_entity_id from app.models import KPIDefinition, KPIValue +from app.utils.alert_entity_scope import month_period_clause from app.risk_levels import risk_level router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"], @@ -88,6 +89,7 @@ def list_bot_kpis( ) if 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() current_val = latest_val.actual_value if latest_val else None diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py index fa9a082c..41940a75 100644 --- a/backend/app/api/cash.py +++ b/backend/app/api/cash.py @@ -12,6 +12,7 @@ from sqlalchemy import or_ import pandas as pd from app.database import get_db 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.models import CashPlan 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( KPIValue.kpi_id == debt_kpi.id, KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).first() if dv and dv.actual_value is not None: short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元 diff --git a/backend/app/api/customer_dashboard.py b/backend/app/api/customer_dashboard.py index 284fe0c9..ac8da0c7 100644 --- a/backend/app/api/customer_dashboard.py +++ b/backend/app/api/customer_dashboard.py @@ -8,6 +8,7 @@ from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPIValue, KPIAlert, User +from app.utils.alert_entity_scope import month_period_clause import logging 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 = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None 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( @@ -127,6 +128,7 @@ def list_customer_kpis( # 最近6期趋势数据 period_q = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(6).all() period_values = [ {"period": v.period, "value": v.actual_value} @@ -171,6 +173,7 @@ def get_kpi_trend( # 获取最近N期数据 values = db.query(KPIValue).filter( KPIValue.kpi_id == kpi_id, + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(months).all() trend_data = [ diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 60da5620..bff1af69 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -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.voucher_guard import apply_voucher_guard 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 收口)。 # 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。 from app.api.kpis import REVERSE_INDICATORS @@ -113,6 +114,7 @@ def get_bep_card( KPIValue.kpi_id == kpis[code].id, KPIValue.source_type == "derived", KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first() 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 = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None 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( KPIAlert.kpi_id == k.id, @@ -396,6 +398,7 @@ def get_my_kpis( trend_values = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(6).all() 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( KPIValue.kpi_id == k.id, + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(6).all() 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 的行为)。 # 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把 # 季度汇总行/半年度预估值(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) # 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径) @@ -600,7 +605,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]: """该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。""" monthly = ( 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( KPIValue.period.desc(), _MAIN_VALUE_PRIORITY, diff --git a/backend/app/api/data_quality.py b/backend/app/api/data_quality.py index ead77654..ee1f91aa 100644 --- a/backend/app/api/data_quality.py +++ b/backend/app/api/data_quality.py @@ -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.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog from app.api.kpis import kpi_to_dict +from app.utils.alert_entity_scope import month_period_clause 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( KPIValue.kpi_id == kpi.id, KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).limit(12).all() # 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( KPIValue.kpi_id == kpi.id, KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).first() if latest_val and latest_val.calculated_at: if latest_val.calculated_at < stale_threshold: diff --git a/backend/app/api/kpi_causality.py b/backend/app/api/kpi_causality.py index 5f0ac85a..749a074a 100644 --- a/backend/app/api/kpi_causality.py +++ b/backend/app/api/kpi_causality.py @@ -13,6 +13,7 @@ from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog from app.utils.oplog import log_op +from app.utils.alert_entity_scope import month_period_clause from app.services.causality_verification import ( ALL_STATUSES, ALL_SOURCE_TYPES, @@ -147,7 +148,7 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int ) if 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: 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( KPIValue.kpi_id == target_id, KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).first() predicted_value = None diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index 6db833f4..f4a77084 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -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.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User 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.utils.threshold_parser import ( calc_level as _tp_calc_level, @@ -200,6 +201,7 @@ def get_kpi_score( ) if 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() 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( KPIValue.kpi_id == k.id, KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).first() current_value = latest_val.actual_value if latest_val else None @@ -568,7 +571,9 @@ def list_kpi_values( raise HTTPException(404, "KPI不存在") if kpi.entity_id != entity_id: 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 { "data": [{ "id": v.id, diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 87c26968..bfe3ce67 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -11,6 +11,7 @@ from app.utils.cash_forecast_engine import ( ) from app.database import get_db 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 sqlalchemy.orm import Session @@ -772,7 +773,7 @@ def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None): ) if 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} seen = set() diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 22c84040..18adcf51 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -20,6 +20,7 @@ from app.database import get_db 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.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.services import report_reclass from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug @@ -243,7 +244,8 @@ def get_kpi_trends( results = [] for kpi in kpis: 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() values.reverse() @@ -2387,6 +2389,7 @@ def _fetch_kpi_data(db: Session, entity_id: int = 1) -> list: latest = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, KPIValue.actual_value.isnot(None), + month_period_clause(KPIValue.period), ).order_by(KPIValue.period.desc()).first() alerts = db.query(KPIAlert).filter( diff --git a/backend/app/api/subjects.py b/backend/app/api/subjects.py index 3d10133a..4fd6e880 100644 --- a/backend/app/api/subjects.py +++ b/backend/app/api/subjects.py @@ -28,7 +28,9 @@ def list_subjects( 账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目** (`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。 行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。 - 返回体字段不删不加必填(前端契约不变)。 + 2026-09-26(yanxue-20260924-cma-fetch-caliber-fixes):响应**增补 `entity_id`** + (`NULL` = 全局标准准则科目;`= 会话账套` = 本账套私有明细)——原响应不含该字段, + 共享层(51 行)与酣客私有(156 行)在界面上不可区分。既有 10 个字段语义与名称不变。 """ query = db.query(Subject).filter( Subject.is_active == 1, @@ -59,6 +61,7 @@ def list_subjects( "new_standard_category": s.new_standard_category, "is_active": s.is_active, "remark": s.remark, + "entity_id": s.entity_id, "updated_at": s.updated_at.isoformat() if s.updated_at else None, } for s in subjects diff --git a/backend/app/services/knowledge_service.py b/backend/app/services/knowledge_service.py index 57b7c498..ba690372 100644 --- a/backend/app/services/knowledge_service.py +++ b/backend/app/services/knowledge_service.py @@ -20,6 +20,7 @@ from sqlalchemy.orm import Session from sqlalchemy import func, and_ 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") @@ -165,7 +166,7 @@ async def _kpi_snapshot(db: Session, entity_id: int = 1) -> list[dict]: for k in kpis: latest = ( 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()) .first() ) diff --git a/backend/app/utils/alert_entity_scope.py b/backend/app/utils/alert_entity_scope.py index 3e20bf6e..00e6143e 100644 --- a/backend/app/utils/alert_entity_scope.py +++ b/backend/app/utils/alert_entity_scope.py @@ -87,6 +87,36 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool: 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 自己的最近可用期间」,单一实现 ── # # 2026-09-25(ASK-20260924-15):原实现只在 `scripts/alert_generator.resolve_latest_period`, diff --git a/backend/tests/test_period_caliber_actual_latest_20260926.py b/backend/tests/test_period_caliber_actual_latest_20260926.py new file mode 100644 index 00000000..14a8cd67 --- /dev/null +++ b/backend/tests/test_period_caliber_actual_latest_20260926.py @@ -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)