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
@@ -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)