feat(analysis): 网页汇总表自定义列 #7
+44
-10
@@ -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),
|
||||
|
||||
+2
-1
@@ -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("调查状态或备注无效")
|
||||
|
||||
+43
-6
@@ -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<Context>(),
|
||||
[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<SavedFormulaDefinition[]>([]);
|
||||
const upload = useRef<HTMLInputElement>(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<ColDef<Row>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -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() {
|
||||
}
|
||||
</th>
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.filter((m) => m !== "customers" && m in labels)
|
||||
.map((m) => (
|
||||
<th key={m}>{labels[m]}</th>
|
||||
<th key={m}>{metricLabel(m)}</th>
|
||||
))}
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
@@ -1068,7 +1078,7 @@ export function App() {
|
||||
<tr key={r.label}>
|
||||
<th>{r.label}</th>
|
||||
{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() {
|
||||
<div className="investigation-band">
|
||||
<section>
|
||||
<div className="section-heading">
|
||||
<h2>{labels[metric]}变化分解</h2>
|
||||
<h2>{metricLabel(metric)}变化分解</h2>
|
||||
<span>
|
||||
{ctx.start} 至 {ctx.end}
|
||||
</span>
|
||||
@@ -1224,6 +1234,22 @@ export function App() {
|
||||
>
|
||||
<Calculator />
|
||||
</button>
|
||||
{!user.departments.length && (
|
||||
<button
|
||||
aria-label="选择网页自定义列"
|
||||
title="选择网页自定义列"
|
||||
onClick={() =>
|
||||
api<{ items: SavedFormulaDefinition[] }>(`/datasets/${ds.id}/formula-definitions`)
|
||||
.then((data) => {
|
||||
setFormulaDefinitions(data.items);
|
||||
setModal("columns");
|
||||
})
|
||||
.catch(report)
|
||||
}
|
||||
>
|
||||
自定义列
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon"
|
||||
aria-label="导出当前范围"
|
||||
@@ -1443,6 +1469,17 @@ export function App() {
|
||||
{modal === "formula" && ds && ctx && (
|
||||
<FormulaExportDialog ds={ds} ctx={ctx} onClose={() => setModal(null)} />
|
||||
)}
|
||||
{modal === "columns" && ds && ctx && (
|
||||
<FormulaColumnsDialog
|
||||
definitions={formulaDefinitions}
|
||||
selected={ctx.custom_columns || []}
|
||||
onClose={() => setModal(null)}
|
||||
onApply={(columns: FormulaColumn[]) => {
|
||||
setCtx({ ...ctx, custom_columns: columns });
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === "settings" && ds && (
|
||||
<SettingsDialog
|
||||
ds={ds}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "./api";
|
||||
import {
|
||||
labels,
|
||||
roleLabels,
|
||||
type AIConfig,
|
||||
type Context,
|
||||
type Dataset,
|
||||
type FormulaColumn,
|
||||
type FormulaExpression,
|
||||
type SavedFormulaDefinition,
|
||||
type Dataset,
|
||||
type Metric,
|
||||
type Preferences,
|
||||
type Session,
|
||||
@@ -1158,3 +1165,55 @@ export function FormulaExportDialog({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormulaColumnsDialog({
|
||||
definitions,
|
||||
selected,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
definitions: SavedFormulaDefinition[];
|
||||
selected: FormulaColumn[];
|
||||
onApply: (columns: FormulaColumn[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const selectedNames = new Set(selected.map((column) => column.name));
|
||||
const [checked, setChecked] = useState(
|
||||
() => new Set(definitions.filter((item) => selectedNames.has(item.definition.name)).map((item) => item.id)),
|
||||
);
|
||||
function toggle(id: string) {
|
||||
setChecked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 10) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Modal title="选择网页自定义列" onClose={onClose}>
|
||||
<div className="dialog-body">
|
||||
<p>已保存定义会由服务端统一计算并显示在分析表格中,最多选择 10 列。</p>
|
||||
{!definitions.length && <p className="muted">暂无已保存定义,请先在公式工作簿中保存。</p>}
|
||||
{definitions.map((item) => (
|
||||
<label className="check" key={item.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
/>
|
||||
{item.definition.name}:{item.definition.meaning}{item.owned ? "" : `(${item.owner}共享)`}
|
||||
</label>
|
||||
))}
|
||||
<footer>
|
||||
<button onClick={onClose}>取消</button>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => onApply(definitions.filter((item) => checked.has(item.id)).map((item) => item.definition))}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export interface Value {
|
||||
}
|
||||
export interface Row {
|
||||
label: string;
|
||||
values: Record<Metric, Value>;
|
||||
values: Record<string, Value>;
|
||||
rows: number;
|
||||
baseline_rows: number;
|
||||
anomaly: boolean;
|
||||
|
||||
+35
-20
@@ -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 }) => {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user