Files
fa-agent/tests/test_finance.py
Jayway Liu 494490f6e6
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 12s
fix(display): 证据金额去尾零与界面显示修复
- 证据接口与 CSV/XLSX 导出去掉 NUMERIC(44,24) 存储尺度尾零(无损精度)
- KPI 大金额折行截断:字号收紧 + nowrap + 省略号兜底
- 贡献分解/走势图表轴标签重叠与 Y 轴裁剪:hideOverlap + 万/亿缩写
- 表格隐藏全不可用指标列,空态改为条件渲染(修复空表覆盖层残留)
- 数据质量与字段字典/导入确认的中文标签映射(不再暴露内部键名)
- 证据弹窗重复点击当前期间停在加载中(F03);证据表格最小宽度
- Playwright 覆盖 F03 重复点击与期间切换场景
2026-10-11 09:41:31 +08:00

252 lines
9.1 KiB
Python

from decimal import Decimal
from io import BytesIO
import pytest
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.test import Client
from openpyxl import load_workbook
from finance.analysis import analyze, context, default_preferences, metrics
from finance.importing import decimal, period_value, process_dataset, validate_mapping
from finance.models import Fact, Membership
pytestmark = pytest.mark.django_db
def test_money_and_period_parsing_reject_invalid_values():
assert decimal("1,234.50") == Decimal("1234.50")
assert decimal("", required=False) is None
assert period_value({"period": "2026/8/1"}) == "2026-08"
assert period_value({"year": "2026年", "month": "8月"}) == "2026-08"
for value in ("NaN", "1e16", "0.0000000000000000000000001"):
with pytest.raises(ValueError):
decimal(value)
with pytest.raises(ValueError, match="必填"):
decimal(None, required=True)
with pytest.raises(ValueError, match="期间"):
period_value({"period": "2026-13"})
def test_mapping_and_context_enforce_ranges_and_dimensions(workspace):
ds, *_ = workspace
valid = validate_mapping(ds.mapping, {"sheets": [{"name": "CSV"}]})
assert valid["roi_formula"] == "profit_over_cost"
with pytest.raises(ValueError, match="ROI"):
validate_mapping({**ds.mapping, "roi_formula": "=1+1"}, {"sheets": [{"name": "CSV"}]})
reserved = {**ds.mapping, "columns": [dict(column) for column in ds.mapping["columns"]]}
reserved["columns"][1]["key"] = "revenue"
with pytest.raises(ValueError, match="保留列名冲突"):
validate_mapping(reserved, {"sheets": [{"name": "CSV"}]})
with pytest.raises(ValueError, match="维度"):
context(ds, {"filters": {"unknown": "x"}})
with pytest.raises(ValueError, match="重叠"):
context(ds, {"comparison": "custom", "start": "2026-08", "end": "2026-08", "baseline_end": "2026-08"})
with pytest.raises(ValueError, match="60"):
context(ds, {"start": "2019-01", "end": "2026-08"})
def test_metrics_and_analysis_preserve_roi_refunds_and_distinct_customers(workspace):
ds, member, *_ = workspace
prefs = default_preferences(ds)
result = analyze(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
prefs,
)
assert result["summary"]["revenue"]["current"] == Decimal("90")
assert result["summary"]["customers"]["current"] == 3
assert result["summary"]["roi"]["current"] == Decimal("1")
assert result["summary"]["discount_rate"]["current"] == Decimal("90") / Decimal("125")
assert result["context"]["missing"] == []
assert {row["label"] for row in result["rows"]} == {"儿科", "外科"}
unavailable = metrics(
{
"rows": 1,
"revenue_sum": Decimal(5),
"cost_sum": None,
"gross_sum": None,
"cost_n": 0,
"gross_n": 0,
"customer_n": 0,
"customers": 0,
"list_sum": None,
"list_n": 0,
"self_sum": None,
"upgraded": 0,
},
"gross_less_cost_over_cost",
["医疗服务"],
)
assert unavailable["roi"] is None
assert unavailable["customers"] is None
assert unavailable["self_revenue"] == 0
def test_dataset_and_evidence_are_organization_and_department_scoped(workspace):
ds, _, _, _, other_client = workspace
url = f"/api/datasets/{ds.pk}"
assert other_client.get(url).status_code == 404
dept_user = User.objects.create_user("finance-dept", password="Finance-TestPassword991!")
Membership.objects.create(
user=dept_user,
organization=ds.organization,
role="analyst",
departments=["儿科"],
)
scoped = Client()
assert scoped.login(username=dept_user.username, password="Finance-TestPassword991!")
assert scoped.get(url).json()["row_count"] is None
evidence = scoped.post(
f"{url}/evidence",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
assert evidence.status_code == 200
assert evidence.json()["total"] == 2
assert all("raw" not in row for row in evidence.json()["rows"])
def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/evidence"
payload = {
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"side": "current",
"offset": 1,
"limit": 1,
}
page = client.post(url, data=payload, content_type="application/json")
assert page.status_code == 200
assert page.json()["total"] == 3
assert len(page.json()["rows"]) == 1
assert page.json()["rows"][0]["source_row"] == 5
for invalid in ({"offset": -1}, {"limit": 201}, {"offset": True}):
response = client.post(url, data={**payload, **invalid}, content_type="application/json")
assert response.status_code == 400
def test_evidence_amounts_trim_storage_scale(workspace):
from decimal import Decimal
from finance.views import amount_text
assert amount_text(Decimal("10.000000000000000000000000")) == "10"
assert amount_text(Decimal("10.500000000000000000000000")) == "10.5"
assert amount_text(Decimal("0E-24")) == "0"
assert amount_text(None) is None
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/evidence",
data={
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"side": "current",
"limit": 1,
},
content_type="application/json",
)
row = response.json()["rows"][0]
assert row["revenue"] == "80"
assert row["cost"] == "30"
assert row["gross"] == "50"
def test_settings_revision_and_invalid_metric_are_rejected(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/settings"
initial = client.get(url).json()
value = initial["value"]
value["min_samples"] = 1
saved = client.put(
url,
data={"value": value, "revision": initial["revision"]},
content_type="application/json",
)
assert saved.status_code == 200
assert saved.json()["revision"] == 1
conflict = client.put(
url,
data={"value": value, "revision": 0},
content_type="application/json",
)
assert conflict.status_code == 409
value["metric"] = "unknown"
invalid = client.put(url, data={"value": value, "revision": 1}, content_type="application/json")
assert invalid.status_code == 400
def test_csv_export_neutralizes_formula_injection_and_preserves_exact_values(workspace):
ds, _, client, *_ = workspace
Fact.objects.filter(dataset=ds).update(dimensions={"department": "=1+1", "product": "检查"})
response = client.post(
f"/api/datasets/{ds.pk}/export",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
assert response.status_code == 200
assert response.streaming
content = b"".join(response.streaming_content).decode("utf-8-sig")
assert "'=1+1" in content
assert ",'-10,5,'-15" in content
assert "00000000000000" not in content
assert "traceiq-evidence.csv" in response["Content-Disposition"]
xlsx = client.post(
f"/api/datasets/{ds.pk}/export",
data={
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"format": "xlsx",
},
content_type="application/json",
)
assert xlsx.streaming
workbook = load_workbook(BytesIO(b"".join(xlsx.streaming_content)), data_only=False)
cell = workbook["证据明细"]["D6"]
assert cell.value == "=1+1"
assert cell.data_type == "s"
def test_invalid_import_rolls_back_facts_and_records_failure(workspace, tmp_path):
ds, *_ = workspace
ds.facts.all().delete()
ds.status = "queued"
ds.mapping = {
"header": 1,
"sheet": "CSV",
"columns": [
{"index": 0, "name": "期间", "role": "period"},
{"index": 1, "name": "科室", "role": "dimension", "key": "department"},
{"index": 2, "name": "实际收入", "role": "revenue"},
],
"roi_formula": "profit_over_cost",
}
ds.save()
csv_path = tmp_path / "invalid.csv"
csv_path.write_text("期间,科室,实际收入\n2026-08,儿科,10\n2026-13,儿科,20\n", encoding="utf-8")
ds.file.name = str(csv_path)
ds.save(update_fields=["file"])
result = process_dataset(ds.pk)
assert result.status == "failed"
assert result.facts.count() == 0
assert "第 3 行" in result.error
def test_upload_requires_role_and_rejects_unsupported_file(workspace):
_, _, client, _, _ = workspace
response = client.post("/api/datasets", data={"file": ContentFile(b"not a workbook", name="bad.txt")})
assert response.status_code == 403 or response.status_code == 400