ci/woodpecker/push/main Pipeline was successful
ASK-20261014-04(任总 2026-10-14 11:20「放行4」)批后口径: - §4-1 博海固定成本弃净额:6601/6602/6603 仅借方(133.98 万,2026-01..09);2026-03 单月由 -9.02 万转正; 6603 贷方(现金折扣收益 64.40 万)改 finance_income_terms 单列备查,不进固定成本/末行/勾稽。 - §4-2 博海变动成本追加 6403 营业税金及附加(530.17 万)。 - §4-3/§4-4 维持现行(酣客收入/边际贡献基数不动)。 - §4-5 管理利润表页加「数据批次/导入时刻」纯展示备注(import_logs)。 - 口径唯一源仍为 bep_caliber.ENTITY_CALIBERS;报表/卡面/派生器一律经 fixed_cost_spec() 解析(读口写口一致)。
412 lines
22 KiB
Python
412 lines
22 KiB
Python
"""管理利润表「真实科目取数」重建单测 — project-20261014-cma-profit-summary-real-data-rebuild(2026-10-14)
|
||
|
||
背景(代码级审核实锤的 3 处硬伤,本文件逐条设负对照):
|
||
① 原 `reports.py` L74 `contribution_margin = gross_profit`(边际贡献=毛利);
|
||
② 原 L76 变动成本 = 营收 × 硬编码比例(五成);
|
||
③ 原末行标签「= 息税前利润」却装净利率算出的 net_profit;且纵向勾稽对不上。
|
||
|
||
断言分组:
|
||
L1 逐行可对到真实科目 —— 每行 = 该 entity 口径下真实科目发生额(`rows.parts.by_subject` 留痕)
|
||
L2 无硬编码比例 / 无 KPI 比率倒推(AST+源码级负对照)、标签与算法一致、勾稽自检生效(含不平负对照)
|
||
L3 按 entity 隔离 —— e1(酣客)/e2(博海)各读自己的科目集;口径改动只改 ENTITY_CALIBERS 即自动跟随
|
||
"""
|
||
import ast
|
||
import datetime
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy.orm import Session
|
||
|
||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||
|
||
from app.models import KPIDefinition, KPIValue, VoucherDetail
|
||
from app.utils import profit_lines as PL
|
||
from app.utils.bep_caliber import ENTITY_CALIBERS
|
||
|
||
PERIOD = "2026-09"
|
||
PREV = "2026-08"
|
||
D = datetime.datetime(2026, 9, 30)
|
||
|
||
|
||
# ── 夹具:真实凭证明细(元;行域 = carry_forward==0)────────────────────────
|
||
|
||
def _row(db, entity_id, no, code, debit=0.0, credit=0.0, cf=0,
|
||
summary="单测明细", period=PERIOD):
|
||
db.add(VoucherDetail(entity_id=entity_id, voucher_no=str(no), voucher_date=D,
|
||
subject_code=code, subject_name="单测科目",
|
||
debit_amount=debit, credit_amount=credit, summary=summary,
|
||
carry_forward=cf, period=period, batch="unit-test"))
|
||
db.flush()
|
||
|
||
|
||
def seed_e1(db: Session, period: str = PERIOD):
|
||
"""酣客(e1)口径真账行:
|
||
收入 5001 贷 2,634,900;
|
||
变动 = 5401借 2,000,000 + 5403借 50,000 + 571103借 30,000 − 530103贷 10,000 = 2,070,000;
|
||
固定 = 5601借 120,000 + (5602借 130,000 − 贷 10,000) + 5603借 7,000 = 247,000。
|
||
+ 一条 carry_forward=1 结转腿(借 9,999,999)⇒ 任何口径都不得计入。
|
||
"""
|
||
_row(db, 1, 1, "5001", credit=2_634_900.0, period=period)
|
||
_row(db, 1, 2, "5401", debit=2_000_000.0, period=period)
|
||
_row(db, 1, 3, "5403", debit=50_000.0, period=period)
|
||
_row(db, 1, 4, "571103", debit=30_000.0, period=period)
|
||
_row(db, 1, 5, "530103", credit=10_000.0, period=period)
|
||
_row(db, 1, 6, "5601", debit=120_000.0, period=period) # 5601% 前缀
|
||
_row(db, 1, 7, "5602", debit=130_000.0, period=period)
|
||
_row(db, 1, 8, "5602", credit=10_000.0, period=period) # 净额 = 120,000
|
||
_row(db, 1, 9, "5603", debit=7_000.0, period=period)
|
||
_row(db, 1, 10, "5401", debit=9_999_999.0, cf=1, period=period) # 结转腿,剔除
|
||
db.commit()
|
||
|
||
|
||
def seed_e2(db: Session, period: str = PERIOD):
|
||
"""博海口径真账行(**ASK-20261014-04 §4-1/§4-2 批后口径**):
|
||
收入 6001 贷 1,000,000;变动 = 6401借 800,000 + 6403借 20,000 = 820,000;
|
||
固定(**仅借方**)= 6601借 60,000 + 6602借 30,000 + 6603借 8,000 = 98,000;
|
||
财务收益(单列,不进固定)= 6603 贷 20,000。
|
||
+ 一条 summary='结转期间损溢' 的结转腿(e2 账套 carry_forward 恒 0,必须按 summary 显式剔除)。
|
||
"""
|
||
_row(db, 2, 1, "6001", credit=1_000_000.0, period=period)
|
||
_row(db, 2, 2, "6401", debit=800_000.0, period=period)
|
||
_row(db, 2, 2, "6403", debit=20_000.0, period=period) # §4-2 追加营业税金及附加
|
||
_row(db, 2, 3, "6601", debit=60_000.0, period=period)
|
||
_row(db, 2, 4, "6602", debit=30_000.0, period=period)
|
||
_row(db, 2, 5, "6602", credit=5_000.0, period=period) # §4-1 后不进固定(仅借方)
|
||
_row(db, 2, 6, "6603", debit=8_000.0, period=period)
|
||
_row(db, 2, 7, "6603", credit=20_000.0, period=period) # §4-1 现金折扣收益 ⇒ 单列财务收益
|
||
_row(db, 2, 8, "6602", debit=999_999.0, summary="结转期间损溢", period=period)
|
||
db.commit()
|
||
|
||
|
||
# ── L1 逐行可对到真实科目 ───────────────────────────────────────────────────
|
||
|
||
class TestLinesFromRealSubjects:
|
||
def test_e1_lines_match_real_accounts(self, db: Session):
|
||
seed_e1(db)
|
||
r = PL.compute_lines(db, 1, PERIOD, PERIOD)
|
||
assert r["available"] is True
|
||
v = r["values"]
|
||
# 万元(元 ÷ 10000;汇总后舍入)
|
||
assert v["revenue"] == 263.49, v
|
||
assert v["variable_cost"] == 207.00, v
|
||
assert v["contribution"] == 56.49, v
|
||
assert v["fixed_cost"] == 24.70, v
|
||
assert v["result"] == 31.79, v
|
||
# 元级留痕:逐项对到真实科目
|
||
assert r["raw_yuan"] == {"revenue": 2_634_900.0, "variable_cost": 2_070_000.0,
|
||
"contribution": 564_900.0, "fixed_cost": 247_000.0,
|
||
"result": 317_900.0}, r["raw_yuan"]
|
||
parts = {p["code"]: p for p in r["rows"]["variable_cost"]["parts"]}
|
||
assert parts["5401"]["by_subject"] == {"5401": 2_000_000.0}
|
||
assert parts["5403"]["by_subject"] == {"5403": 50_000.0}
|
||
assert parts["571103"]["by_subject"] == {"571103": 30_000.0}
|
||
# 减项:厂补(贷方)按口径相减
|
||
assert parts["530103"]["sign"] == "-" and parts["530103"]["total_yuan"] == 10_000.0
|
||
# 固定成本:5601% 前缀 + 净额
|
||
fparts = {p["code"]: p for p in r["rows"]["fixed_cost"]["parts"]}
|
||
assert fparts["5602%"]["match"] == "like"
|
||
assert fparts["5602%"]["total_yuan"] == 120_000.0 # 借 130,000 − 贷 10,000
|
||
assert fparts["5601%"]["total_yuan"] == 120_000.0
|
||
|
||
def test_e1_carry_forward_rows_excluded(self, db: Session):
|
||
seed_e1(db)
|
||
r = PL.compute_lines(db, 1, PERIOD, PERIOD)
|
||
# 结转腿(借 9,999,999,cf=1)不得进 5401
|
||
assert r["rows"]["variable_cost"]["parts"][0]["by_subject"] == {"5401": 2_000_000.0}
|
||
assert r["values"]["variable_cost"] == 207.00
|
||
|
||
def test_e2_lines_match_real_accounts_and_exclude_closing(self, db: Session):
|
||
seed_e2(db)
|
||
r = PL.compute_lines(db, 2, PERIOD, PERIOD)
|
||
assert r["available"] is True
|
||
v = r["values"]
|
||
assert v["revenue"] == 100.00, v
|
||
assert v["variable_cost"] == 82.00, v # 6401 800,000 + 6403 20,000(§4-2)
|
||
assert v["contribution"] == 18.00, v
|
||
assert v["fixed_cost"] == 9.80, v # §4-1 仅借方:60,000 + 30,000 + 8,000 = 98,000 元
|
||
assert v["result"] == 8.20, v
|
||
# §4-1:6603 贷方现金折扣收益**不进固定成本**,改为单列财务收益(2.00 万)
|
||
assert r["finance_income"]["value"] == 2.00, r["finance_income"]
|
||
assert r["raw_yuan"]["fixed_cost"] == 98_000.0, r["raw_yuan"]
|
||
# 结转腿(summary 含「结转」)不得计入 6602
|
||
fparts = {p["code"]: p for p in r["rows"]["fixed_cost"]["parts"]}
|
||
assert fparts["6602"]["total_yuan"] == 30_000.0, fparts["6602"]
|
||
assert fparts["6602"]["field"] == "debit" and fparts["6602"]["match"] == "eq"
|
||
assert r["caliber"]["exclude_closing"] is True
|
||
assert r["caliber"]["fixed_mode"] == "fixed_terms"
|
||
assert r["caliber"]["finance_income_terms"] == [["+", "6603", "credit"]]
|
||
|
||
|
||
# ── L3 按 entity 隔离 ──────────────────────────────────────────────────────
|
||
|
||
class TestEntityIsolation:
|
||
def test_e1_and_e2_read_own_subject_sets(self, db: Session):
|
||
seed_e1(db)
|
||
seed_e2(db)
|
||
r1 = PL.compute_lines(db, 1, PERIOD, PERIOD)
|
||
r2 = PL.compute_lines(db, 2, PERIOD, PERIOD)
|
||
|
||
# 收入基数不同:e1 只算 5001,e2 只算 6001(互不串账)
|
||
assert r1["raw_yuan"]["revenue"] == 2_634_900.0
|
||
assert r2["raw_yuan"]["revenue"] == 1_000_000.0
|
||
|
||
# 变动成本科目集不同:e1 含 5401+5403+571103−530103;e2 含 6401+6403(§4-2)
|
||
assert {p["code"] for p in r1["rows"]["variable_cost"]["parts"]} == {"5401", "5403", "571103", "530103"}
|
||
assert {p["code"] for p in r2["rows"]["variable_cost"]["parts"]} == {"6401", "6403"}
|
||
# e2 的 6401 不得被算进 e1
|
||
assert r1["rows"]["variable_cost"]["parts"][0]["by_subject"] == {"5401": 2_000_000.0}
|
||
|
||
# 固定成本科目集不同:e1=5601%/5602%/5603%(净额前缀);e2=6601/6602/6603(§4-1 仅借方带号项)
|
||
assert {p["code"] for p in r1["rows"]["fixed_cost"]["parts"]} == {"5601%", "5602%", "5603%"}
|
||
assert {p["code"] for p in r2["rows"]["fixed_cost"]["parts"]} == {"6601", "6602", "6603"}
|
||
assert r1["caliber"]["fixed_mode"] == "fixed_filters(net)"
|
||
assert r2["caliber"]["fixed_mode"] == "fixed_terms"
|
||
# 财务收益单列项只有 e2 有(e1 无 6603 贷方项)
|
||
assert r1["finance_income"]["value"] == 0.0
|
||
assert r2["finance_income"]["value"] == 2.00
|
||
|
||
# 口径快照不同(digest 异:口径表按账套而异)
|
||
assert r1["caliber"]["digest"] != r2["caliber"]["digest"]
|
||
assert r1["caliber"]["name"] == "陕西酣客" and r2["caliber"]["name"] == "博海"
|
||
|
||
def test_unregistered_entity_is_marked_missing_not_fabricated(self, db: Session):
|
||
"""未注册账套(entity=9)⇒ 如实标缺(None + missing),禁跨账套套用口径。"""
|
||
seed_e1(db)
|
||
r = PL.compute_lines(db, 9, PERIOD, PERIOD)
|
||
assert r["available"] is False
|
||
assert r["values"]["revenue"] is None and r["values"]["result"] is None
|
||
assert r["missing"] and "未在 ENTITY_CALIBERS 注册" in r["missing"][0]
|
||
|
||
def test_empty_period_is_marked_missing(self, db: Session):
|
||
"""该账套该期无明细行 ⇒ 如实标缺,而不是当成 0。"""
|
||
seed_e1(db, period="2026-07")
|
||
r = PL.compute_lines(db, 1, PERIOD, PERIOD)
|
||
assert r["available"] is False and r["values"]["revenue"] is None
|
||
assert "无凭证明细行" in r["missing"][0]
|
||
|
||
|
||
# ── 口径配置化(口径只有一处:ENTITY_CALIBERS)─────────────────────────────
|
||
|
||
class TestCaliberDriven:
|
||
def test_fixed_cost_terms_modes(self):
|
||
# e1/e2 现行形态 = fixed_filters(前缀 + 借−贷净额)派生
|
||
terms, mode = PL.fixed_cost_terms(ENTITY_CALIBERS[1])
|
||
assert mode == "fixed_filters(net)"
|
||
assert terms == [("+", "5601%", "net"), ("+", "5602%", "net"), ("+", "5603%", "net")]
|
||
# 口径升级为 fixed_terms(ASK-20261014-04 4-1 形态)时本层零改动跟随
|
||
terms2, mode2 = PL.fixed_cost_terms(dict(ENTITY_CALIBERS[2],
|
||
fixed_terms=[("+", "6601", "debit"), ("+", "6603", "debit")]))
|
||
assert mode2 == "fixed_terms"
|
||
assert terms2 == [("+", "6601", "debit"), ("+", "6603", "debit")]
|
||
|
||
def test_caliber_change_flows_through_without_touching_report(self, db: Session, monkeypatch):
|
||
"""口径改动只改 ENTITY_CALIBERS 一处 ⇒ 取数自动跟随(证明科目集未被写死在报表层)。"""
|
||
seed_e1(db)
|
||
base = PL.compute_lines(db, 1, PERIOD, PERIOD)["values"]["fixed_cost"]
|
||
assert base == 24.70
|
||
# 造一个「口径变更」:固定成本只认 5603%
|
||
changed = dict(ENTITY_CALIBERS[1], fixed_filters=[("like", "5603")])
|
||
monkeypatch.setitem(ENTITY_CALIBERS, 1, changed)
|
||
after = PL.compute_lines(db, 1, PERIOD, PERIOD)["values"]["fixed_cost"]
|
||
assert after == 0.70, after
|
||
# 变动成本同样跟随(口径里去掉 5403)
|
||
changed2 = dict(ENTITY_CALIBERS[1], varcost_terms=[("+", "5401", "debit")])
|
||
monkeypatch.setitem(ENTITY_CALIBERS, 1, changed2)
|
||
assert PL.compute_lines(db, 1, PERIOD, PERIOD)["values"]["variable_cost"] == 200.00
|
||
|
||
def test_aggregation_parity_with_dashboard_and_deriver(self, db: Session):
|
||
"""取数机制与既有读口/派生口同口径(同夹具同结果 ⇒ 无三套算法漂移)。"""
|
||
seed_e1(db)
|
||
from app.api.dashboard import _bep_sum_field
|
||
from scripts import kpi_bep_deriver as D
|
||
|
||
mine = PL.sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602"))
|
||
assert mine == _bep_sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602")) == \
|
||
D._sum_field(db, 1, PERIOD, PERIOD, "net", ("like", "5602")) == 120_000.0
|
||
assert PL.sum_field(db, 1, PERIOD, PERIOD, "credit_amount", ("eq", "5001")) == 2_634_900.0
|
||
|
||
|
||
# ── L2 勾稽自检 ───────────────────────────────────────────────────────────
|
||
|
||
class TestReconciliation:
|
||
def test_balanced_when_identity_holds(self):
|
||
r = PL.reconcile(100.0, 60.0, 25.0, 15.0)
|
||
assert r["balanced"] is True and r["diff"] == 0.0 and r["components_present"] is True
|
||
|
||
def test_unbalanced_is_flagged_not_silent(self):
|
||
"""负对照:末行被其它来源改写(如旧实现的「净利率×收入」)⇒ 必须显式不平。"""
|
||
r = PL.reconcile(100.0, 60.0, 25.0, 10.0) # 末行少 5 万
|
||
assert r["balanced"] is False
|
||
assert r["diff"] == 5.0 and r["lhs"] == 15.0 and r["rhs"] == 10.0
|
||
assert "勾稽不平" in r["note"]
|
||
|
||
def test_missing_components_not_silently_balanced(self):
|
||
r = PL.reconcile(100.0, None, 25.0, 15.0)
|
||
assert r["balanced"] is False and r["components_present"] is False
|
||
assert r["missing"] == ["variable_cost"] and r["diff"] is None
|
||
|
||
def test_rounding_tolerance_only(self):
|
||
"""2 位舍入抖动(≤0.02 万)不误报;真不平(0.5 万)必报。"""
|
||
assert PL.reconcile(100.01, 60.0, 25.0, 15.01)["balanced"] is True
|
||
assert PL.reconcile(100.0, 60.0, 25.0, 14.5)["balanced"] is False
|
||
|
||
|
||
# ── L2 无硬编码比例 / 无 KPI 比率倒推(源码级负对照)───────────────────────
|
||
|
||
def _fn_ast_source(module_file: str, fn_name: str) -> str:
|
||
"""取函数源码(剥离 docstring,保留代码)——供「无比例常量/无 KPI 比率」断言。"""
|
||
tree = ast.parse(open(module_file, encoding="utf-8").read())
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.FunctionDef) and node.name == fn_name:
|
||
body = node.body
|
||
if (body and isinstance(body[0], ast.Expr)
|
||
and isinstance(getattr(body[0], "value", None), ast.Constant)
|
||
and isinstance(body[0].value.value, str)):
|
||
body[0].value.value = "" # 剥离 docstring(口径说明可含科目码/比例字样)
|
||
return ast.unparse(node)
|
||
raise AssertionError(f"{fn_name} 未找到于 {module_file}")
|
||
|
||
|
||
def _string_literals(module_file: str):
|
||
"""模块内全部字符串字面量(剥离 docstring)——供「无写死科目码」断言。"""
|
||
tree = ast.parse(open(module_file, encoding="utf-8").read())
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
body = getattr(node, "body", None) or []
|
||
if (body and isinstance(body[0], ast.Expr)
|
||
and isinstance(getattr(body[0], "value", None), ast.Constant)
|
||
and isinstance(body[0].value.value, str)):
|
||
body[0].value.value = ""
|
||
return [n.value for n in ast.walk(tree)
|
||
if isinstance(n, ast.Constant) and isinstance(n.value, str)]
|
||
|
||
|
||
class TestNoHardcodedRatio:
|
||
def test_report_endpoint_has_no_ratio_or_kpi_derivation(self):
|
||
import app.api.reports as reports_mod
|
||
code = _fn_ast_source(reports_mod.__file__, "get_profit_summary")
|
||
for bad in ("0.5", "0.50", "* 0.5"):
|
||
assert bad not in code, f"硬编码比例残留: {bad}\n{code}"
|
||
for bad in ("F_PROFIT_RATE", "F_NET_PROFIT_RATE", "F_COST_RATIO", "F_REVENUE",
|
||
"KPIDefinition", "KPIValue"):
|
||
assert bad not in code, f"KPI 比率倒推残留: {bad}\n{code}"
|
||
# 标签与算法一致:末行标签来自单一常量(= 收入 − 变动 − 固定)
|
||
assert "BOTTOM_LABEL" in code and "reconcile(" in code
|
||
|
||
def test_reports_module_has_no_half_ratio(self):
|
||
import app.api.reports as reports_mod
|
||
src = open(reports_mod.__file__, encoding="utf-8").read()
|
||
assert "0.5" not in src, "reports.py 仍存在 0.5 字面量"
|
||
|
||
def test_take_data_module_has_no_ratio_and_no_subject_codes(self):
|
||
"""取数层:既无比例常量(含 0.5),也无写死的科目码(科目集只能来自 ENTITY_CALIBERS)。"""
|
||
src = open(PL.__file__, encoding="utf-8").read()
|
||
assert "0.5" not in src, "profit_lines.py 存在 0.5 字面量"
|
||
lits = _string_literals(PL.__file__)
|
||
offenders = [s for s in lits if re.fullmatch(r"\d{4,}", s or "")]
|
||
assert offenders == [], f"profit_lines.py 写死了科目码: {offenders}"
|
||
# 口径字段名必须来自 ENTITY_CALIBERS(无自建科目集)
|
||
assert "caliber_or_none" in src
|
||
for own_key in ("revenue_codes", "variable_cost_codes", "fixed_cost_codes"):
|
||
assert own_key not in src, f"自建科目集字段出现: {own_key}"
|
||
|
||
|
||
# ── 端点集成(HTTP)───────────────────────────────────────────────────────
|
||
|
||
BASE = "/api/cma/reports/profit-summary"
|
||
|
||
|
||
class TestEndpoint:
|
||
def test_reconciliation_label_unit_and_change_rate(self, client: TestClient, db: Session):
|
||
create_test_user(db)
|
||
token = get_token_for_user(client)
|
||
seed_e1(db)
|
||
seed_e1(db, period=PREV)
|
||
# 上期收入改为 100.00 万,便于验证环比
|
||
for row in db.query(VoucherDetail).filter(VoucherDetail.period == PREV,
|
||
VoucherDetail.subject_code == "5001").all():
|
||
row.credit_amount = 1_000_000.0
|
||
db.commit()
|
||
|
||
resp = client.get(f"{BASE}?period={PERIOD}", headers=auth_header(token))
|
||
assert resp.status_code == 200, resp.text
|
||
d = resp.json()
|
||
|
||
assert d["unit"] == "万元" and d["bottom_label"] == "营业利润"
|
||
assert d["available"] is True and d["missing"] == []
|
||
assert len(d["items"]) == 5
|
||
|
||
items = {i["name"]: i for i in d["items"]}
|
||
assert items["营业收入"]["value"] == 263.49
|
||
assert items["营业收入"]["prev_value"] == 100.00
|
||
assert items["营业收入"]["change_rate"] == 163.49
|
||
assert items["减:变动成本"]["value"] == 207.00
|
||
assert items["= 边际贡献"]["value"] == 56.49 and items["= 边际贡献"]["is_subtotal"] is True
|
||
assert items["减:固定成本"]["value"] == 24.70
|
||
# ③ 标签与算法一致:末行名 = 常量;值 = 收入 − 变动 − 固定;无「息税前利润」标签残留
|
||
assert f"= {d['bottom_label']}" in items
|
||
assert "= 息税前利润" not in items
|
||
bottom = items[f"= {d['bottom_label']}"]
|
||
assert bottom["is_total"] is True
|
||
assert bottom["value"] == 31.79
|
||
assert bottom["value"] == round(d["reconciliation"]["lhs"], 2)
|
||
assert "不标「净利润」" in bottom["label_basis"]
|
||
|
||
# ④ 勾稽自检:平衡 + 明细在场
|
||
rec = d["reconciliation"]
|
||
assert rec["balanced"] is True and rec["diff"] == 0.0
|
||
assert rec["formula"] == "营业收入 − 变动成本 − 固定成本 = 末行"
|
||
|
||
# L1 逐行可对到真实科目(响应体自带 parts/by_subject 取数留痕)
|
||
vparts = items["减:变动成本"]["terms"]
|
||
assert {p["code"] for p in vparts} == {"5401", "5403", "571103", "530103"}
|
||
assert d["caliber"]["digest"] and d["caliber"]["entity_id"] == 1
|
||
assert sum(p["by_subject"].get("5401", 0) for p in vparts) == 2_000_000.0
|
||
|
||
def test_no_kpi_ratio_fallback_when_no_ledger_rows(self, client: TestClient, db: Session):
|
||
"""负对照:只塞 KPI(旧实现的输入)而账上无行 ⇒ 各行必须标缺,不得按比率倒推。"""
|
||
create_test_user(db)
|
||
token = get_token_for_user(client)
|
||
for code, val in (("F_REVENUE", 100.0), ("F_PROFIT_RATE", 20.0),
|
||
("F_NET_PROFIT_RATE", 10.0), ("F_COST_RATIO", 60.0)):
|
||
k = KPIDefinition(kpi_code=code, kpi_name=code, dimension="finance",
|
||
target_value=1.0, unit="万元", status="active",
|
||
frequency="monthly", entity_id=1)
|
||
db.add(k)
|
||
db.flush()
|
||
db.add(KPIValue(kpi_id=k.id, period=PERIOD, actual_value=val,
|
||
source_type="test", source_batch="unit-test",
|
||
data_status="verified", entity_id=1))
|
||
db.commit()
|
||
|
||
resp = client.get(f"{BASE}?period={PERIOD}", headers=auth_header(token))
|
||
assert resp.status_code == 200
|
||
d = resp.json()
|
||
assert d["available"] is False and "无凭证明细行" in d["missing"][0]
|
||
assert [i["value"] for i in d["items"]] == [None] * 5
|
||
assert d["reconciliation"]["balanced"] is False # 缺数显式不平,不静默
|
||
assert d["reconciliation"]["components_present"] is False
|
||
|
||
def test_entity2_endpoint_isolated_from_entity1_rows(self, client: TestClient, db: Session):
|
||
"""e2 端点只读 e2 科目集(token 走 entity1 ⇒ 仍读到 e1;helper 层 e2 值不受 e1 行影响)。"""
|
||
create_test_user(db)
|
||
token = get_token_for_user(client)
|
||
seed_e1(db)
|
||
seed_e2(db)
|
||
r2 = PL.compute_lines(db, 2, PERIOD, PERIOD)
|
||
assert r2["values"]["revenue"] == 100.00 and r2["values"]["fixed_cost"] == 9.80
|
||
# entity1 的 token ⇒ entity1 账套(e2 行不进入)
|
||
d = client.get(f"{BASE}?period={PERIOD}", headers=auth_header(token)).json()
|
||
assert d["entity_id"] == 1
|
||
items = {i["name"]: i for i in d["items"]}
|
||
assert items["营业收入"]["value"] == 263.49
|
||
assert {p["code"] for p in items["减:固定成本"]["terms"]} == {"5601%", "5602%", "5603%"}
|
||
|
||
|
||
def test_prev_period_helper():
|
||
assert PL.prev_period_of("2026-01") == "2025-12"
|
||
assert PL.prev_period_of("2026-10") == "2026-09"
|