Files
cma-management/backend/tests/test_period_caliber_actual_latest_20260926.py
T
Hermes CI Fix 242f3cf5ec 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,基线同项。
2026-09-26 02:42:04 +08:00

190 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)