fix(display): 证据金额去尾零与界面显示修复 #5

Open
jianwel wants to merge 1 commits from fix/evidence-display-ui into feat/ai-model-config
7 changed files with 206 additions and 97 deletions
+14 -3
View File
@@ -297,6 +297,14 @@ def query(request, dataset_id):
return JsonResponse(analyze(ds, request.member, body(request), pref)) 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",)) @endpoint(("POST",))
def evidence(request, dataset_id): def evidence(request, dataset_id):
ds = dataset(request, dataset_id, True) ds = dataset(request, dataset_id, True)
@@ -314,6 +322,9 @@ def evidence(request, dataset_id):
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw" "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. # Raw patient/customer identifiers require an explicit administrator role.
if request.member.role != "admin": if request.member.role != "admin":
for row in rows: for row in rows:
@@ -412,9 +423,9 @@ def export(request, dataset_id):
row.source_row, row.source_row,
row.period, row.period,
*[row.dimensions.get(d["key"], "") for d in dims], *[row.dimensions.get(d["key"], "") for d in dims],
row.revenue, amount_text(row.revenue),
row.cost, amount_text(row.cost),
row.gross, amount_text(row.gross),
] ]
) )
if workbook: if workbook:
+100 -80
View File
@@ -45,6 +45,7 @@ import {
import { import {
format, format,
labels, labels,
roleLabels,
type Analysis, type Analysis,
type Context, type Context,
type Dataset, type Dataset,
@@ -308,6 +309,23 @@ export function App() {
setPath([]); setPath([]);
setModal(null); 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>[]>( const columns = useMemo<ColDef<Row>[]>(
() => [ () => [
{ {
@@ -327,23 +345,14 @@ export function App() {
</button> </button>
), ),
}, },
...[ ...metricKeys.map((metric) => ({
...new Set<Metric>([
"revenue",
"gross",
"cost",
"roi",
"customers",
ctx?.metric || "revenue",
]),
].map((metric) => ({
colId: metric, colId: metric,
headerName: labels[metric], headerName: labels[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
? null ? null
: Number(p.data.values[metric].current), : Number(p.data.values[metric]?.current),
valueFormatter: (p: { value: unknown }) => valueFormatter: (p: { value: unknown }) =>
format(p.value as number, metric), format(p.value as number, metric),
cellClass: metric === ctx?.metric ? "metric-column" : "", 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( const rows = (result?.rows || []).filter(
(r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search), (r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search),
); );
const metric = ctx?.metric || "revenue"; 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( const trend = useMemo(
() => ({ () => ({
color: ["#197568", "#5794c9"], color: ["#197568", "#5794c9"],
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
legend: { bottom: 0 }, legend: { bottom: 0 },
grid: { top: 25, left: 65, right: 25, bottom: 65 }, grid: { top: 25, left: 85, right: 25, bottom: 65 },
xAxis: { xAxis: {
type: "category", type: "category",
data: result?.trend.map((r) => r.period), 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: [ series: [
{ {
name: "实际收入", name: "实际收入",
@@ -443,7 +462,11 @@ export function App() {
() => ({ () => ({
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
grid: { top: 15, left: 95, right: 30, bottom: 30 }, 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: { yAxis: {
type: "category", type: "category",
inverse: true, inverse: true,
@@ -878,6 +901,8 @@ export function App() {
missing_gross: "毛利缺失", missing_gross: "毛利缺失",
missing_customer: "客户标识缺失", missing_customer: "客户标识缺失",
blank_rows: "空白行", blank_rows: "空白行",
g_l_mismatch: "应收金额两列不一致",
i_n_mismatch: "实收与实际收入不一致",
} as Record<string, string> } as Record<string, string>
)[k] || k} )[k] || k}
<strong>{v.toLocaleString()}</strong> <strong>{v.toLocaleString()}</strong>
@@ -1030,16 +1055,11 @@ export function App() {
)?.name )?.name
} }
</th> </th>
{( {metricKeys
[ .filter((m) => m !== "customers")
"revenue", .map((m) => (
"gross", <th key={m}>{labels[m]}</th>
"cost", ))}
"roi",
] as Metric[]
).map((m) => (
<th key={m}>{labels[m]}</th>
))}
<th>状态</th> <th>状态</th>
</tr> </tr>
</thead> </thead>
@@ -1047,15 +1067,10 @@ export function App() {
{result.rows.slice(0, 12).map((r) => ( {result.rows.slice(0, 12).map((r) => (
<tr key={r.label}> <tr key={r.label}>
<th>{r.label}</th> <th>{r.label}</th>
{( {metricKeys
[ .filter((m) => m !== "customers")
"revenue", .map((m) => {
"gross", const v = r.values[m]?.change;
"cost",
"roi",
] as Metric[]
).map((m) => {
const v = r.values[m].change;
return ( return (
<td key={m}> <td key={m}>
<button <button
@@ -1069,7 +1084,7 @@ export function App() {
: "heat-positive" : "heat-positive"
} }
onClick={() => { onClick={() => {
setCtx({ ...ctx, metric: m }); setCtx({ ...ctx, metric: m as Metric });
setSelected(r); setSelected(r);
setScreen("anomalies"); setScreen("anomalies");
}} }}
@@ -1227,48 +1242,53 @@ export function App() {
</div> </div>
</div> </div>
<div className="grid-layout"> <div className="grid-layout">
<div className="grid"> {rows.length ? (
<AgGridReact<Row> <div className="grid">
theme={gridTheme} <AgGridReact<Row>
rowData={rows} theme={gridTheme}
columnDefs={columns} rowData={rows}
defaultColDef={{ columnDefs={columns}
sortable: true, defaultColDef={{
filter: true, sortable: true,
resizable: true, filter: true,
}} resizable: true,
pagination }}
paginationPageSize={20} pagination
paginationPageSizeSelector={[20, 50, 100]} paginationPageSize={20}
getRowId={(p) => p.data.label} paginationPageSizeSelector={[20, 50, 100]}
onRowClicked={(e) => setSelected(e.data)} getRowId={(p) => p.data.label}
localeText={{ onRowClicked={(e) => setSelected(e.data)}
noRowsToShow: "没有符合条件的分组", localeText={{
page: "页", page: "页",
of: "/", of: "/",
to: "至", to: "至",
more: "更多", more: "更多",
nextPage: "下一页", nextPage: "下一页",
previousPage: "上一页", previousPage: "上一页",
firstPage: "首页", firstPage: "首页",
lastPage: "末页", lastPage: "末页",
pageSizeSelectorLabel: "每页", pageSizeSelectorLabel: "每页",
}} }}
{...(enterprise {...(enterprise
? { ? {
cellSelection: true, cellSelection: true,
sideBar: "columns", sideBar: "columns",
statusBar: { statusBar: {
statusPanels: [ statusPanels: [
{ {
statusPanel: "agTotalRowCountComponent", statusPanel: "agTotalRowCountComponent",
}, },
], ],
}, },
} }
: {})} : {})}
/> />
</div> </div>
) : (
<p role="status" className="grid-empty">
没有符合条件的分组
</p>
)}
{selected && ( {selected && (
<aside className="explanation"> <aside className="explanation">
<div className="section-heading"> <div className="section-heading">
@@ -1472,7 +1492,7 @@ export function App() {
<tr key={c.index}> <tr key={c.index}>
<td>{c.name}</td> <td>{c.name}</td>
<td>{c.description}</td> <td>{c.description}</td>
<td>{c.role}</td> <td>{roleLabels[c.role] || c.role}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+6 -2
View File
@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea
import { api } from "./api"; import { api } from "./api";
import { import {
labels, labels,
roleLabels,
type AIConfig, type AIConfig,
type Context, type Context,
type Dataset, type Dataset,
@@ -165,7 +166,9 @@ export function ImportDialog({
"dimension", "dimension",
"ignore", "ignore",
].map((r) => ( ].map((r) => (
<option key={r}>{r}</option> <option key={r} value={r}>
{roleLabels[r] || r}
</option>
))} ))}
</select> </select>
</td> </td>
@@ -367,6 +370,7 @@ export function EvidenceDialog({
}; };
}, [ds.id, ctx, side, page]); }, [ds.id, ctx, side, page]);
function selectSide(value: "current" | "baseline") { function selectSide(value: "current" | "baseline") {
if (value === side && page === 0) return;
setResult(undefined); setResult(undefined);
setPage(0); setPage(0);
setSide(value); setSide(value);
@@ -406,7 +410,7 @@ export function EvidenceDialog({
</p> </p>
)} )}
<div className="table-scroll"> <div className="table-scroll">
<table> <table className="evidence-table">
<thead> <thead>
<tr> <tr>
<th>来源行</th> <th>来源行</th>
+22 -7
View File
@@ -484,7 +484,7 @@ a {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
padding: 20px 20px 16px; padding: 18px 16px 14px;
gap: 16px; gap: 16px;
background: white; background: white;
min-width: 0; min-width: 0;
@@ -510,12 +510,13 @@ a {
} }
.kpi strong { .kpi strong {
text-align: left; text-align: left;
font-size: 24px; font-size: 21px;
line-height: 1.25; line-height: 1.25;
font-weight: 620; font-weight: 620;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: normal; white-space: nowrap;
overflow-wrap: anywhere; overflow: hidden;
text-overflow: ellipsis;
} }
.kpi-bottom { .kpi-bottom {
display: flex; display: flex;
@@ -1271,9 +1272,6 @@ details {
.kpi { .kpi {
padding: 17px 13px; padding: 17px 13px;
} }
.kpi strong {
font-size: 21px;
}
.chart-band { .chart-band {
gap: 15px; gap: 15px;
} }
@@ -1562,3 +1560,20 @@ details {
scroll-behavior: auto !important; 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;
}
+14
View File
@@ -32,6 +32,20 @@ export interface Column {
key: string; key: string;
confidence: number; confidence: number;
} }
export const roleLabels: Record<string, string> = {
period: "期间",
year: "年份",
month: "月份",
dimension: "维度",
customer: "客户标识",
quantity: "数量",
revenue: "实际收入",
cost: "成本",
gross: "毛利贡献",
list_amount: "应收金额",
discount: "折扣金额",
ignore: "未使用",
};
export interface Mapping { export interface Mapping {
sheet: string; sheet: string;
header: number; header: number;
+19 -3
View File
@@ -30,7 +30,9 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
}); });
await uploaded; await uploaded;
const dialog = page.getByRole("dialog"); 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 dialog.getByRole("button", { name: "确认并导入" }).click();
await expect( await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }), page.getByRole("heading", { name: "集团经营总览", exact: true }),
@@ -85,7 +87,14 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await expect(evidenceDialog).toContainText("1 条"); await expect(evidenceDialog).toContainText("1 条");
await expect(evidenceDialog).toContainText("2026-08"); await expect(evidenceDialog).toContainText("2026-08");
await expect(evidenceDialog).toContainText("CSV"); 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 expect(evidenceDialog).toContainText("2025-08");
await evidenceDialog.getByLabel("关闭", { exact: true }).click(); await evidenceDialog.getByLabel("关闭", { exact: true }).click();
const evidenceOffsets: number[] = []; const evidenceOffsets: number[] = [];
@@ -111,10 +120,17 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
}); });
await page.getByLabel("查看医生A证据", { exact: true }).click(); await page.getByLabel("查看医生A证据", { exact: true }).click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条"); 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("第 51-51 条,共 51 条");
await expect(evidenceDialog).toContainText("2026-09"); await expect(evidenceDialog).toContainText("2026-09");
expect(evidenceOffsets).toEqual([0, 50]); 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 evidenceDialog.getByLabel("关闭", { exact: true }).click();
await page await page
.getByRole("navigation", { name: "分析路径" }) .getByRole("navigation", { name: "分析路径" })
+31 -2
View File
@@ -133,6 +133,35 @@ def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(wor
assert response.status_code == 400 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): def test_settings_revision_and_invalid_metric_are_rejected(workspace):
ds, _, client, *_ = workspace ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/settings" 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 assert response.streaming
content = b"".join(response.streaming_content).decode("utf-8-sig") content = b"".join(response.streaming_content).decode("utf-8-sig")
assert "'=1+1" in content assert "'=1+1" in content
assert ",-10.000000000000000000000000," in content assert ",'-10,5,'-15" in content
assert "'-10.000000000000000000000000" not in content assert "00000000000000" not in content
assert "traceiq-evidence.csv" in response["Content-Disposition"] assert "traceiq-evidence.csv" in response["Content-Disposition"]
xlsx = client.post( xlsx = client.post(