This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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",)]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user