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 = {}
|
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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user