Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494490f6e6 |
+14
-3
@@ -297,6 +297,14 @@ def query(request, dataset_id):
|
||||
return JsonResponse(analyze(ds, request.member, body(request), pref))
|
||||
|
||||
|
||||
def amount_text(value):
|
||||
"""金额展示文本:去掉定点数存储尺度产生的多余尾零(如 10.000000000000000000000000 → 10)。"""
|
||||
if value is None:
|
||||
return None
|
||||
text = format(value.normalize(), "f")
|
||||
return "0" if text == "-0" else text
|
||||
|
||||
|
||||
@endpoint(("POST",))
|
||||
def evidence(request, dataset_id):
|
||||
ds = dataset(request, dataset_id, True)
|
||||
@@ -314,6 +322,9 @@ def evidence(request, dataset_id):
|
||||
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw"
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
for field in ("revenue", "cost", "gross"):
|
||||
row[field] = amount_text(row[field])
|
||||
# Raw patient/customer identifiers require an explicit administrator role.
|
||||
if request.member.role != "admin":
|
||||
for row in rows:
|
||||
@@ -412,9 +423,9 @@ def export(request, dataset_id):
|
||||
row.source_row,
|
||||
row.period,
|
||||
*[row.dimensions.get(d["key"], "") for d in dims],
|
||||
row.revenue,
|
||||
row.cost,
|
||||
row.gross,
|
||||
amount_text(row.revenue),
|
||||
amount_text(row.cost),
|
||||
amount_text(row.gross),
|
||||
]
|
||||
)
|
||||
if workbook:
|
||||
|
||||
+100
-80
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
format,
|
||||
labels,
|
||||
roleLabels,
|
||||
type Analysis,
|
||||
type Context,
|
||||
type Dataset,
|
||||
@@ -308,6 +309,23 @@ export function App() {
|
||||
setPath([]);
|
||||
setModal(null);
|
||||
}
|
||||
// 全部分组都为空的指标列(如数据集没有成本/ROI)不展示,避免占位挤压可用列。
|
||||
const metricKeys = useMemo(() => {
|
||||
const candidates = [
|
||||
...new Set<Metric>([
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
"customers",
|
||||
ctx?.metric || "revenue",
|
||||
]),
|
||||
];
|
||||
const available = candidates.filter(
|
||||
(m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null),
|
||||
);
|
||||
return available.length ? available : (["revenue"] as Metric[]);
|
||||
}, [result, ctx]);
|
||||
const columns = useMemo<ColDef<Row>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -327,23 +345,14 @@ export function App() {
|
||||
</button>
|
||||
),
|
||||
},
|
||||
...[
|
||||
...new Set<Metric>([
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
"customers",
|
||||
ctx?.metric || "revenue",
|
||||
]),
|
||||
].map((metric) => ({
|
||||
...metricKeys.map((metric) => ({
|
||||
colId: metric,
|
||||
headerName: labels[metric],
|
||||
minWidth: 130,
|
||||
valueGetter: (p: { data?: Row }) =>
|
||||
p.data?.values[metric].current == null
|
||||
p.data?.values[metric]?.current == null
|
||||
? null
|
||||
: Number(p.data.values[metric].current),
|
||||
: Number(p.data.values[metric]?.current),
|
||||
valueFormatter: (p: { value: unknown }) =>
|
||||
format(p.value as number, metric),
|
||||
cellClass: metric === ctx?.metric ? "metric-column" : "",
|
||||
@@ -396,24 +405,34 @@ export function App() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[ds, ctx, path],
|
||||
[ds, ctx, path, metricKeys],
|
||||
);
|
||||
const rows = (result?.rows || []).filter(
|
||||
(r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search),
|
||||
);
|
||||
const metric = ctx?.metric || "revenue";
|
||||
const compactAxis = (value: number) => {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1e8) return `${value / 1e8}亿`;
|
||||
if (abs >= 1e4) return `${value / 1e4}万`;
|
||||
return String(value);
|
||||
};
|
||||
const trend = useMemo(
|
||||
() => ({
|
||||
color: ["#197568", "#5794c9"],
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { bottom: 0 },
|
||||
grid: { top: 25, left: 65, right: 25, bottom: 65 },
|
||||
grid: { top: 25, left: 85, right: 25, bottom: 65 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: result?.trend.map((r) => r.period),
|
||||
axisLabel: { color: "#73827b" },
|
||||
axisLabel: { color: "#73827b", hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: "#ecf0ee" } },
|
||||
axisLabel: { hideOverlap: true },
|
||||
},
|
||||
yAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
|
||||
series: [
|
||||
{
|
||||
name: "实际收入",
|
||||
@@ -443,7 +462,11 @@ export function App() {
|
||||
() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { top: 15, left: 95, right: 30, bottom: 30 },
|
||||
xAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
|
||||
xAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: "#ecf0ee" } },
|
||||
axisLabel: { hideOverlap: true, formatter: compactAxis },
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
inverse: true,
|
||||
@@ -878,6 +901,8 @@ export function App() {
|
||||
missing_gross: "毛利缺失",
|
||||
missing_customer: "客户标识缺失",
|
||||
blank_rows: "空白行",
|
||||
g_l_mismatch: "应收金额两列不一致",
|
||||
i_n_mismatch: "实收与实际收入不一致",
|
||||
} as Record<string, string>
|
||||
)[k] || k}
|
||||
<strong>{v.toLocaleString()}</strong>
|
||||
@@ -1030,16 +1055,11 @@ export function App() {
|
||||
)?.name
|
||||
}
|
||||
</th>
|
||||
{(
|
||||
[
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
] as Metric[]
|
||||
).map((m) => (
|
||||
<th key={m}>{labels[m]}</th>
|
||||
))}
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.map((m) => (
|
||||
<th key={m}>{labels[m]}</th>
|
||||
))}
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1047,15 +1067,10 @@ export function App() {
|
||||
{result.rows.slice(0, 12).map((r) => (
|
||||
<tr key={r.label}>
|
||||
<th>{r.label}</th>
|
||||
{(
|
||||
[
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
] as Metric[]
|
||||
).map((m) => {
|
||||
const v = r.values[m].change;
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.map((m) => {
|
||||
const v = r.values[m]?.change;
|
||||
return (
|
||||
<td key={m}>
|
||||
<button
|
||||
@@ -1069,7 +1084,7 @@ export function App() {
|
||||
: "heat-positive"
|
||||
}
|
||||
onClick={() => {
|
||||
setCtx({ ...ctx, metric: m });
|
||||
setCtx({ ...ctx, metric: m as Metric });
|
||||
setSelected(r);
|
||||
setScreen("anomalies");
|
||||
}}
|
||||
@@ -1227,48 +1242,53 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-layout">
|
||||
<div className="grid">
|
||||
<AgGridReact<Row>
|
||||
theme={gridTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columns}
|
||||
defaultColDef={{
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
getRowId={(p) => p.data.label}
|
||||
onRowClicked={(e) => setSelected(e.data)}
|
||||
localeText={{
|
||||
noRowsToShow: "没有符合条件的分组",
|
||||
page: "页",
|
||||
of: "/",
|
||||
to: "至",
|
||||
more: "更多",
|
||||
nextPage: "下一页",
|
||||
previousPage: "上一页",
|
||||
firstPage: "首页",
|
||||
lastPage: "末页",
|
||||
pageSizeSelectorLabel: "每页",
|
||||
}}
|
||||
{...(enterprise
|
||||
? {
|
||||
cellSelection: true,
|
||||
sideBar: "columns",
|
||||
statusBar: {
|
||||
statusPanels: [
|
||||
{
|
||||
statusPanel: "agTotalRowCountComponent",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
<div className="grid">
|
||||
<AgGridReact<Row>
|
||||
theme={gridTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columns}
|
||||
defaultColDef={{
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
getRowId={(p) => p.data.label}
|
||||
onRowClicked={(e) => setSelected(e.data)}
|
||||
localeText={{
|
||||
page: "页",
|
||||
of: "/",
|
||||
to: "至",
|
||||
more: "更多",
|
||||
nextPage: "下一页",
|
||||
previousPage: "上一页",
|
||||
firstPage: "首页",
|
||||
lastPage: "末页",
|
||||
pageSizeSelectorLabel: "每页",
|
||||
}}
|
||||
{...(enterprise
|
||||
? {
|
||||
cellSelection: true,
|
||||
sideBar: "columns",
|
||||
statusBar: {
|
||||
statusPanels: [
|
||||
{
|
||||
statusPanel: "agTotalRowCountComponent",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p role="status" className="grid-empty">
|
||||
没有符合条件的分组
|
||||
</p>
|
||||
)}
|
||||
{selected && (
|
||||
<aside className="explanation">
|
||||
<div className="section-heading">
|
||||
@@ -1472,7 +1492,7 @@ export function App() {
|
||||
<tr key={c.index}>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.description}</td>
|
||||
<td>{c.role}</td>
|
||||
<td>{roleLabels[c.role] || c.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea
|
||||
import { api } from "./api";
|
||||
import {
|
||||
labels,
|
||||
roleLabels,
|
||||
type AIConfig,
|
||||
type Context,
|
||||
type Dataset,
|
||||
@@ -165,7 +166,9 @@ export function ImportDialog({
|
||||
"dimension",
|
||||
"ignore",
|
||||
].map((r) => (
|
||||
<option key={r}>{r}</option>
|
||||
<option key={r} value={r}>
|
||||
{roleLabels[r] || r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
@@ -367,6 +370,7 @@ export function EvidenceDialog({
|
||||
};
|
||||
}, [ds.id, ctx, side, page]);
|
||||
function selectSide(value: "current" | "baseline") {
|
||||
if (value === side && page === 0) return;
|
||||
setResult(undefined);
|
||||
setPage(0);
|
||||
setSide(value);
|
||||
@@ -406,7 +410,7 @@ export function EvidenceDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<table className="evidence-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源行</th>
|
||||
|
||||
+22
-7
@@ -484,7 +484,7 @@ a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 20px 20px 16px;
|
||||
padding: 18px 16px 14px;
|
||||
gap: 16px;
|
||||
background: white;
|
||||
min-width: 0;
|
||||
@@ -510,12 +510,13 @@ a {
|
||||
}
|
||||
.kpi strong {
|
||||
text-align: left;
|
||||
font-size: 24px;
|
||||
font-size: 21px;
|
||||
line-height: 1.25;
|
||||
font-weight: 620;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.kpi-bottom {
|
||||
display: flex;
|
||||
@@ -1271,9 +1272,6 @@ details {
|
||||
.kpi {
|
||||
padding: 17px 13px;
|
||||
}
|
||||
.kpi strong {
|
||||
font-size: 21px;
|
||||
}
|
||||
.chart-band {
|
||||
gap: 15px;
|
||||
}
|
||||
@@ -1562,3 +1560,20 @@ details {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-empty {
|
||||
margin: 0;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--muted, #5b6675);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.evidence-table {
|
||||
min-width: 960px;
|
||||
}
|
||||
.evidence-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ export interface Column {
|
||||
key: string;
|
||||
confidence: number;
|
||||
}
|
||||
export const roleLabels: Record<string, string> = {
|
||||
period: "期间",
|
||||
year: "年份",
|
||||
month: "月份",
|
||||
dimension: "维度",
|
||||
customer: "客户标识",
|
||||
quantity: "数量",
|
||||
revenue: "实际收入",
|
||||
cost: "成本",
|
||||
gross: "毛利贡献",
|
||||
list_amount: "应收金额",
|
||||
discount: "折扣金额",
|
||||
ignore: "未使用",
|
||||
};
|
||||
export interface Mapping {
|
||||
sheet: string;
|
||||
header: number;
|
||||
|
||||
@@ -30,7 +30,9 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
|
||||
});
|
||||
await uploaded;
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("heading", { name: "确认导入字段" })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "确认导入字段" }),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "确认并导入" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "集团经营总览", exact: true }),
|
||||
@@ -85,7 +87,14 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
|
||||
await expect(evidenceDialog).toContainText("1 条");
|
||||
await expect(evidenceDialog).toContainText("2026-08");
|
||||
await expect(evidenceDialog).toContainText("CSV");
|
||||
await evidenceDialog.getByRole("button", { name: "基期", exact: true }).click();
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "本期", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("2026-08");
|
||||
await expect(evidenceDialog).not.toContainText("加载中");
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "基期", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("2025-08");
|
||||
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
|
||||
const evidenceOffsets: number[] = [];
|
||||
@@ -111,10 +120,17 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
|
||||
});
|
||||
await page.getByLabel("查看医生A证据", { exact: true }).click();
|
||||
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||
await evidenceDialog.getByRole("button", { name: "下一页", exact: true }).click();
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "下一页", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
|
||||
await expect(evidenceDialog).toContainText("2026-09");
|
||||
expect(evidenceOffsets).toEqual([0, 50]);
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "基期", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||
await expect(evidenceDialog).not.toContainText("2026-09");
|
||||
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "分析路径" })
|
||||
|
||||
+31
-2
@@ -133,6 +133,35 @@ def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(wor
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_evidence_amounts_trim_storage_scale(workspace):
|
||||
from decimal import Decimal
|
||||
|
||||
from finance.views import amount_text
|
||||
|
||||
assert amount_text(Decimal("10.000000000000000000000000")) == "10"
|
||||
assert amount_text(Decimal("10.500000000000000000000000")) == "10.5"
|
||||
assert amount_text(Decimal("0E-24")) == "0"
|
||||
assert amount_text(None) is None
|
||||
|
||||
ds, _, client, *_ = workspace
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/evidence",
|
||||
data={
|
||||
"start": "2026-08",
|
||||
"end": "2026-08",
|
||||
"comparison": "yoy",
|
||||
"group": "department",
|
||||
"side": "current",
|
||||
"limit": 1,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
row = response.json()["rows"][0]
|
||||
assert row["revenue"] == "80"
|
||||
assert row["cost"] == "30"
|
||||
assert row["gross"] == "50"
|
||||
|
||||
|
||||
def test_settings_revision_and_invalid_metric_are_rejected(workspace):
|
||||
ds, _, client, *_ = workspace
|
||||
url = f"/api/datasets/{ds.pk}/settings"
|
||||
@@ -169,8 +198,8 @@ def test_csv_export_neutralizes_formula_injection_and_preserves_exact_values(wor
|
||||
assert response.streaming
|
||||
content = b"".join(response.streaming_content).decode("utf-8-sig")
|
||||
assert "'=1+1" in content
|
||||
assert ",-10.000000000000000000000000," in content
|
||||
assert "'-10.000000000000000000000000" not in content
|
||||
assert ",'-10,5,'-15" in content
|
||||
assert "00000000000000" not in content
|
||||
assert "traceiq-evidence.csv" in response["Content-Disposition"]
|
||||
|
||||
xlsx = client.post(
|
||||
|
||||
Reference in New Issue
Block a user