- “产品毛利率”参数行不再标记可编辑:说明页写明它是导出时的只读快照, 各行毛利率已写入计算明细 MarginRate 列,调整需在计算明细页按行修改 - 参数页新增完整维度键-显示名对照行(此前说明页提到“本页分组维度”但无字典) - 使用说明新增输入字典:Revenue/Cost/GrossInput/MarginRate/ListAmount 逐项列出 单位、来源字段、修改位置与缺失含义;新增固定结构边界行(哪些列/页不可改、 改结构需重新导出) - 测试:参数页全部“否”、每个可修改输入出现在计算明细表头并被汇总公式引用、 维度对照完整;LibreOffice 用例验证修改 MarginRate 后 lookup 毛利重算(本机跳过)
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
from io import BytesIO
|
||
|
||
import pytest
|
||
from openpyxl import load_workbook
|
||
|
||
from finance.exporting import build_formula_workbook
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
|
||
EDITABLE_INPUT_HEADERS = ["Revenue", "Cost", "GrossInput", "MarginRate", "ListAmount"]
|
||
|
||
|
||
def _workbook(workspace, **extra):
|
||
ds, member, *_ = workspace
|
||
output = BytesIO()
|
||
build_formula_workbook(
|
||
ds,
|
||
member,
|
||
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department", **extra},
|
||
output,
|
||
)
|
||
return load_workbook(BytesIO(output.getvalue()), data_only=False)
|
||
|
||
|
||
def test_parameter_sheet_is_read_only_snapshot(workspace):
|
||
workbook = _workbook(workspace)
|
||
rows = list(workbook["参数与口径"].iter_rows(values_only=True))
|
||
header = list(rows[0])
|
||
editable = header.index("可编辑")
|
||
assert all(row[editable] == "否" for row in rows[1:] if row[0])
|
||
|
||
margin = next(row for row in rows if row[0] and "产品毛利率" in str(row[0]))
|
||
assert "只读" in margin[2]
|
||
assert "MarginRate" in margin[2]
|
||
assert "计算明细" in margin[2]
|
||
|
||
# 维度键与显示名完整对照
|
||
texts = [str(row[0]) for row in rows if row[0]]
|
||
assert "维度 department" in texts and "维度 product" in texts
|
||
department = next(row for row in rows if row[0] == "维度 department")
|
||
assert department[1] == "科室"
|
||
product = next(row for row in rows if row[0] == "维度 product")
|
||
assert product[1] == "产品一级分类"
|
||
|
||
|
||
def test_instructions_document_editable_inputs_with_real_dependencies(workspace):
|
||
workbook = _workbook(workspace)
|
||
instructions = {
|
||
str(row[0]): str(row[1]) for row in workbook["使用说明"].iter_rows(values_only=True) if row[0]
|
||
}
|
||
|
||
data_headers = [cell.value for cell in workbook["计算明细"][1]]
|
||
sources = {
|
||
"Revenue": "实际收入",
|
||
"Cost": "成本",
|
||
"GrossInput": "毛利贡献",
|
||
"MarginRate": "产品毛利率表",
|
||
"ListAmount": "应收金额",
|
||
}
|
||
for header in EDITABLE_INPUT_HEADERS:
|
||
key = next(k for k in instructions if k.startswith(f"输入 {header}("))
|
||
text = instructions[key]
|
||
assert f"计算明细页 {header} 列" in text
|
||
assert sources[header] in text
|
||
assert header in data_headers
|
||
|
||
# 每个可修改输入都被汇总公式真实引用
|
||
summary_formula_cells = [
|
||
cell.value
|
||
for row in workbook["分析汇总"].iter_rows(min_row=6)
|
||
for cell in row
|
||
if isinstance(cell.value, str) and cell.value.startswith("=IF")
|
||
]
|
||
joined = "\n".join(summary_formula_cells)
|
||
for header in ("Revenue", "Cost", "ListAmount"):
|
||
assert f"Data[{header}]" in joined
|
||
detail_formula = next(
|
||
cell.value
|
||
for row in workbook["计算明细"].iter_rows(min_row=2)
|
||
for cell in row
|
||
if isinstance(cell.value, str) and cell.value.startswith("=IF(")
|
||
)
|
||
assert "GrossInput" not in detail_formula # 列名按相对引用参与 GrossCalc
|
||
assert detail_formula.startswith("=IF(")
|
||
|
||
boundary = instructions["固定结构"]
|
||
assert "人数去重" in boundary and "固定快照" in boundary
|
||
assert "重新导出" in boundary
|
||
assert "参数与口径" in instructions["维度列"]
|
||
|
||
|
||
def test_margin_rate_lookup_edit_changes_gross_via_detail_recalculation(workspace, tmp_path):
|
||
import os
|
||
import shutil
|
||
|
||
from finance.models import Fact
|
||
|
||
if not (os.environ.get("SOFFICE_BIN") or shutil.which("soffice")):
|
||
pytest.skip("set SOFFICE_BIN to run the LibreOffice recalculation check")
|
||
|
||
ds, member, *_ = workspace
|
||
# 去掉直接毛利,使毛利全部走 lookup 毛利率
|
||
Fact.objects.filter(dataset=ds).update(gross=None)
|
||
mapping = {**ds.mapping, "margin_rates": {"医疗服务": "0.3"}}
|
||
ds.mapping = mapping
|
||
ds.save(update_fields=["mapping"])
|
||
|
||
source, output = tmp_path / "source", tmp_path / "out"
|
||
source.mkdir()
|
||
output.mkdir()
|
||
workbook_path = source / "lookup.xlsx"
|
||
with workbook_path.open("wb") as file:
|
||
build_formula_workbook(
|
||
ds,
|
||
member,
|
||
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||
file,
|
||
)
|
||
|
||
editable = load_workbook(workbook_path)
|
||
# MarginRate 列在维度列之后:G 列(期间+2维度=3 列后第 4 个保留列)
|
||
header_row = [cell.value for cell in editable["计算明细"][1]]
|
||
rate_column = header_row.index("MarginRate") + 1
|
||
editable["计算明细"].cell(row=2, column=rate_column).value = 0.5
|
||
editable.save(workbook_path)
|
||
|
||
import subprocess
|
||
|
||
profile = tmp_path / "profile"
|
||
result = subprocess.run(
|
||
[
|
||
os.environ.get("SOFFICE_BIN") or shutil.which("soffice"),
|
||
"--headless",
|
||
f"-env:UserInstallation={profile.as_uri()}",
|
||
"--convert-to",
|
||
"xlsx",
|
||
"--outdir",
|
||
str(output),
|
||
str(workbook_path),
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120,
|
||
check=False,
|
||
)
|
||
assert result.returncode == 0, result.stderr or result.stdout
|
||
values = load_workbook(output / workbook_path.name, data_only=True)
|
||
summary = values["分析汇总"]
|
||
gross_row = next(
|
||
row
|
||
for row in range(6, summary.max_row + 1)
|
||
if summary.cell(row, 1).value == "毛利贡献" and summary.cell(row, 2).value == "儿科"
|
||
)
|
||
# 2026-08 儿科收入 100,lookup 毛利率改为 0.5 后毛利应为 50
|
||
assert summary.cell(gross_row, 3).value == pytest.approx(50)
|