diff --git a/docker-compose.yml b/docker-compose.yml index a5eda1b..e663369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: ln-bi: - image: harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.17 + image: harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.18 network_mode: host environment: # Runtime only: set this variable in Portainer Stack environment variables. diff --git a/package-lock.json b/package-lock.json index d0947d6..e6de1da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.17", + "version": "1.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.17", + "version": "1.1.18", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index 94e4088..52ecf03 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.17", + "version": "1.1.18", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 0f81070..36fbdb9 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -383,6 +383,8 @@ export default function AssetsModule() { 部门: item.department || '', 业务负责人: item.manager || '', 客户: item.customerName || '', + 合同: item.contractNo || '', + 项目名称: item.projectName || '', })); const ws = XLSX.utils.json_to_sheet(table); ws['!cols'] = [ @@ -394,6 +396,8 @@ export default function AssetsModule() { { wch: 16 }, { wch: 14 }, { wch: 24 }, + { wch: 28 }, + { wch: 30 }, ]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '明细'); @@ -1674,7 +1678,6 @@ export default function AssetsModule() { selectedFlow={selectedFlow} selectedFlowDetails={selectedFlowDetails} setSelectedFlow={setSelectedFlow} - exportFlowDetails={exportFlowDetails} /> { + 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; + 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, ['统计表', '详情表']); +}); diff --git a/src/modules/assets/analysis-export.ts b/src/modules/assets/analysis-export.ts new file mode 100644 index 0000000..00f9c44 --- /dev/null +++ b/src/modules/assets/analysis-export.ts @@ -0,0 +1,54 @@ +import type { VehicleListItem } from './types'; + +export type ExportRow = Record; + +/** 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 = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' }; + const operation: Record = { '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), + } : {}), + })); +} + +export async function createAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[]) { + const XLSX = await import('xlsx'); + const workbook = XLSX.utils.book_new(); + for (const [name, rows] of [['统计表', statistics], ['详情表', details]] as const) { + const sheet = XLSX.utils.json_to_sheet(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'] }; + XLSX.utils.book_append_sheet(workbook, sheet, name); + } + return workbook; +} + +export async function downloadAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[], title: string) { + const workbook = await createAnalysisWorkbook(statistics, details); + const XLSX = await import('xlsx'); + const safeTitle = title.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').slice(0, 120); + XLSX.writeFile(workbook, `${safeTitle}.xlsx`, { compression: true }); +} diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index 861a37f..e08d5ad 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -107,6 +107,8 @@ export interface FlowDetailItem { department: string; manager: string; customerName: string | null; + contractNo: string | null; + projectName: string | null; } export interface FlowStatsResponse { diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index b217688..9d1783e 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -30,6 +30,7 @@ import { } from "../../../shared/assets/assistant"; import { askAssetAssistant, AssistantRequestError } from "./client"; import { fetchAuthenticated } from "../../../auth/api-client"; +import { useAuth } from "../../../auth/useAuth"; import { ThinkingState } from "./ThinkingState"; import { CompoundResultView } from "./CompoundResultView"; import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; @@ -89,6 +90,10 @@ export default function AssetAssistant({ close: () => void; appearance: "light" | "dark"; }) { + const { user } = useAuth(); + const dataScopeLabel = !user ? "正在确认数据范围" + : user.permissionLevel === "full" ? "全量数据" + : user.permissionLevel === "department" ? "部门数据" : "本人数据"; const page = useRef(null); useLayoutEffect(() => { const viewport = window.visualViewport; @@ -506,15 +511,11 @@ export default function AssetAssistant({ -
+
{subject || "全部所属公司"} · - 当前账号可见范围 - - - 已连接业务数据 - + {dataScopeLabel}
{showInfo && (
diff --git a/src/modules/assets/assistant/assistant.css b/src/modules/assets/assistant/assistant.css index 2aa0560..07dcd21 100644 --- a/src/modules/assets/assistant/assistant.css +++ b/src/modules/assets/assistant/assistant.css @@ -273,6 +273,7 @@ } .aq-scope { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 28px; diff --git a/src/modules/assets/components/FlowAnalysisModal.tsx b/src/modules/assets/components/FlowAnalysisModal.tsx index ebf80e2..6b9acff 100644 --- a/src/modules/assets/components/FlowAnalysisModal.tsx +++ b/src/modules/assets/components/FlowAnalysisModal.tsx @@ -1,5 +1,5 @@ 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 { FlowSelection } from '../model'; import { FLOW_META } from './FlowDetailModal'; @@ -8,13 +8,15 @@ import { useDialogFocus } from './useDialogFocus'; import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker'; import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus'; 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'; interface FlowAnalysisModalProps { selectedFlow: FlowSelection | null; selectedFlowDetails: FlowDetailItem[]; setSelectedFlow: Dispatch>; - exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void; } type Dimension = 'date' | 'department' | 'manager' | 'type'; @@ -54,7 +56,7 @@ export function FlowAnalysisModal(props: FlowAnalysisModalProps) { return ; } -function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow, exportFlowDetails }: Omit & { selectedFlow: FlowSelection }) { +function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit & { selectedFlow: FlowSelection }) { const [dimensions, setDimensions] = useState(['date']); const [mergeParents, setMergeParents] = useState(true); const mergeActive = mergeParents && dimensions.length > 1; @@ -108,6 +110,14 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo 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
{ if (event.target === event.currentTarget) close(); }}>
@@ -133,7 +143,9 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo

{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}

{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}

{!showDetails && dimensions.length > 1 ? : null} - {showDetails ? : null} +
+ +
{!showDetails ? groups.length ? <> @@ -150,7 +162,7 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo :
当前筛选范围暂无交还车数据,请调整筛选。
: detailRows.length ? <> -
{shownDetails.map(item => )}
车牌业务类型运维签章时间建单时间部门业务负责人客户
{item.plateNumber || '未登记车牌'}{FLOW_META[item.type].label}{item.eventTime || '—'}{item.submitTime || '—'}{dimensionLabel('department', dimensionValue(item, 'department'))}{item.manager || '—'}{item.customerName || '—'}
+
{shownDetails.map(item => )}
车牌业务类型运维签章时间建单时间部门业务负责人客户合同项目名称
{item.plateNumber || '未登记车牌'}{FLOW_META[item.type].label}{item.eventTime || '—'}{item.submitTime || '—'}{dimensionLabel('department', dimensionValue(item, 'department'))}{item.manager || '—'}{item.customerName || '—'}{item.contractNo || '—'}{item.projectName || '—'}
:
当前分组暂无业务明细,请返回统计调整条件。
}
diff --git a/src/modules/assets/components/analysis/AnalysisExportButton.tsx b/src/modules/assets/components/analysis/AnalysisExportButton.tsx new file mode 100644 index 0000000..2bf5831 --- /dev/null +++ b/src/modules/assets/components/analysis/AnalysisExportButton.tsx @@ -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; +}) { + 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 + + {error && {error}} + ; +} diff --git a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx index 3ba4b34..c151142 100644 --- a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx +++ b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx @@ -14,6 +14,8 @@ import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api'; import type { VehicleListItem } from '../../types'; import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails'; import { useDialogFocus } from '../useDialogFocus'; +import { AnalysisExportButton } from './AnalysisExportButton'; +import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export'; import './asset-analysis.css'; type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void }; @@ -111,6 +113,23 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]); const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title; const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · '); + const exportWorkbook = async () => { + const rows = showDetails ? visibleVehicles : await fetchJson(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

{title}

{scopeLabel}

@@ -122,7 +141,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
当前结果 {number(analysis.total)} 台{analysis.rows.length} 个分组{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}
{view === 'inventory' &&
待交车 {number(analysis.summary.pending)} 台异动 {number(analysis.summary.abnormal)} 台已包含在当前库存数量中
} {view === 'abnormal' &&

已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}

} -

{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}

{analysis.dimensions.length > 1 && }
+

{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}

{analysis.dimensions.length > 1 && }
{analysis.total === 0 ?

没有符合条件的数据

移除部分筛选条件,或恢复当前入口的全部数据。

{Object.keys(filterValues).length > 0 && }
: <>
{analysis.dimensions.length ? analysis.dimensions.map(dimension => ) : }{view === 'inventory' && <>} @@ -142,7 +161,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi } } {showDetails &&
-

车辆明细 {number(visibleVehicles.length)} 台

+

车辆明细 {number(visibleVehicles.length)} 台

{detailBusy ?
正在加载车辆明细…
: detailError ?

{detailError}

: <> {vehicles.length !== detailGroup.count &&

车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。

} {(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? : } @@ -230,6 +249,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'))]; })), [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
{!showDetails ? <>
@@ -244,7 +270,7 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
当前结果 {number(filtered.length)} 车次{groups.length} 个分组{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}
-

筛选分组可继续分析,查看明细可核对业务记录。

{dimensions.length > 1 && }
+

筛选分组可继续分析,查看明细可核对业务记录。

{dimensions.length > 1 && }
{groups.length ? <>
{DIMENSION_LABELS[dimension]}统计范围数量(台)待交车异动占比分析操作
{dimensions.length ? dimensions.map(dimension => ) : } {shownGroups.map((group, rowIndex) => 0 ? 'analysis-group-start' : undefined}> @@ -261,7 +287,7 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) { :

没有符合条件的数据,请调整筛选。

{activeFilters.length > 0 && }
} : <>
{detailGroup ? groupLabel(detailGroup) : '当前分组'}
-

交还车业务明细 {number(detailRows.length)} 车次

+

交还车业务明细 {number(detailRows.length)} 车次

{detailRows.length ? <>
{labels[dimension]}统计范围数量(车次)占比分析操作
{shownDetails.map((row, index) => )}
车牌日期合同类型客户
{row.plate_number || '—'}{row.handover_date || '—'}{row.contract_type || '—'}{row.customer_name || '—'}
:

当前分组暂无数据,请返回统计分析。

} diff --git a/src/modules/assets/components/analysis/asset-analysis.css b/src/modules/assets/components/analysis/asset-analysis.css index 41ce7e2..41ac830 100644 --- a/src/modules/assets/components/analysis/asset-analysis.css +++ b/src/modules/assets/components/analysis/asset-analysis.css @@ -170,3 +170,8 @@ /* Keep a one-pixel grid; darker boundaries separate parent groups. */ .analysis-group-table tbody tr:first-child > td, .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; } diff --git a/src/modules/assets/components/flow-analysis-modal.css b/src/modules/assets/components/flow-analysis-modal.css index e548e48..1000f62 100644 --- a/src/modules/assets/components/flow-analysis-modal.css +++ b/src/modules/assets/components/flow-analysis-modal.css @@ -57,7 +57,8 @@ .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: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: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; } diff --git a/src/modules/assets/model.test.ts b/src/modules/assets/model.test.ts index 34ad74d..a8cd2c3 100644 --- a/src/modules/assets/model.test.ts +++ b/src/modules/assets/model.test.ts @@ -283,11 +283,11 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', details: [ { 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', - plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null, + plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null, contractNo: null, projectName: null, }, ], }; diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index 2d0df88..3b847f7 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -634,6 +634,8 @@ app.get('/flow-stats', async (c) => { department: row.department || '', manager: row.manager || '', customerName: maskCustomerName(row.customer_name), + contractNo: row.contract_no || null, + projectName: row.project_name || null, })); const dailyMap = new Map(); diff --git a/src/server/routes/vehicles/flow.test.ts b/src/server/routes/vehicles/flow.test.ts index a10ac1f..3472c3d 100644 --- a/src/server/routes/vehicles/flow.test.ts +++ b/src/server/routes/vehicles/flow.test.ts @@ -11,6 +11,7 @@ test('replacement trips are included in delivery counts without inflating totals stat_date: date, truck_id: '1', plate_number: '测试车牌', event_time: `${date} 12:00:00`, submit_time: '2026-09-01 12:00:00', department: null, manager: null, customer_name: null, + contract_no: `CONTRACT-${id}`, project_name: type === 'returned' ? null : `项目-${id}`, }); t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [ 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 }, ]); 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.ok(data.details.filter((x: { type: string }) => x.type === 'replaced') .every((x: { typeLabel: string }) => x.typeLabel === '替换交车')); diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index f6b7a1c..b6d4dbb 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -24,6 +24,8 @@ export interface FlowDetailRow { department: string | null; manager: string | null; customer_name: string | null; + contract_no: string | null; + project_name: string | null; } 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, c.business_department_name AS department, 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 LEFT JOIN delivery_task_subject dts 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, c.business_department_name AS department, 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 LEFT JOIN delivery_task_subject dts ON dts.id = r.delivery_task_subject_id