fix(reconcile): F07 对账按指标类型使用独立容差,自定义列改为 Decimal 参考对账

- 对账检查页新增容差列:人数严格相等(容差 0)、金额 0.01、比率 0.0001,
  状态公式改为 ABS(差异)<=容差,不再统一使用 0.01
- 自定义列不再标注“需业务验收/无服务器参考”:新增 to_decimal 白名单表达式
  解释器(仅遍历 metric/number/四则节点,不执行代码)在服务器端用 Decimal
  计算参考值,缺失指标或除零返回 None(对应 #N/A → 无参考)
- 参数页写明对账规则与“容差调整需财务负责人批准”
- 测试:求值器黄金样例(大数/抵消/极小值/除零/缺失/多层表达式)、
  分指标容差列、自定义列 Decimal 参考与缺失场景、全指标覆盖检查;
  LibreOffice 用例验证全部对账行重算后为“通过/无参考”(本机跳过)
This commit is contained in:
Jayway Liu
2026-09-26 01:42:03 +08:00
parent 9962573379
commit 649fec4121
4 changed files with 284 additions and 8 deletions
+40 -5
View File
@@ -21,6 +21,7 @@ from .formula_columns import (
RESERVED_DATA_HEADERS, RESERVED_DATA_HEADERS,
ensure_available_metrics, ensure_available_metrics,
metrics_used, metrics_used,
to_decimal,
to_excel, to_excel,
validate_column, validate_column,
) )
@@ -30,6 +31,23 @@ FORMULA_VERSION = "3"
MAX_ROWS = 100_000 MAX_ROWS = 100_000
MAX_GROUPS = 200 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__) logger = logging.getLogger(__name__)
@@ -476,6 +494,12 @@ def build_formula_workbook(ds, member, export_context, output):
], ],
["重算范围", f"最多 {MAX_ROWS:,} 行、{MAX_GROUPS} 组", "超限需缩小导出范围", "否"], ["重算范围", f"最多 {MAX_ROWS:,} 行、{MAX_GROUPS} 组", "超限需缩小导出范围", "否"],
["兼容性", "SUMIFS/COUNTIFS", "移除动态数组人数公式;仍需目标 Excel 桌面版本验收", "否"], ["兼容性", "SUMIFS/COUNTIFS", "移除动态数组人数公式;仍需目标 Excel 桌面版本验收", "否"],
[
"对账容差",
"人数严格相等;金额 0.01;比率 0.0001",
"对账检查页按指标类型使用独立容差;自定义列按金额容差 0.01。容差调整需财务负责人批准",
"否",
],
]: ]:
parameters.append(row) parameters.append(row)
for d in dims: for d in dims:
@@ -650,11 +674,18 @@ def build_formula_workbook(ds, member, export_context, output):
reconcile.append( reconcile.append(
_styled_row( _styled_row(
reconcile, reconcile,
["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "状态"], ["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "容差", "状态"],
green, green,
white, 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 reconcile_row = 2
for label in labels: for label in labels:
for side_name, periods, col in (("本期", ctx["current"], "C"), ("基期", ctx["baseline"], "D")): 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), "" if value is None else str(value),
f"='分析汇总'!{col}{formula_row}", f"='分析汇总'!{col}{formula_row}",
f'=IFERROR(E{reconcile_row}-D{reconcile_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 reconcile_row += 1
for index, definition in enumerate(custom_columns): for index, definition in enumerate(custom_columns):
formula_row = metric_rows[(label, f"custom_{index}")] formula_row = metric_rows[(label, f"custom_{index}")]
# 自定义列用 Decimal 白名单解释器计算服务器参考,不再依赖业务人工验收
reference_value = to_decimal(definition["expression"], reference)
reconcile.append( reconcile.append(
[ [
_text_cell(reconcile, definition["name"]), _text_cell(reconcile, definition["name"]),
_text_cell(reconcile, label), _text_cell(reconcile, label),
side_name, side_name,
"自定义公式无服务器参考", "" if reference_value is None else str(reference_value),
f"='分析汇总'!{col}{formula_row}", f"='分析汇总'!{col}{formula_row}",
"", f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
"需业务验收", float(CUSTOM_COLUMN_TOLERANCE),
_reconcile_status(reconcile_row),
] ]
) )
reconcile_row += 1 reconcile_row += 1
+25
View File
@@ -1,4 +1,5 @@
import math import math
from decimal import Decimal
from .analysis import METRICS from .analysis import METRICS
@@ -107,3 +108,27 @@ def to_excel(node, references):
if "number" in node: if "number" in node:
return str(node["number"]) return str(node["number"])
return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})" 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
+4 -3
View File
@@ -20,11 +20,12 @@ def test_reconcile_status_guards_non_numeric_difference(workspace):
output, output,
) )
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False) workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
status_cells = [row[7] for row in workbook["对账检查"].iter_rows(min_row=2)]
formulas = [ formulas = [
row[6].value cell.value for cell in status_cells if isinstance(cell.value, str) and cell.value.startswith("=IF(D")
for row in workbook["对账检查"].iter_rows(min_row=2)
if isinstance(row[6].value, str) and row[6].value.startswith("=IF(D")
] ]
assert formulas assert formulas
assert all("ISNUMBER(F" in formula for formula in formulas) assert all("ISNUMBER(F" in formula for formula in formulas)
assert all('"通过"' 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))
+215
View File
@@ -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