Merge asset analysis exports into main and release v1.2.2
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
ln-bi:
|
ln-bi:
|
||||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.1
|
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.2
|
||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}"
|
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ export default function AssetsModule() {
|
|||||||
部门: item.department || '',
|
部门: item.department || '',
|
||||||
业务负责人: item.manager || '',
|
业务负责人: item.manager || '',
|
||||||
客户: item.customerName || '',
|
客户: item.customerName || '',
|
||||||
|
合同: item.contractNo || '',
|
||||||
|
项目名称: item.projectName || '',
|
||||||
}));
|
}));
|
||||||
const ws = buildJsonSheet(table);
|
const ws = buildJsonSheet(table);
|
||||||
ws['!cols'] = [
|
ws['!cols'] = [
|
||||||
@@ -393,6 +395,8 @@ export default function AssetsModule() {
|
|||||||
{ wch: 16 },
|
{ wch: 16 },
|
||||||
{ wch: 14 },
|
{ wch: 14 },
|
||||||
{ wch: 24 },
|
{ wch: 24 },
|
||||||
|
{ wch: 28 },
|
||||||
|
{ wch: 30 },
|
||||||
];
|
];
|
||||||
writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
||||||
}, [flowRange.end, flowRange.start, flowStats]);
|
}, [flowRange.end, flowRange.start, flowStats]);
|
||||||
@@ -1671,7 +1675,6 @@ export default function AssetsModule() {
|
|||||||
selectedFlow={selectedFlow}
|
selectedFlow={selectedFlow}
|
||||||
selectedFlowDetails={selectedFlowDetails}
|
selectedFlowDetails={selectedFlowDetails}
|
||||||
setSelectedFlow={setSelectedFlow}
|
setSelectedFlow={setSelectedFlow}
|
||||||
exportFlowDetails={exportFlowDetails}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AssetAnalysisModal
|
<AssetAnalysisModal
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { statisticsExportRows, vehicleExportRows } from './analysis-export';
|
||||||
|
import type { VehicleListItem } from './types';
|
||||||
|
|
||||||
|
test('statistics export retains all ordered groups and repeats parent labels for spreadsheet filtering', () => {
|
||||||
|
const groups = Array.from({ length: 105 }, (_, i) => ({ values: ['华东', `城市${i}`], count: 1, extra: { 待交车: 0, 异动: Number(i === 104) } }));
|
||||||
|
const rows = statisticsExportRows(['区域', '城市'], groups, 105);
|
||||||
|
assert.equal(rows.length, 105);
|
||||||
|
assert.deepEqual(rows[104], { 区域: '华东', 城市: '城市104', '数量(台)': 1, 待交车: 0, 异动: 1, '占比(%)': 1 });
|
||||||
|
assert.equal(rows.reduce((n, row) => n + Number(row['数量(台)']), 0), 105);
|
||||||
|
assert.deepEqual(statisticsExportRows([], [{ values: [], count: 3 }], 3, '车次'), [{ 统计范围: '当前筛选结果', '数量(车次)': 3, '占比(%)': 100 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vehicle exports preserve full records, server masking and pending/movement fields without treating missing time as zero', () => {
|
||||||
|
const vehicles = Array.from({ length: 45 }, (_, id) => ({ id, plateNumber: `车-${id}`, vin: '001234', status: 'Inventory', operationStatus: '3', customerName: '测试***公司' } as VehicleListItem));
|
||||||
|
vehicles[0].pendingDelivery = { contractNo: '001-CONTRACT', customerName: '待交***公司' };
|
||||||
|
vehicles[44].abnormalMove = { id: 'move', type: '维修', status: '待结束', work: '更换配件', elapsedDays: null } as NonNullable<VehicleListItem['abnormalMove']>;
|
||||||
|
const rows = vehicleExportRows(vehicles);
|
||||||
|
assert.equal(rows.length, 45);
|
||||||
|
assert.equal(rows[0].VIN, '001234');
|
||||||
|
assert.equal(rows[0].客户, '测试***公司');
|
||||||
|
assert.equal(rows[0].待交车合同, '001-CONTRACT');
|
||||||
|
assert.equal(rows[44].异动类型, '维修');
|
||||||
|
assert.equal(rows[44]['已异动时长(天)'], null);
|
||||||
|
assert.equal(rows[44].实际完成时间, '未完成');
|
||||||
|
assert.equal(rows[0].状态, '在库');
|
||||||
|
assert.equal(rows[0].运营状态, '可运营');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one Excel workbook contains statistics and all details in two named sheets', async () => {
|
||||||
|
const { createAnalysisWorkbook } = await import('./analysis-export');
|
||||||
|
const XLSX = await import('xlsx');
|
||||||
|
const statistics = statisticsExportRows(['区域'], [{ values: ['华东'], count: 105 }], 105);
|
||||||
|
const details = Array.from({ length: 105 }, (_, i) => ({ VIN: `000${i}`, 合同: i === 104 ? 'LAST-CONTRACT' : 'CONTRACT', 项目名称: '项目甲' }));
|
||||||
|
const workbook = await createAnalysisWorkbook(statistics, details);
|
||||||
|
const saved = XLSX.read(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }), { type: 'buffer' });
|
||||||
|
assert.deepEqual(saved.SheetNames, ['统计表', '详情表']);
|
||||||
|
assert.deepEqual(XLSX.utils.sheet_to_json(saved.Sheets['统计表']), statistics);
|
||||||
|
assert.deepEqual(XLSX.utils.sheet_to_json(saved.Sheets['详情表']), details);
|
||||||
|
const empty = await createAnalysisWorkbook([], []);
|
||||||
|
assert.deepEqual(empty.SheetNames, ['统计表', '详情表']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { VehicleListItem } from './types';
|
||||||
|
|
||||||
|
export type ExportRow = Record<string, string | number | null | undefined>;
|
||||||
|
|
||||||
|
/** Use complete filtered groups, never the progressively rendered slice. Repeat parent labels for Excel filtering. */
|
||||||
|
export function statisticsExportRows(labels: string[], groups: { values: string[]; count: number; extra?: ExportRow }[], total: number, unit = '台'): ExportRow[] {
|
||||||
|
return groups.map(group => ({
|
||||||
|
...Object.fromEntries(labels.length ? labels.map((label, index) => [label, group.values[index]]) : [['统计范围', '当前筛选结果']]),
|
||||||
|
[`数量(${unit})`]: group.count,
|
||||||
|
...group.extra,
|
||||||
|
'占比(%)': total ? Number((group.count / total * 100).toFixed(1)) : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] {
|
||||||
|
const status: Record<string, string> = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' };
|
||||||
|
const operation: Record<string, string> = { '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' };
|
||||||
|
const hasPending = vehicles.some(v => v.pendingDelivery);
|
||||||
|
const hasMovement = vehicles.some(v => v.abnormalMove);
|
||||||
|
return vehicles.map(v => ({
|
||||||
|
车牌: v.plateNumber, VIN: v.vin, 品牌: v.brandLabel, 车型: v.type, 型号: v.model,
|
||||||
|
省份: v.province, 城市: v.city || v.location,
|
||||||
|
状态: status[v.status] || v.status, 运营状态: operation[v.operationStatus || ''] || v.operationStatus,
|
||||||
|
客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager,
|
||||||
|
所属公司: v.subjectOrg, 合同: v.contractNo,
|
||||||
|
...(hasPending ? { 待交车合同: v.pendingDelivery?.contractNo, 待交车客户: v.pendingDelivery?.customerName } : {}),
|
||||||
|
...(hasMovement ? {
|
||||||
|
异动类型: v.abnormalMove?.type, 审批通过时间: v.abnormalMove?.approvalPassedAt,
|
||||||
|
'已异动时长(天)': v.abnormalMove?.elapsedDays == null ? null : Number(v.abnormalMove.elapsedDays.toFixed(1)),
|
||||||
|
具体工作: v.abnormalMove?.work, 申请人: v.abnormalMove?.applicant,
|
||||||
|
预计完成时间: v.abnormalMove?.targetCompletionTime,
|
||||||
|
实际完成时间: v.abnormalMove?.actualCompletionTime || (v.abnormalMove?.status === '待结束' ? '未完成' : null),
|
||||||
|
} : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analysisSheets(statistics: ExportRow[], details: ExportRow[]) {
|
||||||
|
const { buildJsonSheet } = await import('../../shared/xlsx');
|
||||||
|
return ([['统计表', statistics], ['详情表', details]] as const).map(([name, rows]) => {
|
||||||
|
const sheet = buildJsonSheet(rows.map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value ?? '—']))));
|
||||||
|
sheet['!cols'] = Object.keys(rows[0] || {}).map(label => ({ wch: /时间|合同|项目|工作/.test(label) ? 28 : 18 }));
|
||||||
|
if (sheet['!ref']) sheet['!autofilter'] = { ref: sheet['!ref'] };
|
||||||
|
return { name, sheet };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[]) {
|
||||||
|
const { createWorkbook } = await import('../../shared/xlsx');
|
||||||
|
return createWorkbook(await analysisSheets(statistics, details));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[], title: string) {
|
||||||
|
const { writeWorkbook } = await import('../../shared/xlsx');
|
||||||
|
const safeTitle = title.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').slice(0, 120);
|
||||||
|
writeWorkbook(await analysisSheets(statistics, details), `${safeTitle}.xlsx`, { compression: true });
|
||||||
|
}
|
||||||
@@ -107,6 +107,8 @@ export interface FlowDetailItem {
|
|||||||
department: string;
|
department: string;
|
||||||
manager: string;
|
manager: string;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
contractNo: string | null;
|
||||||
|
projectName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowStatsResponse {
|
export interface FlowStatsResponse {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "../../../shared/assets/assistant";
|
} from "../../../shared/assets/assistant";
|
||||||
import { askAssetAssistant, AssistantRequestError } from "./client";
|
import { askAssetAssistant, AssistantRequestError } from "./client";
|
||||||
import { fetchAuthenticated } from "../../../auth/api-client";
|
import { fetchAuthenticated } from "../../../auth/api-client";
|
||||||
|
import { useAuth } from "../../../auth/useAuth";
|
||||||
import { ThinkingState } from "./ThinkingState";
|
import { ThinkingState } from "./ThinkingState";
|
||||||
import { CompoundResultView } from "./CompoundResultView";
|
import { CompoundResultView } from "./CompoundResultView";
|
||||||
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
|
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
|
||||||
@@ -89,6 +90,10 @@ export default function AssetAssistant({
|
|||||||
close: () => void;
|
close: () => void;
|
||||||
appearance: "light" | "dark";
|
appearance: "light" | "dark";
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const dataScopeLabel = !user ? "正在确认数据范围"
|
||||||
|
: user.permissionLevel === "full" ? "全量数据"
|
||||||
|
: user.permissionLevel === "department" ? "部门数据" : "本人数据";
|
||||||
const page = useRef<HTMLElement>(null);
|
const page = useRef<HTMLElement>(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
@@ -506,15 +511,11 @@ export default function AssetAssistant({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="aq-scope">
|
<div className="aq-scope" aria-label="当前可查询数据范围">
|
||||||
<Building2 size={13} />
|
<Building2 size={13} />
|
||||||
<span>{subject || "全部所属公司"}</span>
|
<span>{subject || "全部所属公司"}</span>
|
||||||
<span className="aq-dot">·</span>
|
<span className="aq-dot">·</span>
|
||||||
<span>当前账号可见范围</span>
|
<span>{dataScopeLabel}</span>
|
||||||
<span className="aq-live">
|
|
||||||
<i />
|
|
||||||
已连接业务数据
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<div className="aq-info">
|
<div className="aq-info">
|
||||||
|
|||||||
@@ -273,6 +273,7 @@
|
|||||||
}
|
}
|
||||||
.aq-scope {
|
.aq-scope {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 28px;
|
padding: 12px 28px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||||
import { ArrowLeft, Download, X } from 'lucide-react';
|
import { ArrowLeft, X } from 'lucide-react';
|
||||||
import type { FlowDetailItem, FlowType } from '../api';
|
import type { FlowDetailItem, FlowType } from '../api';
|
||||||
import type { FlowSelection } from '../model';
|
import type { FlowSelection } from '../model';
|
||||||
import { FLOW_META } from './FlowDetailModal';
|
import { FLOW_META } from './FlowDetailModal';
|
||||||
@@ -8,13 +8,15 @@ import { useDialogFocus } from './useDialogFocus';
|
|||||||
import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
|
import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
|
||||||
import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
|
import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
|
||||||
import { useProgressiveRows } from './analysis/useProgressiveRows';
|
import { useProgressiveRows } from './analysis/useProgressiveRows';
|
||||||
|
import { AnalysisExportButton } from './analysis/AnalysisExportButton';
|
||||||
|
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
|
||||||
|
import './analysis/asset-analysis.css';
|
||||||
import './flow-analysis-modal.css';
|
import './flow-analysis-modal.css';
|
||||||
|
|
||||||
interface FlowAnalysisModalProps {
|
interface FlowAnalysisModalProps {
|
||||||
selectedFlow: FlowSelection | null;
|
selectedFlow: FlowSelection | null;
|
||||||
selectedFlowDetails: FlowDetailItem[];
|
selectedFlowDetails: FlowDetailItem[];
|
||||||
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
||||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dimension = 'date' | 'department' | 'manager' | 'type';
|
type Dimension = 'date' | 'department' | 'manager' | 'type';
|
||||||
@@ -54,7 +56,7 @@ export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
|
|||||||
return <FlowAnalysisContent key={JSON.stringify(props.selectedFlow)} {...props} selectedFlow={props.selectedFlow} />;
|
return <FlowAnalysisContent key={JSON.stringify(props.selectedFlow)} {...props} selectedFlow={props.selectedFlow} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow, exportFlowDetails }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
|
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
|
||||||
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
|
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
|
||||||
const [mergeParents, setMergeParents] = useState(true);
|
const [mergeParents, setMergeParents] = useState(true);
|
||||||
const mergeActive = mergeParents && dimensions.length > 1;
|
const mergeActive = mergeParents && dimensions.length > 1;
|
||||||
@@ -108,6 +110,14 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
|||||||
resetResult();
|
resetResult();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportWorkbook = () => {
|
||||||
|
const statistics = statisticsExportRows(dimensions.map(d => DIMENSION_LABELS[d]), (detailGroup ? [detailGroup] : groups).map(group => ({ values: group.values.map((v, i) => dimensionLabel(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
|
||||||
|
const details = selectedRows.map(item => ({ 业务日期: item.date, 类型: item.typeLabel, 车牌: item.plateNumber,
|
||||||
|
运维签章时间: item.eventTime, 建单时间: item.submitTime, 部门: item.department, 业务负责人: item.manager,
|
||||||
|
客户: item.customerName, 合同: item.contractNo, 项目名称: item.projectName }));
|
||||||
|
return downloadAnalysisWorkbook(statistics, details, `${FLOW_META[selectedFlow.type].label}-${rangeLabel}-${showDetails ? detailLabel : '当前筛选范围'}`);
|
||||||
|
};
|
||||||
|
|
||||||
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
||||||
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
|
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
|
||||||
<header className="bi-flow-modal-header">
|
<header className="bi-flow-modal-header">
|
||||||
@@ -133,7 +143,9 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
|||||||
|
|
||||||
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
||||||
{!showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
|
{!showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
|
||||||
{showDetails ? <button type="button" disabled={!detailRows.length} className="bi-flow-modal-export" onClick={() => exportFlowDetails(detailRows, `${FLOW_META[selectedFlow.type].label}-${detailLabel}-明细`)}><Download size={13} />导出当前明细</button> : null}
|
<div className="analysis-export-actions">
|
||||||
|
<AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showDetails ? groups.length ? <>
|
{!showDetails ? groups.length ? <>
|
||||||
@@ -150,7 +162,7 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
|
|||||||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||||
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
|
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
|
||||||
: detailRows.length ? <>
|
: detailRows.length ? <>
|
||||||
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td></tr>)}</tbody></table></div>
|
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th><th>合同</th><th>项目名称</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>
|
||||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||||
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
|
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Download, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AnalysisExportButton({ disabled, onExport }: {
|
||||||
|
disabled?: boolean; onExport: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const running = useRef(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const run = async () => {
|
||||||
|
if (running.current) return;
|
||||||
|
running.current = true; setBusy(true); setError('');
|
||||||
|
try { await onExport(); }
|
||||||
|
catch { setError('导出失败,请重试'); }
|
||||||
|
finally { running.current = false; setBusy(false); }
|
||||||
|
};
|
||||||
|
return <span className="analysis-export-action">
|
||||||
|
<button type="button" className="analysis-button" disabled={disabled || busy} onClick={run}>
|
||||||
|
{busy ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />}{busy ? '正在导出…' : '导出 Excel'}
|
||||||
|
</button>
|
||||||
|
{error && <small role="alert">{error}</small>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api';
|
|||||||
import type { VehicleListItem } from '../../types';
|
import type { VehicleListItem } from '../../types';
|
||||||
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
|
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
|
||||||
import { useDialogFocus } from '../useDialogFocus';
|
import { useDialogFocus } from '../useDialogFocus';
|
||||||
|
import { AnalysisExportButton } from './AnalysisExportButton';
|
||||||
|
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
|
||||||
import './asset-analysis.css';
|
import './asset-analysis.css';
|
||||||
|
|
||||||
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
|
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
|
||||||
@@ -110,6 +112,23 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
|||||||
const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
|
const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
|
||||||
const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title;
|
const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title;
|
||||||
const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · ');
|
const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · ');
|
||||||
|
const exportWorkbook = async () => {
|
||||||
|
const rows = showDetails ? visibleVehicles : await fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ metric: analysis.metric, filters: {}, filterValues, generatedAt: data?.generatedAt, scope: request.kind === 'vehicles' ? request.params : {} }),
|
||||||
|
});
|
||||||
|
const exportGroups = detailGroup ? [{ ...detailGroup, count: rows.length, totals: {
|
||||||
|
pending: rows.filter(v => v.status === 'Pending').length,
|
||||||
|
abnormal: rows.filter(v => v.status === 'Abnormal').length,
|
||||||
|
} }] : orderedGroups;
|
||||||
|
const statistics = statisticsExportRows(
|
||||||
|
analysis.dimensions.map(d => DIMENSION_LABELS[d]),
|
||||||
|
exportGroups.map(row => ({ values: analysis.dimensions.map(d => getDimensionValueLabel(d, row.values[d] || '')), count: row.count,
|
||||||
|
extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })),
|
||||||
|
showDetails ? rows.length : analysis.total,
|
||||||
|
);
|
||||||
|
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
|
||||||
|
};
|
||||||
return <div className="asset-analysis-overlay">
|
return <div className="asset-analysis-overlay">
|
||||||
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
|
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
|
||||||
<header className="analysis-header"><div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
|
<header className="analysis-header"><div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
|
||||||
@@ -121,7 +140,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
|||||||
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
||||||
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
|
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
|
||||||
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
||||||
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options">{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
|
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
|
||||||
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
||||||
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
|
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
|
||||||
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
|
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
|
||||||
@@ -141,7 +160,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
|
|||||||
</>}
|
</>}
|
||||||
</>}
|
</>}
|
||||||
{showDetails && <div className="analysis-details">
|
{showDetails && <div className="analysis-details">
|
||||||
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
|
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
|
||||||
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
|
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
|
||||||
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
|
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
|
||||||
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
|
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
|
||||||
@@ -229,6 +248,13 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
|
|||||||
return [dimension, [...counts].map(([entry, count]) => ({ value: entry, label: label(dimension, entry), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
|
return [dimension, [...counts].map(([entry, count]) => ({ value: entry, label: label(dimension, entry), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
|
||||||
})), [rows, filters]);
|
})), [rows, filters]);
|
||||||
|
|
||||||
|
const exportWorkbook = () => {
|
||||||
|
const selectedRows = showDetails ? detailRows : filtered;
|
||||||
|
const selectedGroups = detailGroup ? [detailGroup] : groups;
|
||||||
|
const statistics = statisticsExportRows(dimensions.map(d => labels[d]), selectedGroups.map(group => ({ values: group.values.map((v, i) => label(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
|
||||||
|
const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
|
||||||
|
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`);
|
||||||
|
};
|
||||||
return <div ref={scrollRoot} className="analysis-scroll">
|
return <div ref={scrollRoot} className="analysis-scroll">
|
||||||
{!showDetails ? <>
|
{!showDetails ? <>
|
||||||
<div className="analysis-builder">
|
<div className="analysis-builder">
|
||||||
@@ -243,7 +269,7 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(filtered.length)}</strong> 车次</span><span>{groups.length} 个分组</span><span className="analysis-result-dimensions">{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(filtered.length)}</strong> 车次</span><span>{groups.length} 个分组</span><span className="analysis-result-dimensions">{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
||||||
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p>{dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}</div>
|
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p><div className="analysis-export-actions"><AnalysisExportButton disabled={!filtered.length} onExport={exportWorkbook} /></div>{dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}</div>
|
||||||
{groups.length ? <>
|
{groups.length ? <>
|
||||||
<div className={`analysis-group-table${mergeActive ? ' is-merged' : ''}`} role="region" aria-label="交还车组合统计表" tabIndex={0}><table><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{labels[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(车次)</th><th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead><tbody>
|
<div className={`analysis-group-table${mergeActive ? ' is-merged' : ''}`} role="region" aria-label="交还车组合统计表" tabIndex={0}><table><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{labels[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(车次)</th><th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead><tbody>
|
||||||
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
|
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
|
||||||
@@ -260,7 +286,7 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
|
|||||||
</> : <div className="analysis-message"><p>没有符合条件的数据,请调整筛选。</p>{activeFilters.length > 0 && <button type="button" className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
|
</> : <div className="analysis-message"><p>没有符合条件的数据,请调整筛选。</p>{activeFilters.length > 0 && <button type="button" className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
|
||||||
</> : <>
|
</> : <>
|
||||||
<div className="analysis-navigation"><button type="button" className="analysis-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</span></div>
|
<div className="analysis-navigation"><button type="button" className="analysis-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</span></div>
|
||||||
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3></div>
|
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></div>
|
||||||
{detailRows.length ? <><div className="analysis-detail-table" role="region" aria-label="交还车业务明细表" tabIndex={0}><table><thead><tr><th>车牌</th><th>日期</th><th>合同类型</th><th>客户</th></tr></thead><tbody>{shownDetails.map((row, index) => <tr key={`${row.truck_id}-${index}`}><td>{row.plate_number || '—'}</td><td>{row.handover_date || '—'}</td><td>{row.contract_type || '—'}</td><td>{row.customer_name || '—'}</td></tr>)}</tbody></table></div>
|
{detailRows.length ? <><div className="analysis-detail-table" role="region" aria-label="交还车业务明细表" tabIndex={0}><table><thead><tr><th>车牌</th><th>日期</th><th>合同类型</th><th>客户</th></tr></thead><tbody>{shownDetails.map((row, index) => <tr key={`${row.truck_id}-${index}`}><td>{row.plate_number || '—'}</td><td>{row.handover_date || '—'}</td><td>{row.contract_type || '—'}</td><td>{row.customer_name || '—'}</td></tr>)}</tbody></table></div>
|
||||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||||
</> : <p className="analysis-message">当前分组暂无数据,请返回统计分析。</p>}
|
</> : <p className="analysis-message">当前分组暂无数据,请返回统计分析。</p>}
|
||||||
|
|||||||
@@ -170,3 +170,8 @@
|
|||||||
/* Keep a one-pixel grid; darker boundaries separate parent groups. */
|
/* Keep a one-pixel grid; darker boundaries separate parent groups. */
|
||||||
.analysis-group-table tbody tr:first-child > td,
|
.analysis-group-table tbody tr:first-child > td,
|
||||||
.analysis-group-table.is-merged .analysis-group-start > td { border-top-color:var(--analysis-boundary); }
|
.analysis-group-table.is-merged .analysis-group-start > td { border-top-color:var(--analysis-boundary); }
|
||||||
|
|
||||||
|
.analysis-export-action { display:inline-flex; flex-direction:column; gap:3px; flex-shrink:0; }
|
||||||
|
.analysis-export-action button:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
.analysis-export-action small { color:#b91c1c; font-size:11px; }
|
||||||
|
.analysis-export-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
.bi-flow-modal-action { width: 78px; white-space: nowrap; text-align: right; }
|
.bi-flow-modal-action { width: 78px; white-space: nowrap; text-align: right; }
|
||||||
.bi-flow-modal-action button { padding: 3px 0; color: #2563eb; font-size: 11px; background: none; border: 0; }
|
.bi-flow-modal-action button { padding: 3px 0; color: #2563eb; font-size: 11px; background: none; border: 0; }
|
||||||
.bi-flow-modal-action button:hover { text-decoration: underline; }
|
.bi-flow-modal-action button:hover { text-decoration: underline; }
|
||||||
.bi-flow-modal-detail-table { min-width: 800px; }
|
.bi-flow-modal-detail-table { min-width: 1120px; }
|
||||||
|
.bi-flow-modal-detail-table td:nth-child(8), .bi-flow-modal-detail-table td:nth-child(9) { min-width: 160px; overflow-wrap: anywhere; }
|
||||||
.bi-flow-modal-detail-table td { font-size: 12px; min-width: 80px; }
|
.bi-flow-modal-detail-table td { font-size: 12px; min-width: 80px; }
|
||||||
.bi-flow-modal-detail-table td:nth-child(3), .bi-flow-modal-detail-table td:nth-child(4) { min-width: 133px; font-size: 11px; }
|
.bi-flow-modal-detail-table td:nth-child(3), .bi-flow-modal-detail-table td:nth-child(4) { min-width: 133px; font-size: 11px; }
|
||||||
.bi-flow-modal-empty { padding: 30px 12px; text-align: center; color: #94a3b8; font-size: 12px; background: #f8fafc; border-radius: 8px; }
|
.bi-flow-modal-empty { padding: 30px 12px; text-align: center; color: #94a3b8; font-size: 12px; background: #f8fafc; border-radius: 8px; }
|
||||||
|
|||||||
@@ -283,11 +283,11 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总',
|
|||||||
details: [
|
details: [
|
||||||
{
|
{
|
||||||
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
|
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
|
||||||
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null, contractNo: null, projectName: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
|
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
|
||||||
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null, contractNo: null, projectName: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('replacement trips are included in delivery counts without inflating totals
|
|||||||
stat_date: date, truck_id: '1', plate_number: '测试车牌',
|
stat_date: date, truck_id: '1', plate_number: '测试车牌',
|
||||||
event_time: `${date} 12:00:00`, submit_time: '2026-09-01 12:00:00',
|
event_time: `${date} 12:00:00`, submit_time: '2026-09-01 12:00:00',
|
||||||
department: null, manager: null, customer_name: null,
|
department: null, manager: null, customer_name: null,
|
||||||
|
contract_no: `CONTRACT-${id}`, project_name: type === 'returned' ? null : `项目-${id}`,
|
||||||
});
|
});
|
||||||
t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [
|
t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [
|
||||||
row('delivered-1', 'delivered', '2026-09-15'),
|
row('delivered-1', 'delivered', '2026-09-15'),
|
||||||
@@ -28,6 +29,13 @@ test('replacement trips are included in delivery counts without inflating totals
|
|||||||
{ date: '2026-09-17', delivered: 0, returned: 0, replaced: 0, total: 0 },
|
{ date: '2026-09-17', delivered: 0, returned: 0, replaced: 0, total: 0 },
|
||||||
]);
|
]);
|
||||||
assert.equal(data.details.length, 4);
|
assert.equal(data.details.length, 4);
|
||||||
|
// The same vehicle can have multiple historical contracts/projects; keep each event's own values.
|
||||||
|
assert.deepEqual(data.details.map((item: { id: string; contractNo: string; projectName: string | null }) => [item.contractNo, item.projectName]), [
|
||||||
|
['CONTRACT-delivered-1', '项目-delivered-1'],
|
||||||
|
['CONTRACT-replaced-2', '项目-replaced-2'],
|
||||||
|
['CONTRACT-returned-1', null],
|
||||||
|
['CONTRACT-replaced-3', '项目-replaced-3'],
|
||||||
|
]);
|
||||||
assert.equal(new Set(data.details.map((x: { id: string }) => x.id)).size, 4);
|
assert.equal(new Set(data.details.map((x: { id: string }) => x.id)).size, 4);
|
||||||
assert.ok(data.details.filter((x: { type: string }) => x.type === 'replaced')
|
assert.ok(data.details.filter((x: { type: string }) => x.type === 'replaced')
|
||||||
.every((x: { typeLabel: string }) => x.typeLabel === '替换交车'));
|
.every((x: { typeLabel: string }) => x.typeLabel === '替换交车'));
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface FlowDetailRow {
|
|||||||
department: string | null;
|
department: string | null;
|
||||||
manager: string | null;
|
manager: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
|
contract_no: string | null;
|
||||||
|
project_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeeklyStats {
|
interface WeeklyStats {
|
||||||
@@ -263,7 +265,9 @@ export const FLOW_STATS_SQL = `
|
|||||||
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
||||||
c.business_department_name AS department,
|
c.business_department_name AS department,
|
||||||
c.business_manager_name AS manager,
|
c.business_manager_name AS manager,
|
||||||
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
COALESCE(dts.customer_name, c.customer_name) AS customer_name,
|
||||||
|
COALESCE(NULLIF(dts.contract_code, ''), c.contract_code) AS contract_no,
|
||||||
|
COALESCE(NULLIF(dts.project_name, ''), c.project_name) AS project_name
|
||||||
FROM delivery_vehicle dv
|
FROM delivery_vehicle dv
|
||||||
LEFT JOIN delivery_task_subject dts
|
LEFT JOIN delivery_task_subject dts
|
||||||
ON dts.id = dv.delivery_task_subject_id
|
ON dts.id = dv.delivery_task_subject_id
|
||||||
@@ -293,7 +297,9 @@ export const FLOW_STATS_SQL = `
|
|||||||
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
||||||
c.business_department_name AS department,
|
c.business_department_name AS department,
|
||||||
c.business_manager_name AS manager,
|
c.business_manager_name AS manager,
|
||||||
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
COALESCE(dts.customer_name, c.customer_name) AS customer_name,
|
||||||
|
COALESCE(NULLIF(dts.contract_code, ''), c.contract_code) AS contract_no,
|
||||||
|
COALESCE(NULLIF(dts.project_name, ''), c.project_name) AS project_name
|
||||||
FROM return_vehicle_task r
|
FROM return_vehicle_task r
|
||||||
LEFT JOIN delivery_task_subject dts
|
LEFT JOIN delivery_task_subject dts
|
||||||
ON dts.id = r.delivery_task_subject_id
|
ON dts.id = r.delivery_task_subject_id
|
||||||
|
|||||||
@@ -634,6 +634,8 @@ app.get('/flow-stats', async (c) => {
|
|||||||
department: row.department || '',
|
department: row.department || '',
|
||||||
manager: row.manager || '',
|
manager: row.manager || '',
|
||||||
customerName: maskCustomerName(row.customer_name),
|
customerName: maskCustomerName(row.customer_name),
|
||||||
|
contractNo: row.contract_no || null,
|
||||||
|
projectName: row.project_name || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
|
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
|
||||||
|
|||||||
+12
-6
@@ -32,16 +32,22 @@ function safeSheetName(name: string): string {
|
|||||||
return (name || 'Sheet1').slice(0, 31) || 'Sheet1';
|
return (name || 'Sheet1').slice(0, 31) || 'Sheet1';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写出一本工作簿。调用方可先给 sheet 设置 `!cols` / `!freeze` / 单元格格式。 */
|
/** 统一组装工作簿,供下载及序列化验证复用。 */
|
||||||
export function writeWorkbook(
|
export function createWorkbook(sheets: Array<{ name: string; sheet: XLSX.WorkSheet }>): XLSX.WorkBook {
|
||||||
sheets: Array<{ name: string; sheet: XLSX.WorkSheet }>,
|
|
||||||
fileName: string,
|
|
||||||
): void {
|
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
for (const { name, sheet } of sheets) {
|
for (const { name, sheet } of sheets) {
|
||||||
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(name));
|
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(name));
|
||||||
}
|
}
|
||||||
XLSX.writeFile(workbook, ensureXlsxFilename(fileName));
|
return workbook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写出一本工作簿。调用方可先设置列宽和单元格格式。 */
|
||||||
|
export function writeWorkbook(
|
||||||
|
sheets: Array<{ name: string; sheet: XLSX.WorkSheet }>,
|
||||||
|
fileName: string,
|
||||||
|
options: XLSX.WritingOptions = {},
|
||||||
|
): void {
|
||||||
|
XLSX.writeFile(createWorkbook(sheets), ensureXlsxFilename(fileName), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 便捷:单个二维数组 sheet 直接下载。 */
|
/** 便捷:单个二维数组 sheet 直接下载。 */
|
||||||
|
|||||||
Reference in New Issue
Block a user