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:
@@ -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)
|
||||
Reference in New Issue
Block a user