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.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"
+2
View File
@@ -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
+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.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()
+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.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},
+2
View File
@@ -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
+2
View File
@@ -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单位元
+4 -1
View File
@@ -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 = [
+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.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,
+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.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:
+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.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
+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.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,
+2 -1
View File
@@ -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()
+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.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(
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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()
)
+30
View File
@@ -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`,
@@ -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)