- “产品毛利率”参数行不再标记可编辑:说明页写明它是导出时的只读快照, 各行毛利率已写入计算明细 MarginRate 列,调整需在计算明细页按行修改 - 参数页新增完整维度键-显示名对照行(此前说明页提到“本页分组维度”但无字典) - 使用说明新增输入字典:Revenue/Cost/GrossInput/MarginRate/ListAmount 逐项列出 单位、来源字段、修改位置与缺失含义;新增固定结构边界行(哪些列/页不可改、 改结构需重新导出) - 测试:参数页全部“否”、每个可修改输入出现在计算明细表头并被汇总公式引用、 维度对照完整;LibreOffice 用例验证修改 MarginRate 后 lookup 毛利重算(本机跳过)
300 lines
11 KiB
Python
300 lines
11 KiB
Python
import json
|
|
from decimal import Decimal
|
|
from io import BytesIO
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from django.contrib.auth.models import User
|
|
from openpyxl import load_workbook
|
|
|
|
from finance.exporting import build_formula_workbook
|
|
from finance.formula_columns import validate_column
|
|
from finance.formula_views import design_column
|
|
from finance.models import Dataset, Fact, Membership, Organization
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
@pytest.fixture
|
|
def formula_data():
|
|
org = Organization.objects.create(name="Formula tests")
|
|
user = User.objects.create_user("formula", password="Formula-TestPassword991!")
|
|
member = Membership.objects.create(user=user, organization=org, role="admin")
|
|
mapping = {
|
|
"sheet": "CSV",
|
|
"columns": [
|
|
{"index": 0, "name": "Period", "role": "period"},
|
|
{"index": 1, "name": "Department", "role": "dimension", "key": "department"},
|
|
{"index": 2, "name": "Revenue", "role": "revenue"},
|
|
{"index": 3, "name": "Cost", "role": "cost"},
|
|
],
|
|
"roi_formula": "profit_over_cost",
|
|
}
|
|
ds = Dataset.objects.create(
|
|
organization=org,
|
|
created_by=user,
|
|
name="formula.csv",
|
|
sha256="b" * 64,
|
|
status="ready",
|
|
mapping=mapping,
|
|
periods=["2025-08", "2026-08"],
|
|
)
|
|
for period, revenue, cost in (("2025-08", 80, 30), ("2026-08", 100, 40)):
|
|
Fact.objects.create(
|
|
dataset=ds,
|
|
period=period,
|
|
dimensions={"department": "儿科"},
|
|
revenue=Decimal(revenue),
|
|
cost=Decimal(cost),
|
|
gross=Decimal(revenue - cost),
|
|
customer=f"{period}-patient",
|
|
source_row=1 if period == "2025-08" else 2,
|
|
raw=[period, "儿科", revenue, cost],
|
|
)
|
|
return ds, member
|
|
|
|
|
|
def custom_column():
|
|
return {
|
|
"name": "收入成本差",
|
|
"meaning": "实际收入减成本",
|
|
"scope": "按所选分组及本期、基期计算",
|
|
"missing": "结果不可用(#N/A);零分母不可用",
|
|
"expression": {
|
|
"operator": "subtract",
|
|
"left": {"metric": "revenue"},
|
|
"right": {"metric": "cost"},
|
|
},
|
|
}
|
|
|
|
|
|
def test_formula_workbook_exposes_custom_metric_as_recalculable_column(formula_data):
|
|
ds, member = formula_data
|
|
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)
|
|
assert workbook["计算明细"]["M2"].value == '=IF(H2="direct",F2,IF(H2="lookup",D2*G2,""))'
|
|
assert workbook["计算明细"]["I2"].data_type == "n"
|
|
summary = workbook["分析汇总"]
|
|
metric_row = next(
|
|
row for row in range(6, summary.max_row + 1) if summary.cell(row, 1).value == "收入成本差"
|
|
)
|
|
formula = summary.cell(metric_row, 3).value
|
|
assert formula.startswith("=IF(OR(ISNA(C")
|
|
assert "IFERROR((C" in formula and "NA())" in formula
|
|
assert "自定义指标" in workbook.sheetnames
|
|
custom = workbook["自定义指标"]
|
|
assert custom["B1"].value == "收入成本差(本期)"
|
|
assert custom["B2"].value == f"='分析汇总'!C{metric_row}"
|
|
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():
|
|
from finance.formula_columns import metrics_used, to_excel
|
|
|
|
expression = {
|
|
"operator": "divide",
|
|
"left": {"metric": "revenue"},
|
|
"right": {
|
|
"operator": "add",
|
|
"left": {"metric": "cost"},
|
|
"right": {"number": 2.5},
|
|
},
|
|
}
|
|
validate_column({**custom_column(), "expression": expression})
|
|
assert metrics_used(expression) == {"revenue", "cost"}
|
|
assert to_excel(expression, {"revenue": "C6", "cost": "D6"}) == "(C6/(D6+2.5))"
|
|
column = custom_column()
|
|
column["expression"] = {"metric": "__import__('os')"}
|
|
with pytest.raises(ValueError, match="不支持的指标"):
|
|
validate_column(column)
|
|
|
|
column = custom_column()
|
|
column["expression"] = {"formula": "=HYPERLINK(...)"}
|
|
with pytest.raises(ValueError, match="不支持的字段或运算"):
|
|
validate_column(column)
|
|
|
|
|
|
def test_design_column_preserves_clarification_branch_and_full_conversation(formula_data, settings):
|
|
ds, _ = formula_data
|
|
settings.AI_ENABLED = True
|
|
settings.OPENAI_API_KEY = "test-key"
|
|
settings.OPENAI_MODEL = "test-model"
|
|
|
|
clarification = {
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": '{"complete":false,"questions":["请说明零分母如何处理。"],"column":null}',
|
|
}
|
|
]
|
|
}
|
|
],
|
|
}
|
|
with patch("finance.formula_views.httpx.Client") as http:
|
|
http.return_value.__enter__.return_value.post.return_value = type(
|
|
"Response",
|
|
(),
|
|
{"raise_for_status": lambda self: None, "json": lambda self: clarification},
|
|
)()
|
|
incomplete = design_column(
|
|
[{"role": "user", "content": "新增利润率列"}],
|
|
ds,
|
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
|
)
|
|
assert incomplete == {
|
|
"complete": False,
|
|
"questions": ["请说明零分母如何处理。"],
|
|
"column": None,
|
|
}
|
|
|
|
class Response:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": '{"complete":false,"questions":["零分母如何处理?"],"column":null}',
|
|
}
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
messages = [
|
|
{"role": "user", "content": "增加收入成本差列"},
|
|
{"role": "assistant", "content": "请说明计算方式和缺失值处理。"},
|
|
{"role": "user", "content": "收入减成本,缺失时不可用"},
|
|
]
|
|
with patch("finance.formula_views.httpx.Client") as client:
|
|
client.return_value.__enter__.return_value.post.return_value = Response()
|
|
result = design_column(
|
|
messages,
|
|
ds,
|
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
|
)
|
|
|
|
assert result == {"complete": False, "questions": ["零分母如何处理?"], "column": None}
|
|
payload = client.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
assert payload["input"][1:] == messages
|
|
|
|
|
|
def test_design_column_accepts_loaded_definition_context(formula_data, settings):
|
|
ds, _ = formula_data
|
|
settings.AI_ENABLED = True
|
|
settings.OPENAI_API_KEY = "test-key"
|
|
settings.OPENAI_MODEL = "test-model"
|
|
context = "已载入已保存定义「收入成本差」;计算表达式:" + '{"operator":"subtract",' * 45
|
|
assert 1000 < len(context) <= 2000
|
|
messages = [
|
|
{"role": "user", "content": context},
|
|
{"role": "assistant", "content": "已载入该定义作为当前基准。"},
|
|
{"role": "user", "content": "把刚才公式改成乘以 100"},
|
|
]
|
|
clarification = {
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": '{"complete":false,"questions":["要乘在整个表达式上吗?"],"column":null}',
|
|
}
|
|
]
|
|
}
|
|
],
|
|
}
|
|
with patch("finance.formula_views.httpx.Client") as http:
|
|
http.return_value.__enter__.return_value.post.return_value = type(
|
|
"Response",
|
|
(),
|
|
{"raise_for_status": lambda self: None, "json": lambda self: clarification},
|
|
)()
|
|
result = design_column(
|
|
messages,
|
|
ds,
|
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
|
)
|
|
assert result["complete"] is False
|
|
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
assert payload["input"][1:] == messages
|
|
|
|
with pytest.raises(Exception, match="对话消息内容无效"):
|
|
design_column(
|
|
[{"role": "user", "content": "y" * 2001}],
|
|
ds,
|
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
|
)
|
|
|
|
|
|
def test_design_column_returns_only_validated_formula_definition(formula_data, settings):
|
|
ds, _ = formula_data
|
|
settings.AI_ENABLED = True
|
|
settings.OPENAI_API_KEY = "test-key"
|
|
settings.OPENAI_MODEL = "test-model"
|
|
definition = custom_column()
|
|
response = type(
|
|
"Response",
|
|
(),
|
|
{
|
|
"raise_for_status": lambda self: None,
|
|
"json": lambda self: {
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"content": [
|
|
{
|
|
"type": "output_text",
|
|
"text": json.dumps(
|
|
{"complete": True, "questions": [], "column": definition},
|
|
ensure_ascii=False,
|
|
),
|
|
}
|
|
]
|
|
}
|
|
],
|
|
},
|
|
},
|
|
)()
|
|
with patch("finance.formula_views.httpx.Client") as http:
|
|
http.return_value.__enter__.return_value.post.return_value = response
|
|
result = design_column(
|
|
[
|
|
{"role": "user", "content": "增加收入成本差列"},
|
|
{"role": "assistant", "content": "请补充计算口径"},
|
|
{"role": "user", "content": "收入减成本,按分组选期间计算"},
|
|
],
|
|
ds,
|
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
|
)
|
|
assert result == {"complete": True, "questions": [], "column": definition}
|
|
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
|
assert payload["store"] is False
|
|
assert payload["text"]["format"]["strict"] is True
|