持久化与工作簿:F05/F06/F07 定义库并发、说明页字典、对账容差 #3
+40
-5
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user