This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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}"
|
||||
assert "含义:实际收入减成本" in workbook["使用说明"]["B12"].value
|
||||
assert "范围:按所选分组及本期、基期计算" in workbook["使用说明"]["B12"].value
|
||||
|
||||
|
||||
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_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
|
||||
Reference in New Issue
Block a user