feat(analysis): 网页汇总表自定义列 #7

Open
jianwel wants to merge 1 commits from feat/web-custom-columns into fix/formula-export-reliability
7 changed files with 216 additions and 40 deletions
+44 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+61 -2
View File
@@ -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>
);
}
+1 -1
View File
@@ -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
View File
@@ -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 }) => {
+30
View File
@@ -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}"