Files
cma-management/backend/tests/test_profit_summary_real_caliber_20261014.py
T
Hermes FullstackandHermes CI Fix a52faa90a6
ci/woodpecker/push/main Pipeline was successful
fix(dashboard): bep-card terms 分支补字段别名映射(修 289cc8f 引入的 e2 500)
289cc8f(§4-1 博海固定成本仅借方)在 dashboard._bep_sum_field 新增 terms 分支,
把 bep_caliber 的 terms 记法字段名(debit/credit)原样传给 getattr(VoucherDetail, field)
⇒ AttributeError: type object 'VoucherDetail' has no attribute 'debit'
⇒ GET /api/cma/dashboard/bep-card(entity=2 博海)稳定 500(entity=1 走 filters 分支不受影响)。

修复:terms 分支内做字段名映射 debit→debit_amount / credit→credit_amount(与
profit_lines.FIELD_MAP、kpi_bep_deriver._TERM_FIELD 同式),再递归聚合。

回归测试:tests/test_profit_summary_real_caliber_20261014.py 新增
test_dashboard_card_e2_terms_mode_field_alias(修复前 RED:复现同一 AttributeError;
修复后 GREEN:e2 固定成本 98,000 元 / 卡面 9.80 万)。
2026-10-16 18:28:50 +08:00

433 lines
24 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.
"""管理利润表「真实科目取数」重建单测 — 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
def test_dashboard_card_e2_terms_mode_field_alias(self, db: Session):
"""289cc8f 回归(P0):e2 固定成本走 terms(仅借方)时,`dashboard._bep_sum_field`
必须把 terms 记法字段名(debit/credit)映射到模型列名(debit_amount/credit_amount);
否则 `getattr(VoucherDetail, 'debit')` AttributeError ⇒ `GET /dashboard/bep-card` 500。
"""
seed_e2(db)
from app.api.dashboard import _bep_sum_field, _bep_inputs
from app.utils.bep_caliber import fixed_cost_spec
cal2 = ENTITY_CALIBERS[2]
mode, spec = fixed_cost_spec(cal2)
assert mode == "terms", spec
raw = _bep_sum_field(db, 2, PERIOD, PERIOD, "net", terms=spec,
exclude_closing=cal2["exclude_closing"])
# §4-1 仅借方:6601 60,000 + 6602 30,000 + 6603 8,000 = 98,000 元
# (6602 贷 5,000 / 6603 贷 20,000 不进固定;summary 含「结转」腿被剔除)
assert raw == 98_000.0, raw
inp = _bep_inputs(db, 2, cal2, PERIOD, PERIOD, 1)
assert inp["sales_avg"] == 100.00, inp
assert inp["admin_avg"] == 9.80, inp
# ── 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"