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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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单位元
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user