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 = {} result = {}
for key in METRICS: for key in keys or METRICS:
c, b = current.get(key), baseline.get(key) 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 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 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 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): 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"]) qs = scoped_facts(ds, member, ctx["filters"])
formula = ds.mapping["roi_formula"] formula = ds.mapping["roi_formula"]
products = ds.mapping.get("self_operated_products", []) products = ds.mapping.get("self_operated_products", [])
@@ -190,8 +213,14 @@ def analyze(ds, member, body, preferences):
) )
comparable = not ctx["missing"] comparable = not ctx["missing"]
if set(ctx["current"]) - set(ds.periods): if set(ctx["current"]) - set(ds.periods):
total_c = {**total_c, **{key: None for key in METRICS}} total_c = {**total_c, **{key: None for key in value_keys}}
summary = comparison(total_c, total_b, comparable) 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"] group = "dimensions__" + ctx["group"]
buckets = {} buckets = {}
for side in ("current", "baseline"): for side in ("current", "baseline"):
@@ -205,7 +234,7 @@ def analyze(ds, member, body, preferences):
k: Decimal(0) k: Decimal(0)
if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None
else 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(): for label, sides in buckets.items():
c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b)) c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b))
if set(ctx["current"]) - set(ds.periods): if set(ctx["current"]) - set(ds.periods):
c = {**c, **{key: None for key in METRICS}} c = {**c, **{key: None for key in value_keys}}
values = comparison(c, b, comparable) 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] v = values[metric]
change, delta = v["change"], v["delta"] change, delta = v["change"], v["delta"]
impact = ( impact = (
@@ -249,7 +280,10 @@ def analyze(ds, member, body, preferences):
) )
rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"])) rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"]))
observed = { 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)) for r in qs.values("period").annotate(**aggregations(products))
} }
trend = [] trend = []
@@ -257,7 +291,7 @@ def analyze(ds, member, body, preferences):
period = month_text(index) period = month_text(index)
values = observed.get(period) values = observed.get(period)
if values is None: 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}) trend.append({"period": period, **values})
return { return {
"dataset_id": str(ds.pk), "dataset_id": str(ds.pk),
+2 -1
View File
@@ -22,7 +22,7 @@ from django.utils import timezone
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell 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 .importing import file_digest, profile_file, suggest_mapping, validate_mapping
from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle
@@ -521,6 +521,7 @@ def investigations(request, dataset_id):
raise APIError("查看者不可修改调查状态", 403) raise APIError("查看者不可修改调查状态", 403)
data = body(request) data = body(request)
ctx = context(ds, data.get("context", {})) ctx = context(ds, data.get("context", {}))
ctx["custom_columns"] = custom_columns(ds, data.get("context", {}))
status, note = data.get("status"), data.get("note", "") status, note = data.get("status"), data.get("note", "")
if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000: if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000:
raise APIError("调查状态或备注无效") raise APIError("调查状态或备注无效")
+43 -6
View File
@@ -35,6 +35,7 @@ import { Chart } from "./Chart";
import { import {
AdminDialog, AdminDialog,
AIConfigDialog, AIConfigDialog,
FormulaColumnsDialog,
FormulaExportDialog, FormulaExportDialog,
Copilot, Copilot,
EvidenceDialog, EvidenceDialog,
@@ -49,6 +50,8 @@ import {
type Analysis, type Analysis,
type Context, type Context,
type Dataset, type Dataset,
type FormulaColumn,
type SavedFormulaDefinition,
type Metric, type Metric,
type Preferences, type Preferences,
type Row, type Row,
@@ -89,6 +92,7 @@ export function App() {
| "ai" | "ai"
| null | null
| "formula" | "formula"
| "columns"
>(null), >(null),
[evidenceCtx, setEvidenceCtx] = useState<Context>(), [evidenceCtx, setEvidenceCtx] = useState<Context>(),
[chat, setChat] = useState(false), [chat, setChat] = useState(false),
@@ -98,6 +102,7 @@ export function App() {
[note, setNote] = useState(""), [note, setNote] = useState(""),
[status, setStatus] = useState("open"), [status, setStatus] = useState("open"),
[saved, setSaved] = useState(false); [saved, setSaved] = useState(false);
const [formulaDefinitions, setFormulaDefinitions] = useState<SavedFormulaDefinition[]>([]);
const upload = useRef<HTMLInputElement>(null), const upload = useRef<HTMLInputElement>(null),
requestVersion = useRef(0); requestVersion = useRef(0);
const report = useCallback( const report = useCallback(
@@ -226,6 +231,8 @@ export function App() {
c.comparison === current.comparison && c.comparison === current.comparison &&
c.metric === current.metric && c.metric === current.metric &&
c.group === current.group && c.group === current.group &&
JSON.stringify(c.custom_columns || []) ===
JSON.stringify(current.custom_columns || []) &&
Object.keys(c.filters).length === Object.keys(c.filters).length ===
Object.keys(current.filters).length && Object.keys(current.filters).length &&
Object.entries(c.filters).every( Object.entries(c.filters).every(
@@ -324,8 +331,11 @@ export function App() {
const available = candidates.filter( const available = candidates.filter(
(m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null), (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]); }, [result, ctx]);
const metricLabel = (metric: string) =>
labels[metric as Metric] || ctx?.custom_columns?.[Number(metric.slice(7))]?.name || metric;
const columns = useMemo<ColDef<Row>[]>( const columns = useMemo<ColDef<Row>[]>(
() => [ () => [
{ {
@@ -347,7 +357,7 @@ export function App() {
}, },
...metricKeys.map((metric) => ({ ...metricKeys.map((metric) => ({
colId: metric, colId: metric,
headerName: labels[metric], headerName: metricLabel(metric),
minWidth: 130, minWidth: 130,
valueGetter: (p: { data?: Row }) => valueGetter: (p: { data?: Row }) =>
p.data?.values[metric]?.current == null p.data?.values[metric]?.current == null
@@ -1056,9 +1066,9 @@ export function App() {
} }
</th> </th>
{metricKeys {metricKeys
.filter((m) => m !== "customers") .filter((m) => m !== "customers" && m in labels)
.map((m) => ( .map((m) => (
<th key={m}>{labels[m]}</th> <th key={m}>{metricLabel(m)}</th>
))} ))}
<th>状态</th> <th>状态</th>
</tr> </tr>
@@ -1068,7 +1078,7 @@ export function App() {
<tr key={r.label}> <tr key={r.label}>
<th>{r.label}</th> <th>{r.label}</th>
{metricKeys {metricKeys
.filter((m) => m !== "customers") .filter((m) => m !== "customers" && m in labels)
.map((m) => { .map((m) => {
const v = r.values[m]?.change; const v = r.values[m]?.change;
return ( return (
@@ -1115,7 +1125,7 @@ export function App() {
<div className="investigation-band"> <div className="investigation-band">
<section> <section>
<div className="section-heading"> <div className="section-heading">
<h2>{labels[metric]}变化分解</h2> <h2>{metricLabel(metric)}变化分解</h2>
<span> <span>
{ctx.start} 至 {ctx.end} {ctx.start} 至 {ctx.end}
</span> </span>
@@ -1224,6 +1234,22 @@ export function App() {
> >
<Calculator /> <Calculator />
</button> </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 <button
className="icon" className="icon"
aria-label="导出当前范围" aria-label="导出当前范围"
@@ -1443,6 +1469,17 @@ export function App() {
{modal === "formula" && ds && ctx && ( {modal === "formula" && ds && ctx && (
<FormulaExportDialog ds={ds} ctx={ctx} onClose={() => setModal(null)} /> <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 && ( {modal === "settings" && ds && (
<SettingsDialog <SettingsDialog
ds={ds} ds={ds}
+61 -2
View File
@@ -1,15 +1,22 @@
import { useEffect, useRef, useState } from "react"; 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 { api } from "./api";
import { import {
labels, labels,
roleLabels, roleLabels,
type AIConfig, type AIConfig,
type Context, type Context,
type Dataset,
type FormulaColumn, type FormulaColumn,
type FormulaExpression, type FormulaExpression,
type SavedFormulaDefinition, type SavedFormulaDefinition,
type Dataset,
type Metric, type Metric,
type Preferences, type Preferences,
type Session, type Session,
@@ -1158,3 +1165,55 @@ export function FormulaExportDialog({
</Modal> </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 { export interface Row {
label: string; label: string;
values: Record<Metric, Value>; values: Record<string, Value>;
rows: number; rows: number;
baseline_rows: number; baseline_rows: number;
anomaly: boolean; anomaly: boolean;
+35 -20
View File
@@ -180,38 +180,53 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await page.route("**/formula-design", async (route) => { await page.route("**/formula-design", async (route) => {
designCalls++; designCalls++;
await route.fulfill({ await route.fulfill({
json: designCalls === 1 json:
? { complete: false, questions: ["缺失值和零分母如何处理?"], column: null } designCalls === 1
: { ? {
complete: true, complete: false,
questions: [], questions: ["缺失值和零分母如何处理?"],
column: { column: null,
name: "收入成本差", }
meaning: "实际收入减成本", : {
scope: "所选分组与期间", complete: true,
missing: "结果不可用(#N/A);零分母不可用", questions: [],
expression: { column: {
operator: "subtract", name: "收入成本差",
left: { metric: "revenue" }, meaning: "实际收入减成本",
right: { metric: "cost" }, scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
}, },
}, },
},
}); });
}); });
await page.getByRole("button", { name: "生成公式工作簿" }).click(); await page.getByRole("button", { name: "生成公式工作簿" }).click();
const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" }); const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await formulaDialog.getByRole("checkbox").check(); await formulaDialog.getByRole("checkbox").check();
await formulaDialog.getByLabel("列计算描述").fill("新增收入成本差,缺失或零分母显示不可用"); await formulaDialog
.getByLabel("列计算描述")
.fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByRole("button", { name: "发送描述" }).click(); await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await expect(formulaDialog).toContainText("缺失值和零分母如何处理?"); 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 formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click(); await formulaDialog
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 }); .getByRole("button", { name: "确认并生成公式工作簿" })
.click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
const formulaDownload = page.waitForEvent("download"); const formulaDownload = page.waitForEvent("download");
await formulaDialog.getByRole("button", { name: "下载公式工作簿" }).click(); 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(); await formulaDialog.getByLabel("关闭").click();
}); });
test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => { 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 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): def test_dataset_and_evidence_are_organization_and_department_scoped(workspace):
ds, _, _, _, other_client = workspace ds, _, _, _, other_client = workspace
url = f"/api/datasets/{ds.pk}" url = f"/api/datasets/{ds.pk}"