chore: import existing codebase as baseline
Verify / verify (push) Failing after 1m20s

This commit is contained in:
Jayway Liu
2026-09-26 01:12:10 +08:00
commit 0ac0c4eb56
77 changed files with 14155 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import pytest
from django.contrib.auth.models import User
from django.test import Client
from finance.models import Dataset, Fact, Membership, Organization
@pytest.fixture
def workspace(tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
org = Organization.objects.create(name="Finance test")
user = User.objects.create_user("finance-admin", password="Finance-TestPassword991!")
member = Membership.objects.create(user=user, organization=org, role="admin")
other_org = Organization.objects.create(name="Other organization")
other_user = User.objects.create_user("finance-other", password="Finance-TestPassword991!")
other_member = Membership.objects.create(user=other_user, organization=other_org, role="admin")
mapping = {
"sheet": "CSV",
"header": 1,
"columns": [
{"index": 0, "name": "期间", "role": "period"},
{"index": 1, "name": "科室", "role": "dimension", "key": "department"},
{"index": 2, "name": "产品一级分类", "role": "dimension", "key": "product"},
{"index": 3, "name": "实际收入", "role": "revenue"},
{"index": 4, "name": "成本", "role": "cost"},
{"index": 5, "name": "毛利贡献", "role": "gross"},
{"index": 6, "name": "客户编码", "role": "customer"},
{"index": 7, "name": "应收金额", "role": "list_amount"},
],
"roi_formula": "profit_over_cost",
"self_operated_products": ["医疗服务"],
}
ds = Dataset.objects.create(
organization=org,
created_by=user,
name="finance.csv",
sha256="a" * 64,
status="ready",
mapping=mapping,
periods=["2025-08", "2026-08"],
)
facts = [
("2025-08", "儿科", "医疗服务", 100, 40, 60, "C1", 120),
("2025-08", "儿科", "检查", 50, 20, 30, "C2", 60),
("2026-08", "儿科", "医疗服务", 80, 30, 50, "C1", 100),
("2026-08", "儿科", "医疗服务", 20, 10, 10, "C3", 25),
("2026-08", "外科", "检查", -10, 5, -15, "C4", 0),
]
for index, (period, department, product, revenue, cost, gross, customer, list_amount) in enumerate(
facts, 2
):
Fact.objects.create(
dataset=ds,
period=period,
dimensions={"department": department, "product": product},
revenue=revenue,
cost=cost,
gross=gross,
customer=customer,
list_amount=list_amount,
source_row=index,
raw=[period, department, product, revenue, cost, gross, customer, list_amount],
)
client = Client()
assert client.login(username=user.username, password="Finance-TestPassword991!")
other_client = Client()
assert other_client.login(username=other_user.username, password="Finance-TestPassword991!")
return ds, member, client, other_member, other_client
+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
const password =
process.env.E2E_PASSWORD ||
fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1];
const csv = (customerCode: string) =>
`期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,${customerCode}\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,${customerCode}\n`;
test("新增列:补充信息触发追问并重新确认后生成公式工作簿", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/");
await page.getByLabel("用户名", { exact: true }).fill("admin");
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible();
const uploaded = page.waitForResponse(
(response) =>
response.url().endsWith("/api/datasets") &&
response.request().method() === "POST",
);
await page.getByLabel("上传财务表格").setInputFiles({
name: `formula-e2e-${Date.now()}.csv`,
mimeType: "text/csv",
buffer: Buffer.from(csv(`E2E-${Date.now()}`)),
});
await uploaded;
await page
.getByRole("dialog", { name: "确认导入字段" })
.getByRole("button", { name: "确认并导入" })
.click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible({ timeout: 60000 });
let designCalls = 0;
let sentMessages: { role: string; content: string }[] = [];
await page.route("**/formula-design", async (route) => {
sentMessages = JSON.parse(route.request().postData()!).messages;
designCalls++;
await route.fulfill({
json:
designCalls === 1 || designCalls === 3
? {
complete: false,
questions: [
designCalls === 1
? "缺失值和零分母如何处理?"
: "请说明新增边界条件的计算口径。",
],
column: null,
}
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
});
});
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const dialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await dialog.getByRole("checkbox").check();
await dialog
.getByLabel("列计算描述")
.fill("新增收入成本差;请询问缺失值和零分母处理方式");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(dialog).toContainText("缺失值和零分母如何处理?");
await dialog.getByLabel("列计算描述").fill("都显示不可用");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(dialog.getByText("收入成本差", { exact: true })).toBeVisible();
await expect(dialog).toContainText("计算表达式:(实际收入 − 成本)");
await expect(dialog).toContainText("本轮描述 2/6");
await dialog.getByLabel("列计算描述").fill("增加一项特殊边界条件");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(dialog).toContainText("请说明新增边界条件的计算口径。");
await expect(
dialog.getByRole("button", { name: "确认并生成公式工作簿" }),
).toHaveCount(0);
await dialog.getByLabel("列计算描述").fill("边界条件按收入减成本计算");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(
dialog.getByRole("button", { name: "确认并生成公式工作簿" }),
).toBeVisible();
await expect(dialog).toContainText("本轮描述 4/6");
expect(sentMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
"user",
"assistant",
"user",
]);
expect(sentMessages.at(-1)?.content).toContain("边界条件按收入减成本");
await dialog.getByRole("button", { name: "保存到定义库" }).click();
await expect(dialog.getByLabel("已保存定义")).toHaveValue(/.+/);
await dialog.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(dialog.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
const download = page.waitForEvent("download");
await dialog.getByRole("button", { name: "下载公式工作簿" }).click();
expect((await download).suggestedFilename()).toBe(
"traceiq-formula-analysis.xlsx",
);
await dialog.getByLabel("关闭", { exact: true }).click();
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)");
await reopened
.getByRole("button", { name: "确认并生成公式工作簿" })
.click();
await expect(reopened.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
expect(designCalls).toBe(4);
});
+273
View File
@@ -0,0 +1,273 @@
import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import fs from "node:fs";
const password =
process.env.E2E_PASSWORD ||
fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1];
const csv =
"期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,C1\n2025-08,测试儿保,普儿,高级,医生B,医疗服务,10000,4000,6000,C2\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,C1\n2026-08,测试儿保,普儿,高级,医生B,医疗服务,5000,4000,3000,C2\n";
async function login(page: Page) {
await page.goto("/");
await page.getByLabel("用户名", { exact: true }).fill("admin");
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible();
}
test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导出", async ({
page,
}) => {
await page.setViewportSize({ width: 1512, height: 982 });
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await login(page);
const uploaded = page.waitForResponse(
(r) => r.url().endsWith("/api/datasets") && r.request().method() === "POST",
);
await page.getByLabel("上传财务表格").setInputFiles({
name: "e2e-finance.csv",
mimeType: "text/csv",
buffer: Buffer.from(csv.replaceAll("C2", `C2-${Date.now()}`)),
});
await uploaded;
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("heading", { name: "确认导入字段" })).toBeVisible();
await dialog.getByRole("button", { name: "确认并导入" }).click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible({ timeout: 60000 });
await expect(page.locator(".kpi").first()).toContainText("10,000");
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await page.getByLabel("最小样本数").fill("1");
await page.getByRole("button", { name: "保存设置", exact: true }).click();
await expect(page.locator(".kpi").first()).toContainText("10,000");
await page.screenshot({
path: "test-results/desktop-overview.png",
fullPage: true,
});
await page
.getByRole("button", { name: "异常追踪", exact: false })
.first()
.click();
await expect(
page.getByRole("heading", { name: "1 个分组需要关注" }),
).toBeVisible();
await page.getByLabel("下钻测试儿保", { exact: true }).click();
await expect(
page.getByRole("heading", { name: "测试儿保 · 异常调查" }),
).toBeVisible();
await page.getByLabel("下钻普儿", { exact: true }).click();
await expect(
page.getByRole("heading", { name: "普儿 · 异常调查" }),
).toBeVisible();
await page.getByLabel("下钻高级", { exact: true }).click();
await expect(
page.getByRole("heading", { name: "高级 · 异常调查" }),
).toBeVisible();
await page.getByLabel("调查状态", { exact: true }).selectOption("reviewing");
await page.getByLabel("调查备注", { exact: true }).fill("核对退款与产品口径");
await page.getByRole("button", { name: "保存调查记录", exact: true }).click();
await expect(
page.getByRole("button", { name: "已保存", exact: true }),
).toBeVisible();
await page
.getByRole("navigation", { name: "分析路径" })
.getByRole("button", { name: "普儿", exact: true })
.click();
await page.getByLabel("下钻高级", { exact: true }).click();
await expect(page.getByLabel("调查备注", { exact: true })).toHaveValue(
"核对退款与产品口径",
);
await expect(page.getByLabel("调查状态", { exact: true })).toHaveValue(
"reviewing",
);
await page.getByLabel("查看医生A证据", { exact: true }).click();
const evidenceDialog = page.getByRole("dialog");
await expect(evidenceDialog).toContainText("1 条");
await expect(evidenceDialog).toContainText("2026-08");
await expect(evidenceDialog).toContainText("CSV");
await evidenceDialog.getByRole("button", { name: "基期", exact: true }).click();
await expect(evidenceDialog).toContainText("2025-08");
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
const evidenceOffsets: number[] = [];
await page.route("**/api/datasets/*/evidence", async (route) => {
const request = route.request().postDataJSON() as { offset?: number };
const offset = request.offset || 0;
evidenceOffsets.push(offset);
const count = offset === 0 ? 50 : 1;
await route.fulfill({
json: {
total: 51,
sheet: "CSV",
rows: Array.from({ length: count }, (_, index) => ({
source_row: offset + index + 1,
period: offset === 0 ? "2026-08" : "2026-09",
dimensions: {},
revenue: "1",
cost: "1",
gross: "0",
})),
},
});
});
await page.getByLabel("查看医生A证据", { exact: true }).click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
await evidenceDialog.getByRole("button", { name: "下一页", exact: true }).click();
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
await expect(evidenceDialog).toContainText("2026-09");
expect(evidenceOffsets).toEqual([0, 50]);
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
await page
.getByRole("navigation", { name: "分析路径" })
.getByRole("button", { name: "集团总览", exact: true })
.click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "环比", exact: true }).click();
await expect(
page.getByRole("status").filter({ hasText: "缺少期间" }),
).toContainText("2026-07");
await page.getByRole("button", { name: "同比", exact: true }).click();
await expect(page.locator(".kpi").first()).toContainText("10,000");
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await page.getByLabel("异常阈值").fill("0.8");
await page.getByRole("button", { name: "取消", exact: true }).click();
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await expect(page.getByLabel("异常阈值")).toHaveValue("0.15");
await page.getByRole("button", { name: "取消", exact: true }).click();
await page.getByRole("button", { name: "Copilot", exact: true }).click();
await page.getByLabel("描述要分析的问题").fill("查看异常");
await page.locator(".copilot").getByRole("button", { name: "发送" }).click();
await expect(page.locator(".message")).toContainText("按当前规则识别出");
await expect(
page.getByRole("heading", { name: "异常追踪", exact: true }),
).toBeVisible();
await page.locator(".copilot").getByLabel("关闭", { exact: true }).click();
const downloaded = page.waitForEvent("download");
await page.getByLabel("导出当前范围").click();
const file = await downloaded;
expect(file.suggestedFilename()).toBe("traceiq-evidence.csv");
await page.getByRole("button", { name: "经营总览", exact: true }).click();
await expect(page.locator("canvas").first()).toBeVisible();
const pixels = await page
.locator("canvas")
.first()
.evaluate((canvas: HTMLCanvasElement) => {
const ctx = canvas.getContext("2d")!;
const p = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let n = 0;
for (let i = 3; i < p.length; i += 4) if (p[i] > 0) n++;
return n;
});
expect(pixels).toBeGreaterThan(1000);
let designCalls = 0;
await page.route("**/formula-design", async (route) => {
designCalls++;
await route.fulfill({
json: designCalls === 1
? { complete: false, questions: ["缺失值和零分母如何处理?"], column: null }
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
});
});
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await formulaDialog.getByRole("checkbox").check();
await formulaDialog.getByLabel("列计算描述").fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await expect(formulaDialog).toContainText("缺失值和零分母如何处理?");
await formulaDialog.getByLabel("列计算描述").fill("缺失项和零分母都按不可用处理");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 });
const formulaDownload = page.waitForEvent("download");
await formulaDialog.getByRole("button", { name: "下载公式工作簿" }).click();
expect((await formulaDownload).suggestedFilename()).toBe("traceiq-formula-analysis.xlsx");
await formulaDialog.getByLabel("关闭").click();
});
test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await expect(page.locator(".kpi").first()).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await page.screenshot({
path: "test-results/mobile-overview.png",
fullPage: true,
});
await page.getByLabel("展开导航").click();
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.screenshot({
path: "test-results/mobile-settings.png",
fullPage: true,
});
await page.getByRole("button", { name: "取消", exact: true }).click();
await page.getByRole("button", { name: "经营总览", exact: true }).click();
await page.getByRole("button", { name: "Copilot", exact: true }).click();
await expect(page.getByLabel("描述要分析的问题")).toBeVisible();
expect(
await page
.locator(".copilot")
.evaluate((el) => el.getBoundingClientRect().right <= innerWidth),
).toBe(true);
await page.locator(".copilot").getByLabel("关闭", { exact: true }).click();
});
test("原附件:全量导入结果在桌面及手机正确显示", async ({ page }) => {
test.skip(!process.env.REFERENCE_DATASET_NAME, "仅本机显式启用原附件验收");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.setViewportSize({ width: 1512, height: 982 });
await login(page);
await page
.getByLabel("选择数据集")
.selectOption({ label: process.env.REFERENCE_DATASET_NAME! });
await expect(page.locator(".kpi").nth(0)).toContainText("152,419,115.35");
await expect(page.locator(".kpi").nth(1)).toContainText("17,868,580.85");
await expect(page.locator(".kpi").nth(3)).toContainText("不可用");
await expect(page.locator(".kpi").nth(4)).toContainText("38,361");
await page.screenshot({
path: "test-results/reference-desktop.png",
fullPage: true,
});
await page.getByRole("button", { name: "环比", exact: true }).click();
await expect(
page.getByRole("status").filter({ hasText: "缺少期间" }),
).toContainText("2026-07");
await page.getByRole("button", { name: "同比", exact: true }).click();
await expect(page.locator(".kpi").first()).toContainText("-32.4%");
await page.setViewportSize({ width: 390, height: 844 });
await expect
.poll(() =>
page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth &&
[...document.querySelectorAll(".chart canvas")].every(
(canvas) => canvas.getBoundingClientRect().right <= innerWidth,
),
),
)
.toBe(true);
await page.screenshot({
path: "test-results/reference-mobile.png",
fullPage: true,
});
expect(errors).toEqual([]);
});
+221
View File
@@ -0,0 +1,221 @@
import json
from unittest.mock import patch
import pytest
from django.test import Client
from finance.assistant import model_intent
from finance.models import Audit, FormulaDefinition, FormulaExport, Membership
pytestmark = pytest.mark.django_db
def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, settings):
ds, _, _, _, _ = workspace
settings.OPENAI_MODEL = "test-model"
settings.OPENAI_API_KEY = "test-key"
class Response:
def raise_for_status(self):
return None
def json(self):
return {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": json.dumps(
{
"action": "overview",
"metric": "revenue",
"group": "department",
"filters": [{"key": "department", "value": "儿科"}],
}
),
}
]
}
],
}
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = Response()
intent = model_intent("看儿科收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
assert intent == {
"action": "overview",
"metric": "revenue",
"group": "department",
"filters": {"department": "儿科"},
}
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["store"] is False
assert "C1" not in json.dumps(payload)
def test_model_intent_rejects_unknown_filter_and_incomplete_provider_response(workspace, settings):
ds, *_ = workspace
settings.OPENAI_MODEL = "test-model"
settings.OPENAI_API_KEY = "test-key"
class Response:
def raise_for_status(self):
return None
def json(self):
return {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": '{"action":"overview","metric":"revenue","group":"department","filters":[{"key":"__class__","value":"x"}]}',
}
]
}
],
}
response = Response()
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = response
with pytest.raises(Exception, match="无效意图"):
model_intent("分析收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = type(
"Response",
(),
{"raise_for_status": lambda self: None, "json": lambda self: {"status": "in_progress"}},
)()
with pytest.raises(Exception, match="无效意图"):
model_intent("分析收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
def test_assistant_local_response_and_external_consent_guard(workspace, settings):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/assistant"
payload = {
"question": "查看实际收入",
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
}
response = client.post(url, data=payload, content_type="application/json")
assert response.status_code == 200
assert response.json()["provider"] == "local"
assert Audit.objects.filter(action="assistant.query").exists()
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
denied = client.post(
url,
data={**payload, "use_model": True, "consent": False},
content_type="application/json",
)
assert denied.status_code == 400
assert "同意" in denied.json()["error"]
def test_formula_design_and_export_api_validate_consent_and_scope(workspace, settings):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}"
payload = {
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
"messages": [{"role": "user", "content": "收入成本差"}],
"consent": False,
}
denied = client.post(f"{url}/formula-design", data=payload, content_type="application/json")
assert denied.status_code == 400
export = client.post(
f"{url}/formula-exports",
data={**payload["context"], "custom_columns": [{"expression": {}}]},
content_type="application/json",
)
assert export.status_code == 400
assert FormulaExport.objects.count() == 0
scoped = Client()
user = ds.created_by
user.membership.departments = ["儿科"]
user.membership.save(update_fields=["departments"])
assert scoped.login(username=user.username, password="Finance-TestPassword991!")
forbidden = scoped.post(
f"{url}/formula-exports", data=payload["context"], content_type="application/json"
)
assert forbidden.status_code == 403
assert scoped.get(f"{url}/formula-definitions").status_code == 403
def test_formula_definition_library_is_private_validated_and_audited(workspace):
ds, _, client, _, _ = workspace
url = f"/api/datasets/{ds.pk}/formula-definitions"
definition = {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "所选分组与期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
saved = client.post(url, data={"definition": definition}, content_type="application/json")
assert saved.status_code == 201
definition_id = saved.json()["id"]
assert client.get(url).json()["items"][0]["definition"] == definition
updated = client.post(
url,
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
content_type="application/json",
)
assert updated.status_code == 200
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
assert updated.json()["definition"]["meaning"] == "更新说明"
other = Client()
other_user = ds.created_by.__class__.objects.create_user(
"formula-library-other", password="Formula-Library-TestPassword991!"
)
Membership.objects.create(user=other_user, organization=ds.organization, role="analyst")
assert other.login(username=other_user.username, password="Formula-Library-TestPassword991!")
assert other.get(url).json()["items"] == []
assert other.delete(f"{url}/{definition_id}").status_code == 404
deleted = client.delete(f"{url}/{definition_id}")
assert deleted.status_code == 200
assert not FormulaDefinition.objects.filter(pk=definition_id).exists()
assert Audit.objects.filter(action="formula_definition.save").count() == 2
assert Audit.objects.filter(action="formula_definition.delete").count() == 1
def test_formula_definition_library_rejects_unavailable_metric(workspace):
ds, _, client, *_ = workspace
mapping = {**ds.mapping, "columns": [c for c in ds.mapping["columns"] if c["role"] != "cost"]}
ds.mapping = mapping
ds.save(update_fields=["mapping"])
response = client.post(
f"/api/datasets/{ds.pk}/formula-definitions",
data={
"definition": {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "所选分组与期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
},
content_type="application/json",
)
assert response.status_code == 400
assert "成本" in response.json()["error"]
+222
View File
@@ -0,0 +1,222 @@
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_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.000000000000000000000000," in content
assert "'-10.000000000000000000000000" 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
+83
View File
@@ -0,0 +1,83 @@
import os
from datetime import timedelta
import pytest
from django.contrib.auth.models import User
from django.core.management import call_command
from django.utils import timezone
from finance.models import Dataset, FormulaExport, Membership, Organization
pytestmark = pytest.mark.django_db
@pytest.fixture
def cleanup_root(settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
org = Organization.objects.create(name="Cleanup test")
user = User.objects.create_user("cleanup", password="Cleanup-TestPassword991!")
Membership.objects.create(user=user, organization=org, role="admin")
dataset = Dataset.objects.create(
organization=org,
created_by=user,
name="fixture.csv",
sha256="a" * 64,
status="ready",
)
return tmp_path / "formula-exports", dataset, user
def test_cleanup_expires_jobs_and_removes_files(cleanup_root):
root, dataset, user = cleanup_root
path = root / str(user.pk) / "expired.xlsx"
path.parent.mkdir(parents=True)
path.write_bytes(b"workbook")
job = FormulaExport.objects.create(
dataset=dataset,
user=user,
status="succeeded",
path=str(path),
expires_at=timezone.now() - timedelta(seconds=1),
)
call_command("cleanup_formula_exports")
job.refresh_from_db()
assert job.status == "expired"
assert job.path == ""
assert not path.exists()
def test_cleanup_removes_only_stale_temporary_files(cleanup_root):
root, _, user = cleanup_root
user_dir = root / str(user.pk)
user_dir.mkdir(parents=True)
stale, recent = user_dir / "stale.tmp", user_dir / "recent.tmp"
stale.touch()
recent.touch()
old = timezone.now().timestamp() - 25 * 3600
os.utime(stale, (old, old))
call_command("cleanup_formula_exports")
assert not stale.exists()
assert recent.exists()
def test_cleanup_does_not_delete_files_outside_export_root(cleanup_root, tmp_path):
root, dataset, user = cleanup_root
outside = tmp_path / "protected.xlsx"
outside.write_bytes(b"keep")
job = FormulaExport.objects.create(
dataset=dataset,
user=user,
status="succeeded",
path=str(outside),
expires_at=timezone.now() - timedelta(seconds=1),
)
call_command("cleanup_formula_exports")
job.refresh_from_db()
assert job.status == "expired"
assert outside.read_bytes() == b"keep"
+246
View File
@@ -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
+75
View File
@@ -0,0 +1,75 @@
from io import BytesIO
from unittest.mock import patch
import pytest
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.views import APIError
pytestmark = pytest.mark.django_db
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def test_design_column_requires_alternating_messages_and_completed_response(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
with pytest.raises(APIError, match="顺序"):
design_column([{"role": "assistant", "content": "需要补充"}], ds, ctx)
with pytest.raises(APIError, match="顺序"):
design_column([{"role": "user", "content": "新增列"}, {"role": "user", "content": "再补充"}], ds, ctx)
response = type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {"status": "in_progress", "output": []},
},
)()
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = response
with pytest.raises(APIError, match="未创建工作簿"):
design_column([{"role": "user", "content": "新增列"}], ds, ctx)
def test_formula_labels_from_ai_are_written_as_plain_text(workspace):
ds, member, *_ = workspace
definition = custom_column()
definition["name"] = '=HYPERLINK("https://invalid")'
validate_column(definition)
output = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
output,
)
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
summary = workbook["分析汇总"]
cell = next(cell for row in summary.iter_rows() for cell in row if cell.value == definition["name"])
assert cell.data_type == "s"
+376
View File
@@ -0,0 +1,376 @@
import stat
from decimal import Decimal
from io import BytesIO
from pathlib import Path
import pytest
from finance.exporting import build_formula_workbook
from finance.models import Fact, FormulaExport
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
pytestmark = pytest.mark.django_db
def test_formula_workbook_fails_closed_when_row_or_group_limit_is_exceeded(workspace, monkeypatch):
import finance.exporting as exporting
ds, member, *_ = workspace
args = (
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
BytesIO(),
)
monkeypatch.setattr(exporting, "MAX_ROWS", 0)
with pytest.raises(ValueError, match="最多支持"):
build_formula_workbook(*args)
monkeypatch.setattr(exporting, "MAX_ROWS", 100_000)
monkeypatch.setattr(exporting, "MAX_GROUPS", 0)
with pytest.raises(ValueError, match="分组"):
build_formula_workbook(*args)
def test_formula_workbook_uses_streaming_writer(workspace, monkeypatch):
import finance.exporting as exporting
ds, member, *_ = workspace
real_workbook = exporting.Workbook
options = {}
def workbook(*args, **kwargs):
options.update(kwargs)
return real_workbook(*args, **kwargs)
monkeypatch.setattr(exporting, "Workbook", workbook)
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
BytesIO(),
)
assert options == {"write_only": True}
def test_formula_export_worker_publishes_complete_file_and_removes_temp_file(workspace):
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
context = {
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
}
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports", data=context, content_type="application/json"
)
job = FormulaExport.objects.get(pk=response.json()["id"])
stats = process_formula_export(job.pk)
job.refresh_from_db()
assert stats["rows"] == 5
assert stats["formula_version"] == "3"
assert job.status == "succeeded"
assert job.expires_at is not None
assert not job.path.endswith(".tmp")
assert Path(job.path).read_bytes()[:2] == b"PK"
assert stat.S_IMODE(Path(job.path).stat().st_mode) == 0o600
assert stat.S_IMODE(Path(job.path).parent.stat().st_mode) == 0o700
assert stat.S_IMODE(Path(job.path).parent.parent.stat().st_mode) == 0o700
assert not Path(job.path).with_suffix(".tmp").exists()
assert not list(Path(job.path).parent.glob(f"{job.pk}.*.tmp"))
def test_formula_export_worker_records_failure_and_removes_partial_file(workspace, monkeypatch):
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
job = FormulaExport.objects.get(pk=response.json()["id"])
def fail(*args, **kwargs):
args[-1].write(b"partial")
raise ValueError("controlled export failure")
monkeypatch.setattr("finance.exporting.build_formula_workbook", fail)
with pytest.raises(ValueError, match="controlled export failure"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
assert job.error == "controlled export failure"
response = client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}")
assert response.json()["error"] == "生成失败,请联系管理员并提供任务编号"
assert "controlled export failure" not in response.content.decode()
assert not job.path
temporary = (
Path(job.dataset.file.storage.location) / "formula-exports" / str(job.user_id) / f"{job.pk}.tmp"
)
assert not temporary.exists()
def test_formula_export_worker_removes_stale_published_file_after_failed_retry(workspace, monkeypatch):
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
job = FormulaExport.objects.get(pk=response.json()["id"])
target = Path(job.dataset.file.storage.location) / "formula-exports" / str(job.user_id) / f"{job.pk}.xlsx"
target.parent.mkdir(parents=True)
target.write_bytes(b"stale workbook")
monkeypatch.setattr(
"finance.exporting.build_formula_workbook",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("retry failed")),
)
with pytest.raises(ValueError, match="retry failed"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
assert job.path == ""
assert job.expires_at is None
assert not target.exists()
def test_formula_export_worker_treats_department_order_as_the_same_scope(workspace):
from finance.exporting import process_formula_export
ds, member, *_ = workspace
member.departments = ["外科", "儿科"]
member.save(update_fields=["departments"])
job = FormulaExport.objects.create(
dataset=ds,
user=ds.created_by,
context={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
departments=["外科", "儿科"],
status="processing",
)
member.departments = ["儿科", "外科"]
member.save(update_fields=["departments"])
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "succeeded"
def test_formula_export_worker_records_path_preparation_failure(workspace, settings):
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
job = FormulaExport.objects.get(pk=response.json()["id"])
job.status = "processing"
job.save(update_fields=["status"])
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
export_root.symlink_to(Path(settings.MEDIA_ROOT) / "elsewhere", target_is_directory=True)
with pytest.raises(ValueError, match="符号链接"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
from decimal import Decimal
ds, member, *_ = workspace
Fact.objects.create(
dataset=ds,
period="2025-08",
dimensions={"department": None, "product": "医疗服务"},
revenue=Decimal("10"),
cost=Decimal("4"),
gross=Decimal("6"),
customer="missing-department",
source_row=20,
)
Fact.objects.create(
dataset=ds,
period="2025-08",
dimensions={"department": "(未分类)", "product": "医疗服务"},
revenue=Decimal("10"),
cost=Decimal("4"),
gross=Decimal("6"),
customer="literal-unclassified",
source_row=21,
)
with pytest.raises(ValueError, match="与系统缺失值标签冲突"):
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
},
BytesIO(),
)
def test_formula_criteria_escape_excel_wildcards_and_quotes():
from finance.exporting import _checked_formula, _criteria
criteria = _criteria(
{"subject": 'A*?~"科'},
"department",
"儿科?*",
["2026-08", "2026-09"],
)
assert 'Data[subject],"=A~*~?~~""科"' in criteria
assert 'Data[department],"=儿科~?~*"' in criteria
assert 'Data[Period],">=2026-08"' in criteria
assert _criteria({}, "department", ">高值", ["2026-08"]).startswith('Data[department],"=>高值"')
with pytest.raises(ValueError, match="8,192"):
_checked_formula("=" + "A" * 8192)
with pytest.raises(ValueError, match="255"):
_criteria({}, "department", "科" * 255, ["2026-08"])
def test_formula_workbook_rejects_case_insensitive_group_collision(workspace):
ds, member, *_ = workspace
for source_row, label in ((20, "Team"), (21, "team")):
Fact.objects.create(
dataset=ds,
period="2026-08",
dimensions={"department": label, "product": "医疗服务"},
revenue=Decimal(10),
cost=Decimal(4),
gross=Decimal(6),
customer=f"case-{source_row}",
source_row=source_row,
)
with pytest.raises(ValueError, match="大小写"):
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
BytesIO(),
)
def test_formula_workbook_omits_default_sheet_and_writes_untrusted_labels_as_text(workspace):
from openpyxl import load_workbook
ds, member, *_ = workspace
dangerous = '=HYPERLINK("https://invalid")'
mapping = {**ds.mapping}
mapping["sheet"] = dangerous
mapping["columns"] = [dict(column) for column in mapping["columns"]]
mapping["columns"][1]["name"] = dangerous
mapping["columns"][3]["name"] = dangerous
mapping["self_operated_products"] = [dangerous]
ds.mapping = mapping
ds.save(update_fields=["mapping"])
ds.facts.update(dimensions={"department": dangerous, "product": "医疗服务"})
definition = custom_column()
definition["name"] = dangerous
output = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
output,
)
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
assert "Sheet" not in workbook.sheetnames
cells = [
workbook["分析汇总"]["D3"],
workbook["原始证据"]["A2"],
workbook["原始证据"]["C1"],
workbook["参数与口径"]["B2"],
workbook["参数与口径"]["B4"],
workbook["对账检查"]["B2"],
]
custom_reconcile = next(
cell for row in workbook["对账检查"].iter_rows() for cell in row if cell.value == dangerous
)
cells.append(custom_reconcile)
assert all(cell.value == dangerous and cell.data_type == "s" for cell in cells)
def test_formula_reconcile_uses_literal_unclassified_group(workspace):
from openpyxl import load_workbook
ds, member, *_ = workspace
Fact.objects.create(
dataset=ds,
period="2026-08",
dimensions={"department": "(未分类)", "product": "医疗服务"},
revenue=Decimal(25),
cost=Decimal(10),
gross=Decimal(15),
customer="unclassified",
list_amount=Decimal(30),
source_row=20,
raw=["2026-08", "", "医疗服务", 25, 10, 15, "unclassified", 30],
)
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)
reference = next(
row[3].value
for row in workbook["对账检查"].iter_rows(min_row=2)
if row[0].value == "实际收入" and row[1].value == "(未分类)" and row[2].value == "本期"
)
assert Decimal(reference) == Decimal(25)
def test_formula_workbook_rejects_dimension_keys_that_collide_with_reserved_headers(workspace):
ds, member, *_ = workspace
mapping = {**ds.mapping}
mapping["columns"] = [dict(column) for column in mapping["columns"]]
mapping["columns"][1]["key"] = "revenue"
ds.mapping = mapping
ds.save(update_fields=["mapping"])
with pytest.raises(ValueError, match="保留列名冲突"):
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "revenue"},
BytesIO(),
)
+126
View File
@@ -0,0 +1,126 @@
import json
from unittest.mock import patch
import pytest
from finance.formula_columns import available_metrics, ensure_available_metrics
from finance.formula_views import design_column
pytestmark = pytest.mark.django_db
def model_response(design):
return type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {
"status": "completed",
"output": [{"content": [{"type": "output_text", "text": json.dumps(design)}]}],
},
},
)()
def test_available_metrics_follow_dataset_mapping(workspace):
ds, *_ = workspace
assert set(available_metrics(ds)) == {
"revenue",
"cost",
"gross",
"roi",
"customers",
"basket",
"margin",
"discount_rate",
"self_revenue",
"upgrade_rate",
"list_basket",
}
ds.mapping = {
**ds.mapping,
"columns": [
column
for column in ds.mapping["columns"]
if column["role"] not in {"cost", "gross", "customer", "list_amount"}
],
"self_operated_products": [],
"margin_rates": {},
}
assert set(available_metrics(ds)) == {"revenue"}
with pytest.raises(ValueError, match="消费人数"):
ensure_available_metrics(ds, {"expression": {"metric": "customers"}})
def test_model_cannot_complete_definition_using_unavailable_metrics(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
definition = {
"name": "收入乘消费人数",
"meaning": "实际收入乘消费人数",
"scope": "按所选分组和期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "multiply",
"left": {"metric": "revenue"},
"right": {"metric": "customers"},
},
}
ds.mapping = {
**ds.mapping,
"columns": [column for column in ds.mapping["columns"] if column["role"] != "customer"],
}
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(
{"complete": True, "questions": [], "column": definition}
)
result = design_column(
[{"role": "user", "content": "增加收入乘消费人数列"}],
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
assert result["complete"] is False
assert result["column"] is None
assert "消费人数" in result["questions"][0]
payload = client.return_value.__enter__.return_value.post.call_args.kwargs["json"]
system = payload["input"][0]["content"]
assert '"customers": "消费人数"' in system.split('"unavailable_metrics":', 1)[1]
metric_schema = payload["text"]["format"]["schema"]["properties"]["column"]["anyOf"][1]["properties"][
"expression"
]["anyOf"][0]["properties"]["metric"]
assert "customers" not in metric_schema["enum"]
assert "revenue" in metric_schema["enum"]
def test_export_api_rejects_unavailable_metric_even_without_design_dialog(workspace):
ds, _, client, *_ = workspace
definition = {
"name": "人数列",
"meaning": "消费人数",
"scope": "按分组期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {"metric": "customers"},
}
ds.mapping = {
**ds.mapping,
"columns": [column for column in ds.mapping["columns"] if column["role"] != "customer"],
}
ds.save(update_fields=["mapping"])
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
content_type="application/json",
)
assert response.status_code == 400
assert "消费人数" in response.json()["error"]
+183
View File
@@ -0,0 +1,183 @@
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
from tests.test_formula_export_reliability import custom_column
pytestmark = pytest.mark.django_db
def test_formula_workbook_recalculates_with_libreoffice(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 = tmp_path / "source"
output = tmp_path / "recalculated"
source.mkdir()
output.mkdir()
workbook_path = source / "formula.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
},
workbook_file,
)
profile = tmp_path / "lo-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
recalculated = output / workbook_path.name
assert recalculated.is_file(), result.stderr or result.stdout
formulas = load_workbook(recalculated, data_only=False)
values = load_workbook(recalculated, data_only=True)
summary_formulas = formulas["分析汇总"]
summary_values = values["分析汇总"]
row = next(
row
for row in range(6, summary_formulas.max_row + 1)
if summary_formulas.cell(row, 1).value == "收入成本差"
)
assert summary_formulas.cell(row, 3).value.startswith("=")
assert summary_values.cell(row, 3).value == pytest.approx(60)
assert summary_values.cell(row, 4).value == pytest.approx(90)
assert summary_values.cell(row, 5).value == pytest.approx(-30)
assert summary_values.cell(row, 6).value == pytest.approx(-1 / 3)
errors = {
cell.value
for sheet in values
for row_cells in sheet.iter_rows()
for cell in row_cells
if cell.data_type == "e"
}
assert errors <= {"#N/A"}
def _recalculate(workbook_path, output, profile, executable):
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
recalculated = output / Path(workbook_path).name
assert recalculated.is_file(), result.stderr or result.stdout
return load_workbook(recalculated, data_only=True)
def test_custom_column_recalculates_after_detail_amount_edit(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 / "recalculated"
source.mkdir()
output.mkdir()
workbook_path = source / "edited-input.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
},
workbook_file,
)
edited = load_workbook(workbook_path)
edited["计算明细"]["E4"] = 140
edited.save(workbook_path)
values = _recalculate(workbook_path, output, tmp_path / "edit-profile", executable)
summary = values["分析汇总"]
row = next(
row
for row in range(6, summary.max_row + 1)
if summary.cell(row, 1).value == "收入成本差" and summary.cell(row, 2).value == "儿科"
)
assert summary.cell(row, 3).value == pytest.approx(120)
assert summary.cell(row, 5).value == pytest.approx(30)
def test_roi_zero_cost_and_missing_baseline_return_excel_na(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
ds.facts.filter(period="2026-08").update(cost=0)
source, output = tmp_path / "source", tmp_path / "recalculated"
source.mkdir()
output.mkdir()
workbook_path = source / "roi-edges.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "custom",
"baseline_end": "2024-08",
"group": "department",
},
workbook_file,
)
values = _recalculate(workbook_path, output, tmp_path / "roi-profile", executable)
summary = values["分析汇总"]
roi_row = next(
row
for row in range(6, summary.max_row + 1)
if summary.cell(row, 1).value == "ROI" and summary.cell(row, 2).value == "儿科"
)
assert summary.cell(roi_row, 3).value == "#N/A"
assert summary.cell(roi_row, 4).value == "#N/A"
errors = {
cell.value
for sheet in values
for row_cells in sheet.iter_rows()
for cell in row_cells
if cell.data_type == "e"
}
assert errors <= {"#N/A"}
+109
View File
@@ -0,0 +1,109 @@
import json
from io import BytesIO
from unittest.mock import patch
import pytest
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.views import APIError
pytestmark = pytest.mark.django_db
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def model_response(design):
return type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {
"status": "completed",
"output": [{"content": [{"type": "output_text", "text": json.dumps(design)}]}],
},
},
)()
def test_design_column_asks_for_missing_details_then_returns_safe_definition(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
missing = {"complete": False, "questions": ["请补充缺失值和零分母的处理方式。"], "column": None}
complete = {"complete": True, "questions": [], "column": custom_column()}
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(missing)
first = design_column([{"role": "user", "content": "增加收入减成本的新列"}], ds, ctx)
assert first == missing
messages = [
{"role": "user", "content": "增加收入减成本的新列"},
{"role": "assistant", "content": first["questions"][0]},
{"role": "user", "content": "缺失或零分母显示不可用"},
]
client.return_value.__enter__.return_value.post.return_value = model_response(complete)
result = design_column(messages, ds, ctx)
assert result == complete
payload = client.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["input"][-1] == messages[-1]
validate_column(result["column"])
def test_design_column_rejects_unsafe_model_definitions(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
invalid = {
"complete": True,
"questions": [],
"column": {**custom_column(), "expression": "=HYPERLINK()"},
}
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(invalid)
with pytest.raises(APIError, match="未创建工作簿"):
design_column([{"role": "user", "content": "随意算一个值"}], ds, ctx)
def test_formula_labels_from_ai_are_written_as_plain_text(workspace):
ds, member, *_ = workspace
definition = custom_column()
definition["name"] = '=HYPERLINK("https://invalid")'
validate_column(definition)
workbook_bytes = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
workbook_bytes,
)
workbook = load_workbook(BytesIO(workbook_bytes.getvalue()), data_only=False)
summary = workbook["分析汇总"]
cell = next(cell for row in summary.iter_rows() for cell in row if cell.value == definition["name"])
assert cell.data_type == "s"
+86
View File
@@ -0,0 +1,86 @@
import os
import shutil
import subprocess
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
pytestmark = pytest.mark.django_db
def _recalculate(executable, source, output, profile):
result = subprocess.run(
[
executable,
"--headless",
f"-env:UserInstallation={profile.as_uri()}",
"--convert-to",
"xlsx",
"--outdir",
str(output),
str(source),
],
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
recalculated = output / source.name
assert recalculated.is_file(), result.stderr or result.stdout
return recalculated
def test_direct_and_lookup_gross_recalculate_and_roll_up(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
ds.mapping = {**ds.mapping, "margin_rates": {"医疗服务": "0.25"}}
ds.save(update_fields=["mapping"])
lookup_fact = ds.facts.get(period="2026-08", source_row=5)
lookup_fact.raw[5] = ""
lookup_fact.save(update_fields=["raw"])
source_dir, output_dir = tmp_path / "source", tmp_path / "recalculated"
source_dir.mkdir()
output_dir.mkdir()
workbook_path = source_dir / "gross-sources.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
workbook_file,
)
recalculated = _recalculate(executable, workbook_path, output_dir, tmp_path / "lo-profile-gross")
formulas = load_workbook(recalculated, data_only=False)
values = load_workbook(recalculated, data_only=True)
details = values["计算明细"]
assert details["I4"].value == "direct"
assert details["I5"].value == "lookup"
assert details["N4"].value == pytest.approx(50)
assert details["N5"].value == pytest.approx(5)
summary_formula = formulas["分析汇总"]
summary_values = values["分析汇总"]
row = next(
row
for row in range(6, summary_formula.max_row + 1)
if summary_formula.cell(row, 1).value == "毛利贡献"
)
assert summary_formula.cell(row, 3).value.startswith("=")
assert summary_values.cell(row, 3).value == pytest.approx(55)
errors = {
cell.value
for sheet in values
for row_cells in sheet.iter_rows()
for cell in row_cells
if cell.data_type == "e"
}
assert errors <= {"#N/A"}
+30
View File
@@ -0,0 +1,30 @@
from io import BytesIO
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
pytestmark = pytest.mark.django_db
def test_reconcile_status_guards_non_numeric_difference(workspace):
ds, member, *_ = workspace
ds.periods = ["2025-08"]
ds.save(update_fields=["periods"])
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)
formulas = [
row[6].value
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 all("ISNUMBER(F" in formula for formula in formulas)
assert all('"通过"' in formula for formula in formulas)
+125
View File
@@ -0,0 +1,125 @@
import hashlib
import zipfile
from datetime import date
from decimal import Decimal
from io import BytesIO
import pytest
from django.contrib.auth.models import User
from finance.importing import (
decimal,
file_digest,
period_value,
process_dataset,
profile_file,
rows_for,
safe_text,
suggest_mapping,
validate_archive,
validate_mapping,
)
from finance.models import Dataset, Membership, Organization
pytestmark = pytest.mark.django_db
@pytest.fixture
def import_dataset(tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
org = Organization.objects.create(name="Import tests")
user = User.objects.create_user("importer", password="Import-TestPassword991!")
Membership.objects.create(user=user, organization=org, role="admin")
ds = Dataset.objects.create(
organization=org,
created_by=user,
name="sample.csv",
sha256="c" * 64,
status="queued",
mapping={
"sheet": "CSV",
"header": 1,
"columns": [
{"index": 0, "name": "期间", "role": "period"},
{"index": 1, "name": "科室", "role": "dimension", "key": "department"},
{"index": 2, "name": "产品一级分类", "role": "dimension", "key": "product"},
{"index": 3, "name": "实际收入", "role": "revenue"},
{"index": 4, "name": "毛利贡献", "role": "gross"},
{"index": 5, "name": "客户编码", "role": "customer"},
],
"roi_formula": "profit_over_cost",
"margin_rates": {"医疗服务": "0.6"},
},
)
source = tmp_path / "sample.csv"
source.write_text(
"期间,科室,产品一级分类,实际收入,毛利贡献,客户编码\n"
"2026-08,儿科,医疗服务,100,60,C1\n"
"2026-08,儿科,未知产品,25,,\n"
"\n",
encoding="utf-8",
)
ds.file.name = str(source)
ds.save(update_fields=["file"])
return ds
def test_csv_import_commits_rows_quality_and_derived_gross(import_dataset):
ds = process_dataset(import_dataset.pk)
assert ds.status == "ready"
assert ds.row_count == 2
assert ds.periods == ["2026-08"]
assert ds.quality["missing_cost"] == 2
assert ds.quality["missing_gross"] == 1
assert ds.quality["missing_customer"] == 1
assert ds.quality["blank_rows"] == 1
assert list(ds.facts.order_by("source_row").values_list("gross", flat=True)) == [
Decimal("60.000000000000000000000000"),
None,
]
assert ds.facts.get(source_row=2).dimensions["department"] == "儿科"
def test_profile_csv_mapping_and_file_digest(tmp_path):
source = tmp_path / "input.csv"
source.write_text("期间,科室,实际收入\n2026-08,儿科,12\n", encoding="utf-8")
profile = profile_file(source)
mapping = suggest_mapping(profile)
assert mapping["columns"][0]["role"] == "period"
assert mapping["columns"][2]["role"] == "revenue"
assert validate_mapping(mapping, profile) == mapping
from django.core.files.uploadedfile import SimpleUploadedFile
upload = SimpleUploadedFile("input.csv", source.read_bytes(), content_type="text/csv")
assert file_digest(upload) == hashlib.sha256(source.read_bytes()).hexdigest()
def test_safe_text_and_period_inputs():
assert safe_text(date(2026, 8, 3)) == "2026-08-03"
assert safe_text("x" * 5000) == "x" * 4000
assert period_value({"period": date(2026, 8, 3)}) == "2026-08"
assert period_value({"period": "2026-8-19T12:00:00"}) == "2026-08"
with pytest.raises(ValueError, match="精度"):
decimal("0.0000000000000000000000001")
def test_archive_rejects_macro_and_external_links():
for member in ("xl/vbaProject.bin", "xl/externalLinks/externalLink1.xml"):
archive_file = BytesIO()
with zipfile.ZipFile(archive_file, "w") as archive:
archive.writestr(member, "blocked")
archive_file.seek(0)
with pytest.raises(ValueError, match="宏或外部链接"):
validate_archive(archive_file)
def test_workbook_rows_can_read_formulas_from_selected_sheet(tmp_path):
from openpyxl import Workbook
path = tmp_path / "book.xlsx"
workbook = Workbook()
workbook.active.title = "Data"
workbook.active.append(["value"])
workbook.active.append(["=1+1"])
workbook.save(path)
assert list(rows_for(path, "Data", formulas=True)) == [("value",), ("=1+1",)]
+94
View File
@@ -0,0 +1,94 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import User
from django.core.management import call_command
from django.core.management.base import CommandError
from django.utils import timezone
from finance.models import Audit, FormulaExport
pytestmark = pytest.mark.django_db
def test_bootstrap_creates_admin_once_and_rejects_duplicate(workspace, monkeypatch):
monkeypatch.setenv("BOOTSTRAP_PASSWORD", "Bootstrap-TestPassword991!")
with pytest.raises(CommandError, match="already exists"):
call_command("bootstrap", username="finance-admin", organization="Duplicate")
user_count = User.objects.count()
call_command("bootstrap", username="new-admin", organization="New Organization")
user = User.objects.get(username="new-admin")
assert user.check_password("Bootstrap-TestPassword991!")
assert user.membership.role == "admin"
assert User.objects.count() == user_count + 1
def test_cli_import_previews_then_queues_and_deduplicates(workspace, tmp_path):
ds, _, _, _, _ = workspace
source = tmp_path / "cli.csv"
source.write_text("期间,科室,实际收入\n2026-08,儿科,10\n", encoding="utf-8")
call_command("import_file", str(source), username=ds.created_by.username)
assert not Audit.objects.filter(action="dataset.cli_import").exists()
call_command("import_file", str(source), username=ds.created_by.username, confirm_mapping=True)
assert Audit.objects.filter(action="dataset.cli_import").count() == 1
call_command("import_file", str(source), username=ds.created_by.username, confirm_mapping=True)
assert Audit.objects.filter(action="dataset.cli_import").count() == 1
def test_worker_processes_queued_import_and_formula_export(workspace):
ds, _, _, *_ = workspace
job = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={"custom_columns": []})
from unittest.mock import patch
with patch("finance.management.commands.import_worker.process_formula_export", return_value={"rows": 5}):
call_command("import_worker", once=True)
job.refresh_from_db()
assert job.status == "processing"
def test_formula_export_status_download_and_access_controls(workspace, settings, tmp_path):
ds, _, client, _, other_client = workspace
url = f"/api/datasets/{ds.pk}"
job = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={}, status="queued")
status_url = f"{url}/formula-exports/{job.pk}"
assert client.get(status_url).json()["status"] == "queued"
assert client.get(f"{status_url}/download").status_code == 409
assert other_client.get(status_url).status_code == 404
job.status = "succeeded"
job.expires_at = timezone.now() + timedelta(hours=1)
target = tmp_path / "formula-exports" / str(ds.created_by_id) / f"{job.pk}.xlsx"
target.parent.mkdir(parents=True)
target.write_bytes(b"xlsx")
job.path = str(target)
job.save()
response = client.get(f"{status_url}/download")
assert response.status_code == 200
assert response["Content-Disposition"].endswith('"traceiq-formula-analysis.xlsx"')
other_target = target.with_name("another-task.xlsx")
other_target.write_bytes(b"other")
job.path = str(other_target)
job.save(update_fields=["path"])
assert client.get(f"{status_url}/download").status_code == 410
job.path = str(target)
job.save(update_fields=["path"])
target.unlink()
target.symlink_to(other_target)
assert client.get(f"{status_url}/download").status_code == 410
target.unlink()
target.write_bytes(b"xlsx")
job.departments = ["儿科"]
job.save(update_fields=["departments"])
assert client.get(status_url).status_code == 403
def test_formula_export_create_validates_definition_and_records_job(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/formula-exports"
ctx = {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"}
response = client.post(url, data=ctx, content_type="application/json")
assert response.status_code == 202
assert FormulaExport.objects.filter(dataset=ds, status="queued").exists()
+19
View File
@@ -0,0 +1,19 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
pytestmark = pytest.mark.django_db
def test_upload_rejects_empty_oversized_and_unsupported_files(workspace, settings):
_, _, client, *_ = workspace
settings.MAX_UPLOAD_BYTES = 4
assert (
client.post("/api/datasets", data={"file": SimpleUploadedFile("empty.csv", b"")}).status_code == 400
)
assert (
client.post("/api/datasets", data={"file": SimpleUploadedFile("large.csv", b"12345")}).status_code
== 400
)
settings.MAX_UPLOAD_BYTES = 1024
response = client.post("/api/datasets", data={"file": SimpleUploadedFile("invalid.txt", b"abc")})
assert response.status_code == 400
+144
View File
@@ -0,0 +1,144 @@
import json
import pytest
from django.contrib.auth.models import User
from django.test import Client
from finance.models import Audit, Investigation, Membership
def test_api_auth_method_and_request_shape_guards(workspace):
ds, _, client, *_ = workspace
anonymous = Client()
assert anonymous.get(f"/api/datasets/{ds.pk}").status_code == 401
assert client.get(f"/api/datasets/{ds.pk}/query").status_code == 405
assert (
client.post(f"/api/datasets/{ds.pk}/query", data="[]", content_type="application/json").status_code
== 400
)
assert (
client.post(f"/api/datasets/{ds.pk}/query", data="{}", content_type="text/plain").status_code == 400
)
bad_login = anonymous.post(
"/api/login", data=json.dumps({"username": "x", "password": "wrong"}), content_type="application/json"
)
assert bad_login.status_code == 401
assert anonymous.post("/api/logout").status_code == 401
pytestmark = pytest.mark.django_db
def test_session_login_logout_and_health_contract(workspace):
ds, _, _, _, _ = workspace
client = Client()
assert client.get("/api/health").json() == {"status": "ok"}
session = client.get("/api/session").json()
assert session["user"] is None
login = client.post(
"/api/login",
data=json.dumps({"username": ds.created_by.username, "password": "Finance-TestPassword991!"}),
content_type="application/json",
)
assert login.status_code == 200
assert client.get("/api/session").json()["user"]["role"] == "admin"
assert client.post("/api/logout").json() == {"ok": True}
assert client.get("/api/session").json()["user"] is None
assert Audit.objects.filter(action__in=["auth.login", "auth.logout"]).count() == 2
def test_user_management_enforces_org_scope_and_self_protection(workspace):
ds, _, client, _, _ = workspace
base = {"role": "analyst", "departments": ["儿科"]}
created = client.post(
"/api/users",
data=json.dumps({**base, "username": "managed-user", "password": "Managed-TestPassword991!"}),
content_type="application/json",
)
assert created.status_code == 200
managed = User.objects.get(username="managed-user")
membership = Membership.objects.get(user=managed)
assert membership.organization == ds.organization
assert membership.departments == ["儿科"]
denied = client.post(
"/api/users",
data=json.dumps({**base, "username": "weak-user", "password": "123"}),
content_type="application/json",
)
assert denied.status_code == 400
assert (
client.patch(
"/api/users",
data=json.dumps({"id": ds.created_by_id, "active": False, **base}),
content_type="application/json",
).status_code
== 400
)
assert (
client.patch(
"/api/users",
data=json.dumps({"id": managed.pk, "active": False, "role": "viewer", "departments": []}),
content_type="application/json",
).status_code
== 200
)
managed.refresh_from_db()
membership.refresh_from_db()
assert not managed.is_active
assert membership.role == "viewer"
assert client.get("/api/users").status_code == 200
assert client.get("/api/audit").status_code == 200
assert Audit.objects.filter(action="user.update").count() == 2
def test_investigation_lifecycle_and_viewer_write_denial(workspace):
ds, _, client, _, _ = workspace
context = {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"}
url = f"/api/datasets/{ds.pk}/investigations"
saved = client.post(
url,
data=json.dumps({**context, "status": "reviewing", "note": "复核收入成本"}),
content_type="application/json",
)
assert saved.status_code == 200
assert Investigation.objects.filter(dataset=ds, status="reviewing", note="复核收入成本").exists()
assert client.get(url).json()["items"][0]["status"] == "reviewing"
invalid = client.post(
url,
data=json.dumps({**context, "status": "closed", "note": ""}),
content_type="application/json",
)
assert invalid.status_code == 400
viewer = User.objects.create_user("finance-viewer", password="Finance-TestPassword991!")
Membership.objects.create(user=viewer, organization=ds.organization, role="viewer")
readonly = Client()
assert readonly.login(username=viewer.username, password="Finance-TestPassword991!")
denied = readonly.post(
url,
data=json.dumps({**context, "status": "resolved", "note": ""}),
content_type="application/json",
)
assert denied.status_code == 403
def test_evidence_rejects_bad_pagination_and_exports_exact_xlsx(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/evidence"
invalid = client.post(
url,
data=json.dumps({"start": "2026-08", "end": "2026-08", "limit": 201}),
content_type="application/json",
)
assert invalid.status_code == 400
exported = client.post(
f"/api/datasets/{ds.pk}/export",
data=json.dumps({"start": "2026-08", "end": "2026-08", "format": "xlsx", "group": "department"}),
content_type="application/json",
)
assert exported.status_code == 200
assert exported.streaming
assert exported["Content-Disposition"].endswith('"traceiq-evidence.xlsx"')
assert b"".join(exported.streaming_content).startswith(b"PK")
+28
View File
@@ -0,0 +1,28 @@
from unittest.mock import patch
import pytest
from django.core.management import call_command
from finance.models import FormulaExport
pytestmark = pytest.mark.django_db
def test_worker_processes_queued_import_and_reports_export_failure(workspace):
ds, *_ = workspace
ds.status = "queued"
ds.save(update_fields=["status"])
with patch(
"finance.management.commands.import_worker.process_dataset", return_value=ds
) as process_import:
call_command("import_worker", once=True)
process_import.assert_called_once_with(ds.pk)
export = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={})
with patch(
"finance.management.commands.import_worker.process_formula_export",
side_effect=ValueError("invalid export"),
):
call_command("import_worker", once=True)
export.refresh_from_db()
assert export.status == "processing"