diff --git a/finance/exporting.py b/finance/exporting.py index c22c165..cfd411b 100644 --- a/finance/exporting.py +++ b/finance/exporting.py @@ -21,6 +21,7 @@ from .formula_columns import ( RESERVED_DATA_HEADERS, ensure_available_metrics, metrics_used, + to_decimal, to_excel, validate_column, ) @@ -30,6 +31,23 @@ FORMULA_VERSION = "3" MAX_ROWS = 100_000 MAX_GROUPS = 200 +# 对账比较规则:人数严格相等;金额 0.01;比率 0.0001(一个基点)。 +# 容差属财务口径,调整需财务负责人批准后方可修改。 +RECONCILE_TOLERANCES = { + "customers": Decimal("0"), + "revenue": Decimal("0.01"), + "cost": Decimal("0.01"), + "gross": Decimal("0.01"), + "self_revenue": Decimal("0.01"), + "basket": Decimal("0.0001"), + "margin": Decimal("0.0001"), + "discount_rate": Decimal("0.0001"), + "upgrade_rate": Decimal("0.0001"), + "list_basket": Decimal("0.0001"), + "roi": Decimal("0.0001"), +} +CUSTOM_COLUMN_TOLERANCE = Decimal("0.01") + logger = logging.getLogger(__name__) @@ -476,6 +494,12 @@ def build_formula_workbook(ds, member, export_context, output): ], ["重算范围", f"最多 {MAX_ROWS:,} 行、{MAX_GROUPS} 组", "超限需缩小导出范围", "否"], ["兼容性", "SUMIFS/COUNTIFS", "移除动态数组人数公式;仍需目标 Excel 桌面版本验收", "否"], + [ + "对账容差", + "人数严格相等;金额 0.01;比率 0.0001", + "对账检查页按指标类型使用独立容差;自定义列按金额容差 0.01。容差调整需财务负责人批准", + "否", + ], ]: parameters.append(row) for d in dims: @@ -650,11 +674,18 @@ def build_formula_workbook(ds, member, export_context, output): reconcile.append( _styled_row( reconcile, - ["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "状态"], + ["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "容差", "状态"], green, white, ) ) + + def _reconcile_status(row_number): + return ( + f'=IF(D{row_number}="","无参考",IF(ISNUMBER(F{row_number}),' + f'IF(ABS(F{row_number})<=G{row_number},"通过","待复核"),"待复核"))' + ) + reconcile_row = 2 for label in labels: for side_name, periods, col in (("本期", ctx["current"], "C"), ("基期", ctx["baseline"], "D")): @@ -671,21 +702,25 @@ def build_formula_workbook(ds, member, export_context, output): "" if value is None else str(value), f"='分析汇总'!{col}{formula_row}", f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")', - f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<0.01,"通过","待复核"),"待复核"))', + float(RECONCILE_TOLERANCES[key]), + _reconcile_status(reconcile_row), ] ) reconcile_row += 1 for index, definition in enumerate(custom_columns): formula_row = metric_rows[(label, f"custom_{index}")] + # 自定义列用 Decimal 白名单解释器计算服务器参考,不再依赖业务人工验收 + reference_value = to_decimal(definition["expression"], reference) reconcile.append( [ _text_cell(reconcile, definition["name"]), _text_cell(reconcile, label), side_name, - "自定义公式无服务器参考", + "" if reference_value is None else str(reference_value), f"='分析汇总'!{col}{formula_row}", - "", - "需业务验收", + f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")', + float(CUSTOM_COLUMN_TOLERANCE), + _reconcile_status(reconcile_row), ] ) reconcile_row += 1 diff --git a/finance/formula_columns.py b/finance/formula_columns.py index 9981ceb..72e22b2 100644 --- a/finance/formula_columns.py +++ b/finance/formula_columns.py @@ -1,4 +1,5 @@ import math +from decimal import Decimal from .analysis import METRICS @@ -107,3 +108,27 @@ def to_excel(node, references): if "number" in node: return str(node["number"]) return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})" + + +def to_decimal(node, values): + """按已校验的白名单表达式树用 Decimal 计算服务器参考值。 + + 只遍历 metric/number/四则运算节点,不执行任何代码;依赖指标缺失或除零时 + 返回 None,与 Excel 侧 #N/A 的语义一致。 + """ + if "metric" in node: + return values[node["metric"]] + if "number" in node: + return Decimal(str(node["number"])) + left, right = to_decimal(node["left"], values), to_decimal(node["right"], values) + if left is None or right is None: + return None + if node["operator"] == "add": + return left + right + if node["operator"] == "subtract": + return left - right + if node["operator"] == "multiply": + return left * right + if right == 0: + return None + return left / right diff --git a/tests/test_formula_reconcile_guard.py b/tests/test_formula_reconcile_guard.py index a4b9131..da6b6f4 100644 --- a/tests/test_formula_reconcile_guard.py +++ b/tests/test_formula_reconcile_guard.py @@ -20,11 +20,12 @@ def test_reconcile_status_guards_non_numeric_difference(workspace): output, ) workbook = load_workbook(BytesIO(output.getvalue()), data_only=False) + status_cells = [row[7] for row in workbook["对账检查"].iter_rows(min_row=2)] formulas = [ - row[6].value - for row in workbook["对账检查"].iter_rows(min_row=2) - if isinstance(row[6].value, str) and row[6].value.startswith("=IF(D") + cell.value for cell in status_cells if isinstance(cell.value, str) and cell.value.startswith("=IF(D") ] assert formulas assert all("ISNUMBER(F" in formula for formula in formulas) assert all('"通过"' in formula for formula in formulas) + # 容差列独立成列,状态按容差比较而不是统一 0.01 + assert all(row[6].value is not None for row in workbook["对账检查"].iter_rows(min_row=2)) diff --git a/tests/test_formula_reconcile_tolerances.py b/tests/test_formula_reconcile_tolerances.py new file mode 100644 index 0000000..e7f4240 --- /dev/null +++ b/tests/test_formula_reconcile_tolerances.py @@ -0,0 +1,215 @@ +import os +import shutil +import subprocess +from decimal import Decimal +from io import BytesIO + +import pytest +from openpyxl import load_workbook + +from finance.exporting import CUSTOM_COLUMN_TOLERANCE, RECONCILE_TOLERANCES, build_formula_workbook +from finance.formula_columns import to_decimal + +pytestmark = pytest.mark.django_db + + +def custom_column(name="收入成本差"): + return { + "name": name, + "meaning": "实际收入减成本", + "scope": "按所选分组及本期、基期计算", + "missing": "结果不可用(#N/A);零分母不可用", + "expression": { + "operator": "subtract", + "left": {"metric": "revenue"}, + "right": {"metric": "cost"}, + }, + } + + +def test_to_decimal_evaluator_golden_samples(): + values = { + "revenue": Decimal("999999999999.99"), + "cost": Decimal("0.0000001"), + "customers": Decimal("7"), + } + # 大数与多层表达式:((收入 - 收入) + 成本×2) ÷ (成本×2) = 1,抵消后不丢失精度 + cancellation = { + "operator": "divide", + "left": { + "operator": "add", + "left": { + "operator": "subtract", + "left": {"metric": "revenue"}, + "right": {"metric": "revenue"}, + }, + "right": {"operator": "multiply", "left": {"metric": "cost"}, "right": {"number": 2}}, + }, + "right": {"operator": "multiply", "left": {"metric": "cost"}, "right": {"number": 2}}, + } + assert to_decimal(cancellation, values) == Decimal("1") + # 极小值与比率 + assert to_decimal({"metric": "cost"}, values) == Decimal("0.0000001") + assert to_decimal( + {"operator": "divide", "left": {"metric": "revenue"}, "right": {"metric": "customers"}}, values + ) == Decimal("999999999999.99") / Decimal("7") + # 除零与缺失指标返回 None(对应 Excel #N/A) + assert to_decimal({"operator": "divide", "left": {"metric": "revenue"}, "right": {"number": 0}}, values) is None + assert to_decimal({"operator": "add", "left": {"metric": "cost"}, "right": {"metric": "customers"}}, {**values, "customers": None}) is None + # 负数与常数组合 + assert to_decimal({"operator": "subtract", "left": {"number": -2.5}, "right": {"metric": "cost"}}, values) == Decimal("-2.5") - Decimal("0.0000001") + + +def test_reconcile_uses_independent_tolerances_per_metric_kind(workspace): + ds, member, *_ = workspace + output = BytesIO() + build_formula_workbook( + ds, + member, + {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"}, + output, + ) + workbook = load_workbook(BytesIO(output.getvalue()), data_only=False) + sheet = workbook["对账检查"] + rows = list(sheet.iter_rows(values_only=True)) + assert list(rows[0]) == ["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "容差", "状态"] + + def find(metric, side="本期"): + return next(row for row in rows[1:] if row[0] == metric and row[2] == side) + + assert find("消费人数")[6] == 0 # 人数严格相等 + assert find("实际收入")[6] == pytest.approx(0.01) + assert find("毛利贡献")[6] == pytest.approx(0.01) + assert find("毛利率")[6] == pytest.approx(0.0001) + assert find("ROI")[6] == pytest.approx(0.0001) + assert find("客单价")[6] == pytest.approx(0.0001) + # 状态公式按容差列比较(<=G),不再统一 <0.01 + status = next(cell.value for cell in sheet[2] if isinstance(cell.value, str) and cell.value.startswith("=IF(D")) + assert "<=G2" in status + # 参数页写明对账规则,容差调整需财务批准 + parameters = {str(row[0]): row for row in workbook["参数与口径"].iter_rows(values_only=True) if row[0]} + assert "人数严格相等" in parameters["对账容差"][1] + assert parameters["对账容差"][3] == "否" + + +def test_custom_columns_reconcile_against_decimal_reference(workspace): + ds, member, *_ = workspace + output = BytesIO() + build_formula_workbook( + ds, + member, + { + "start": "2026-08", + "end": "2026-08", + "comparison": "yoy", + "group": "department", + "custom_columns": [custom_column()], + }, + output, + ) + workbook = load_workbook(BytesIO(output.getvalue()), data_only=False) + rows = list(workbook["对账检查"].iter_rows(values_only=True)) + custom_rows = [row for row in rows[1:] if row[0] == "收入成本差"] + assert custom_rows + pediatrics = next(row for row in custom_rows if row[1] == "儿科" and row[2] == "本期") + assert Decimal(pediatrics[3]) == Decimal("60") # 100 - 40 + assert pediatrics[6] == pytest.approx(float(CUSTOM_COLUMN_TOLERANCE)) + status = next( + row[7] + for row in workbook["对账检查"].iter_rows(min_row=2, values_only=True) + if row[0] == "收入成本差" and row[1] == "儿科" and row[2] == "本期" + ) + assert "<=G" in status and "通过" in status + # 旧的占位文案不再出现 + assert all("自定义公式无服务器参考" not in str(row[3]) for row in custom_rows) + assert all(row[7] != "需业务验收" for row in custom_rows) + + +def test_custom_column_reference_missing_when_dependency_unavailable(workspace): + from finance.models import Fact + + ds, member, *_ = workspace + # 外科 2026-08 成本置空 → cost 不可用 → 收入成本差参考为 None → 无参考 + Fact.objects.filter(dataset=ds, period="2026-08", dimensions__department="外科").update(cost=None) + output = BytesIO() + build_formula_workbook( + ds, + member, + { + "start": "2026-08", + "end": "2026-08", + "comparison": "yoy", + "group": "department", + "custom_columns": [custom_column()], + }, + output, + ) + workbook = load_workbook(BytesIO(output.getvalue()), data_only=False) + surgery = next( + row + for row in workbook["对账检查"].iter_rows(min_row=2, values_only=True) + if row[0] == "收入成本差" and row[1] == "外科" and row[2] == "本期" + ) + assert surgery[3] in ("", None) # 无服务器参考 + assert "无参考" in surgery[7] + + +def test_reconcile_tolerances_cover_every_metric(): + from finance.analysis import METRICS + + assert set(RECONCILE_TOLERANCES) == set(METRICS) + assert RECONCILE_TOLERANCES["customers"] == 0 + assert all( + tolerance in (Decimal("0.01"), Decimal("0.0001")) + for key, tolerance in RECONCILE_TOLERANCES.items() + if key != "customers" + ) + + +def test_reconcile_passes_in_libreoffice_with_per_metric_tolerances(workspace, tmp_path): + executable = os.environ.get("SOFFICE_BIN") or shutil.which("soffice") + if not executable: + pytest.skip("set SOFFICE_BIN to run the LibreOffice recalculation check") + ds, member, *_ = workspace + source, output = tmp_path / "source", tmp_path / "out" + source.mkdir() + output.mkdir() + workbook_path = source / "reconcile.xlsx" + with workbook_path.open("wb") as file: + build_formula_workbook( + ds, + member, + { + "start": "2026-08", + "end": "2026-08", + "comparison": "yoy", + "group": "department", + "custom_columns": [custom_column()], + }, + file, + ) + profile = tmp_path / "profile" + result = subprocess.run( + [ + executable, + "--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) + statuses = [ + (row[0], row[1], row[2], row[7]) + for row in values["对账检查"].iter_rows(min_row=2, values_only=True) + ] + assert statuses + assert all(status in ("通过", "无参考") for *_head, status in statuses), statuses