From adce23bbd93607f030773ddcfee81df94d8ed870 Mon Sep 17 00:00:00 2001 From: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com> Date: Sun, 11 Oct 2026 09:45:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E7=BD=91=E9=A1=B5=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E8=A1=A8=E8=87=AA=E5=AE=9A=E4=B9=89=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyze 支持 custom_columns:白名单表达式树由服务端 Decimal 求值, 自定义列进入汇总、分组行与明细(缺失保持不可用,不转零) - 新增「选择网页自定义列」弹窗:从定义库(含共享)多选最多 10 列, 应用后进入主分析表格与热力图;公式工作簿可一键导出网页已选列 - 请求去重纳入自定义列;Playwright 覆盖选择后显示与数值断言 --- finance/analysis.py | 54 +++++++++++++++++++++++++------ finance/views.py | 3 +- frontend/src/App.tsx | 49 +++++++++++++++++++++++++---- frontend/src/dialogs.tsx | 63 +++++++++++++++++++++++++++++++++++-- frontend/src/types.ts | 2 +- tests/e2e/workspace.spec.ts | 55 ++++++++++++++++++++------------ tests/test_finance.py | 30 ++++++++++++++++++ 7 files changed, 216 insertions(+), 40 deletions(-) diff --git a/finance/analysis.py b/finance/analysis.py index 70f5bce..9578859 100644 --- a/finance/analysis.py +++ b/finance/analysis.py @@ -167,9 +167,9 @@ def metrics(agg, formula, products=None): } -def comparison(current, baseline, comparable): +def comparison(current, baseline, comparable, keys=None): result = {} - for key in METRICS: + for key in keys or METRICS: c, b = current.get(key), baseline.get(key) delta = c - b if comparable and c is not None and b is not None else None change = Decimal(delta) / abs(Decimal(b)) if delta is not None and b != 0 else None @@ -177,8 +177,31 @@ def comparison(current, baseline, comparable): return result +def custom_columns(ds, body): + from .formula_columns import ensure_available_metrics, validate_column + + columns = body.get("custom_columns", []) + if columns is None: + return [] + if not isinstance(columns, list) or len(columns) > 10: + raise ValueError("自定义列最多 10 个") + result = [validate_column(column) for column in columns] + for column in result: + ensure_available_metrics(ds, column) + return result + + +def custom_values(values, columns): + from .formula_columns import evaluate + + return {f"custom_{index}": evaluate(column["expression"], values) for index, column in enumerate(columns)} + + def analyze(ds, member, body, preferences): - ctx = context(ds, body) + columns = custom_columns(ds, body) + ctx = {**context(ds, body), "custom_columns": columns} + custom_keys = [f"custom_{index}" for index in range(len(columns))] + value_keys = [*METRICS, *custom_keys] qs = scoped_facts(ds, member, ctx["filters"]) formula = ds.mapping["roi_formula"] products = ds.mapping.get("self_operated_products", []) @@ -190,8 +213,14 @@ def analyze(ds, member, body, preferences): ) comparable = not ctx["missing"] if set(ctx["current"]) - set(ds.periods): - total_c = {**total_c, **{key: None for key in METRICS}} - summary = comparison(total_c, total_b, comparable) + total_c = {**total_c, **{key: None for key in value_keys}} + current_missing = bool(set(ctx["current"]) - set(ds.periods)) + total_c = { + **total_c, + **({key: None for key in custom_keys} if current_missing else custom_values(total_c, columns)), + } + total_b = {**total_b, **custom_values(total_b, columns)} + summary = comparison(total_c, total_b, comparable, value_keys) group = "dimensions__" + ctx["group"] buckets = {} for side in ("current", "baseline"): @@ -205,7 +234,7 @@ def analyze(ds, member, body, preferences): k: Decimal(0) if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None else None - for k in METRICS + for k in value_keys }, } @@ -217,8 +246,10 @@ def analyze(ds, member, body, preferences): for label, sides in buckets.items(): c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b)) if set(ctx["current"]) - set(ds.periods): - c = {**c, **{key: None for key in METRICS}} - values = comparison(c, b, comparable) + c = {**c, **{key: None for key in value_keys}} + c = {**c, **({key: None for key in custom_keys} if current_missing else custom_values(c, columns))} + b = {**b, **custom_values(b, columns)} + values = comparison(c, b, comparable, value_keys) v = values[metric] change, delta = v["change"], v["delta"] impact = ( @@ -249,7 +280,10 @@ def analyze(ds, member, body, preferences): ) rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"])) observed = { - r["period"]: metrics(r, formula, products) + r["period"]: { + **(values := metrics(r, formula, products)), + **custom_values(values, columns), + } for r in qs.values("period").annotate(**aggregations(products)) } trend = [] @@ -257,7 +291,7 @@ def analyze(ds, member, body, preferences): period = month_text(index) values = observed.get(period) if values is None: - values = empty_side(total_c) if period in ds.periods else {key: None for key in METRICS} + values = empty_side(total_c) if period in ds.periods else {key: None for key in value_keys} trend.append({"period": period, **values}) return { "dataset_id": str(ds.pk), diff --git a/finance/views.py b/finance/views.py index e4c5310..5918efb 100644 --- a/finance/views.py +++ b/finance/views.py @@ -22,7 +22,7 @@ from django.utils import timezone from openpyxl import Workbook from openpyxl.cell import WriteOnlyCell -from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts +from .analysis import METRICS, analyze, context, custom_columns, default_preferences, dimensions, scoped_facts from .importing import file_digest, profile_file, suggest_mapping, validate_mapping from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle @@ -521,6 +521,7 @@ def investigations(request, dataset_id): raise APIError("查看者不可修改调查状态", 403) data = body(request) ctx = context(ds, data.get("context", {})) + ctx["custom_columns"] = custom_columns(ds, data.get("context", {})) status, note = data.get("status"), data.get("note", "") if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000: raise APIError("调查状态或备注无效") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0639361..b0c7d8e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ import { Chart } from "./Chart"; import { AdminDialog, AIConfigDialog, + FormulaColumnsDialog, FormulaExportDialog, Copilot, EvidenceDialog, @@ -49,6 +50,8 @@ import { type Analysis, type Context, type Dataset, + type FormulaColumn, + type SavedFormulaDefinition, type Metric, type Preferences, type Row, @@ -89,6 +92,7 @@ export function App() { | "ai" | null | "formula" + | "columns" >(null), [evidenceCtx, setEvidenceCtx] = useState(), [chat, setChat] = useState(false), @@ -98,6 +102,7 @@ export function App() { [note, setNote] = useState(""), [status, setStatus] = useState("open"), [saved, setSaved] = useState(false); + const [formulaDefinitions, setFormulaDefinitions] = useState([]); const upload = useRef(null), requestVersion = useRef(0); const report = useCallback( @@ -226,6 +231,8 @@ export function App() { c.comparison === current.comparison && c.metric === current.metric && c.group === current.group && + JSON.stringify(c.custom_columns || []) === + JSON.stringify(current.custom_columns || []) && Object.keys(c.filters).length === Object.keys(current.filters).length && Object.entries(c.filters).every( @@ -324,8 +331,11 @@ export function App() { const available = candidates.filter( (m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null), ); - return available.length ? available : (["revenue"] as Metric[]); + const custom = (ctx?.custom_columns || []).map((_, index) => `custom_${index}`); + return [...(available.length ? available : ["revenue"]), ...custom]; }, [result, ctx]); + const metricLabel = (metric: string) => + labels[metric as Metric] || ctx?.custom_columns?.[Number(metric.slice(7))]?.name || metric; const columns = useMemo[]>( () => [ { @@ -347,7 +357,7 @@ export function App() { }, ...metricKeys.map((metric) => ({ colId: metric, - headerName: labels[metric], + headerName: metricLabel(metric), minWidth: 130, valueGetter: (p: { data?: Row }) => p.data?.values[metric]?.current == null @@ -1056,9 +1066,9 @@ export function App() { } {metricKeys - .filter((m) => m !== "customers") + .filter((m) => m !== "customers" && m in labels) .map((m) => ( - {labels[m]} + {metricLabel(m)} ))} 状态 @@ -1068,7 +1078,7 @@ export function App() { {r.label} {metricKeys - .filter((m) => m !== "customers") + .filter((m) => m !== "customers" && m in labels) .map((m) => { const v = r.values[m]?.change; return ( @@ -1115,7 +1125,7 @@ export function App() {
-

{labels[metric]}变化分解

+

{metricLabel(metric)}变化分解

{ctx.start} 至 {ctx.end} @@ -1224,6 +1234,22 @@ export function App() { > + {!user.departments.length && ( + + )} + + +
+ + ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 24c9378..0008087 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -133,7 +133,7 @@ export interface Value { } export interface Row { label: string; - values: Record; + values: Record; rows: number; baseline_rows: number; anomaly: boolean; diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts index b262472..0317a81 100644 --- a/tests/e2e/workspace.spec.ts +++ b/tests/e2e/workspace.spec.ts @@ -180,38 +180,53 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导 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" }, + 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 + .getByLabel("列计算描述") + .fill("新增收入成本差,缺失或零分母显示不可用"); await formulaDialog.getByRole("button", { name: "发送描述" }).click(); await expect(formulaDialog).toContainText("缺失值和零分母如何处理?"); - await formulaDialog.getByLabel("列计算描述").fill("缺失项和零分母都按不可用处理"); + await formulaDialog + .getByLabel("列计算描述") + .fill("缺失项和零分母都按不可用处理"); await formulaDialog.getByRole("button", { name: "发送描述" }).click(); - await formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click(); - await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 }); + 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"); + expect((await formulaDownload).suggestedFilename()).toBe( + "traceiq-formula-analysis.xlsx", + ); await formulaDialog.getByLabel("关闭").click(); }); test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => { diff --git a/tests/test_finance.py b/tests/test_finance.py index 33e3afe..fafdeb3 100644 --- a/tests/test_finance.py +++ b/tests/test_finance.py @@ -85,6 +85,36 @@ def test_metrics_and_analysis_preserve_roi_refunds_and_distinct_customers(worksp assert unavailable["self_revenue"] == 0 +def test_analysis_computes_saved_custom_columns_in_summary_and_rows(workspace): + ds, member, *_ = workspace + column = { + "name": "收入成本差", + "meaning": "收入减成本", + "scope": "按分组计算", + "missing": "结果不可用(#N/A);零分母不可用", + "expression": { + "operator": "subtract", + "left": {"metric": "revenue"}, + "right": {"metric": "cost"}, + }, + } + result = analyze( + ds, + member, + { + "start": "2026-08", + "end": "2026-08", + "comparison": "yoy", + "group": "department", + "custom_columns": [column], + }, + default_preferences(ds), + ) + assert result["summary"]["custom_0"]["current"] == Decimal("45") + assert result["summary"]["custom_0"]["baseline"] == Decimal("90") + assert result["rows"][0]["values"]["custom_0"]["current"] == Decimal("60") + + def test_dataset_and_evidence_are_organization_and_department_scoped(workspace): ds, _, _, _, other_client = workspace url = f"/api/datasets/{ds.pk}"