From 9962573379b8e873f548e1f387e08a41b4cd07e6 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sat, 26 Sep 2026 01:39:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(workbook):=20F06=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E4=B8=BA=E5=8F=AA=E8=AF=BB=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=EF=BC=8C=E8=AF=B4=E6=98=8E=E9=A1=B5=E8=A1=A5=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E4=B8=8E=E7=BB=B4=E5=BA=A6=E5=AF=B9=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - “产品毛利率”参数行不再标记可编辑:说明页写明它是导出时的只读快照, 各行毛利率已写入计算明细 MarginRate 列,调整需在计算明细页按行修改 - 参数页新增完整维度键-显示名对照行(此前说明页提到“本页分组维度”但无字典) - 使用说明新增输入字典:Revenue/Cost/GrossInput/MarginRate/ListAmount 逐项列出 单位、来源字段、修改位置与缺失含义;新增固定结构边界行(哪些列/页不可改、 改结构需重新导出) - 测试:参数页全部“否”、每个可修改输入出现在计算明细表头并被汇总公式引用、 维度对照完整;LibreOffice 用例验证修改 MarginRate 后 lookup 毛利重算(本机跳过) --- finance/exporting.py | 54 +++++++++- tests/test_formula_columns.py | 9 +- tests/test_formula_workbook_docs.py | 155 ++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 tests/test_formula_workbook_docs.py diff --git a/finance/exporting.py b/finance/exporting.py index 29cc4d1..c22c165 100644 --- a/finance/exporting.py +++ b/finance/exporting.py @@ -462,7 +462,12 @@ def build_formula_workbook(ds, member, export_context, output): ], ["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"], ["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"], - ["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"], + [ + "产品毛利率(快照)", + _text_cell(parameters, str(rates)), + "只读快照;导出时已按行写入计算明细 MarginRate 列,调整毛利率请在计算明细页按行修改", + "否", + ], [ "Excel 数值精度", "IEEE-754 双精度", @@ -473,6 +478,15 @@ def build_formula_workbook(ds, member, export_context, output): ["兼容性", "SUMIFS/COUNTIFS", "移除动态数组人数公式;仍需目标 Excel 桌面版本验收", "否"], ]: parameters.append(row) + for d in dims: + parameters.append( + [ + _text_cell(parameters, f"维度 {d['key']}"), + _text_cell(parameters, d["name"]), + "维度键与显示名对照(只读);计算明细与原始证据的列名即维度键", + "否", + ] + ) for definition in custom_columns: parameters.append( [ @@ -483,10 +497,27 @@ def build_formula_workbook(ds, member, export_context, output): ] ) instructions.append(_styled_row(instructions, ["主题", "说明"], green, white)) + + def source_name(role): + return next((c["name"] for c in mapping["columns"] if c["role"] == role), "未映射") + + editable_inputs = [ + ("Revenue", "实际收入", "金额", source_name("revenue"), "空值行不参与求和,汇总按现有行计算"), + ("Cost", "成本", "金额", source_name("cost"), "任一行缺失时本组成本及 ROI 等依赖指标不可用"), + ("GrossInput", "直接毛利", "金额", source_name("gross"), "空值行改按产品毛利率(lookup)计算毛利"), + ( + "MarginRate", + "产品毛利率", + "小数比率", + "导入确认的产品毛利率表", + "仅 GrossSource=lookup 的行参与毛利计算;0.2 表示 20%", + ), + ("ListAmount", "原价金额", "金额", source_name("list_amount"), "用于扣率与原价客单价;缺失则相关指标不可用"), + ] for row in [ ["用途", "公式分析工作簿;本地修改不回写系统。"], ["行粒度", "计算明细每行对应一个 Fact,不等同患者就诊数。"], - ["维度列", "列名为数据导入映射中的维度键;显示名称见本页分组维度。"], + ["维度列", "列名为数据导入映射中的维度键;键与显示名对照见“参数与口径”页的维度行。"], ["毛利", "直接毛利优先,否则收入×产品毛利率;无法计算则不可用。毛利贡献不代表净利润。"], ["ROI", "按选定口径;缺成本、成本非正或所需指标缺失时不可用。"], ["人数", "客户 token 跨行去重;缺客户编码时不可用。"], @@ -497,6 +528,25 @@ def build_formula_workbook(ds, member, export_context, output): ["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"], ["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"], ["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"], + [ + "输入字典", + "以下输入可在“计算明细”页直接修改,全部汇总公式与对账实时引用;字段、单位、来源与缺失含义逐项列出。", + ], + *[ + [ + _text_cell(instructions, f"输入 {header}({label})"), + _text_cell( + instructions, + f"单位:{unit};来源:{source};修改位置:计算明细页 {header} 列;缺失含义:{missing}。", + ), + ] + for header, label, unit, source, missing in editable_inputs + ], + [ + "固定结构", + "Period、维度列、CustomerToken、GrossSource、SelfOperated、CustomerPresent、RowCount 与“人数去重”页均为固定快照;" + "修改或增删这些列/行会使汇总、去重与对账失真,需要改变结构时请重新导出。", + ], ]: instructions.append(row) for definition in custom_columns: diff --git a/tests/test_formula_columns.py b/tests/test_formula_columns.py index c07fbaf..4192a9f 100644 --- a/tests/test_formula_columns.py +++ b/tests/test_formula_columns.py @@ -97,8 +97,13 @@ def test_formula_workbook_exposes_custom_metric_as_recalculable_column(formula_d custom = workbook["自定义指标"] assert custom["B1"].value == "收入成本差(本期)" assert custom["B2"].value == f"='分析汇总'!C{metric_row}" - assert "含义:实际收入减成本" in workbook["使用说明"]["B12"].value - assert "范围:按所选分组及本期、基期计算" in workbook["使用说明"]["B12"].value + custom_note = next( + str(row[1]) + for row in workbook["使用说明"].iter_rows(values_only=True) + if row[0] == "收入成本差" + ) + assert "含义:实际收入减成本" in custom_note + assert "范围:按所选分组及本期、基期计算" in custom_note def test_custom_expression_rejects_formula_text_and_unknown_metrics(): diff --git a/tests/test_formula_workbook_docs.py b/tests/test_formula_workbook_docs.py new file mode 100644 index 0000000..9542b12 --- /dev/null +++ b/tests/test_formula_workbook_docs.py @@ -0,0 +1,155 @@ +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)