diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index f582e45..00e1415 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -37,6 +37,7 @@ import { getWeeklyFlowRange, selectFlowDetails, type VehicleModalSelection, + type FlowSelection, } from './model'; import { SearchSelect } from '../../components/SearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect'; @@ -87,7 +88,7 @@ export default function AssetsModule() { const [flowStats, setFlowStats] = useState(null); const [flowLoading, setFlowLoading] = useState(false); const [flowDailyExpanded, setFlowDailyExpanded] = useState(false); - const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null); + const [selectedFlow, setSelectedFlow] = useState(null); // Dept/Region/Customer data const [deptData, setDeptData] = useState([]); @@ -384,11 +385,11 @@ export default function AssetsModule() { const source = rows ?? flowStats?.details ?? []; if (source.length === 0) return; const table = source.map((item) => ({ - 日期: item.date, + 业务日期: item.date, 类型: item.typeLabel, 车牌: item.plateNumber, - 流转时间: item.eventTime || '', - 提交时间: item.submitTime || '', + 运维签章时间: item.eventTime || '', + 建单时间: item.submitTime || '', 部门: item.department || '', 业务负责人: item.manager || '', 客户: item.customerName || '', diff --git a/src/modules/assets/components/FlowDetailModal.tsx b/src/modules/assets/components/FlowDetailModal.tsx index 326717c..5935a00 100644 --- a/src/modules/assets/components/FlowDetailModal.tsx +++ b/src/modules/assets/components/FlowDetailModal.tsx @@ -2,17 +2,18 @@ import React from 'react'; import { Download, X } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { FlowDetailItem, FlowType } from '../api'; +import type { FlowSelection } from '../model'; export const FLOW_META: Record = { delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' }, returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' }, - replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' }, + replaced: { label: '替换交车', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' }, }; interface FlowDetailModalProps { - selectedFlow: { date: string; type: FlowType } | null; + selectedFlow: FlowSelection | null; selectedFlowDetails: FlowDetailItem[]; - setSelectedFlow: React.Dispatch>; + setSelectedFlow: React.Dispatch>; exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void; } export function FlowDetailModal({ @@ -21,6 +22,9 @@ export function FlowDetailModal({ setSelectedFlow, exportFlowDetails, }: FlowDetailModalProps) { + const dateLabel = selectedFlow + ? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`) + : ''; return ( <> {/* Flow Detail Modal */} @@ -37,16 +41,17 @@ export function FlowDetailModal({
-
提交时间口径
+
运维签章时间口径 · 单位:车次

- {selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细 + {dateLabel} · {FLOW_META[selectedFlow.type].label}明细

- 共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户 + 共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
-
提交
+
建单
{item.submitTime?.slice(5, 16) || '-'}
-
{item.typeLabel}时间
+
运维签章时间
{item.eventTime || '-'}
diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index e6de760..fa8b609 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -66,6 +66,7 @@ export function VehicleDetailModal({ showPlateNumbers.category === 'Delivered' ? '本周已交车' : showPlateNumbers.category === 'Returned' ? '已还车' : showPlateNumbers.category === 'Replaced' ? '已替换' : + showPlateNumbers.category === 'Abnormal' ? '异动' : showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` : showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}

diff --git a/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx b/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx index b72387c..800ce42 100644 --- a/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx +++ b/src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx @@ -14,7 +14,7 @@ export function AssetSummaryDesktopTable({ }: AssetSummarySectionProps) { return (
- +
@@ -26,6 +26,7 @@ export function AssetSummaryDesktopTable({ + @@ -43,7 +44,7 @@ export function AssetSummaryDesktopTable({ 'bg-blue-50/50 hover:bg-blue-50 transition-colors' }`} onClick={() => toggleAssetType(typeGroup.type)}> - ))} +
车型库存-北京 库存-新疆 库存-其他异动 待交车 当前在运营 本周交车 +
{expandedAssetTypes.has(typeGroup.type) ? @@ -55,6 +56,7 @@ export function AssetSummaryDesktopTable({
资产 {typeGroup.totalAssets} 库存 {typeGroup.totalInventory} + 异动 {typeGroup.totalAbnormal} 运营 {typeGroup.totalOperating}
@@ -119,6 +121,12 @@ export function AssetSummaryDesktopTable({ ) : ''}
+ + {model.pending > 0 ? ( { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating} diff --git a/src/modules/assets/components/overview/FlowDateRangePicker.tsx b/src/modules/assets/components/overview/FlowDateRangePicker.tsx new file mode 100644 index 0000000..e323fa2 --- /dev/null +++ b/src/modules/assets/components/overview/FlowDateRangePicker.tsx @@ -0,0 +1,118 @@ +import { useRef, useState } from 'react'; +import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { getWeeklyFlowRange, type DateRange } from '../../model'; + +const weekdays = ['一', '二', '三', '四', '五', '六', '日']; +const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +const parseDate = (value: string) => new Date(`${value}T12:00:00`); +const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); +const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1; + +export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) { + const dialog = useRef(null); + const [draft, setDraft] = useState(value); + const [selecting, setSelecting] = useState<'start' | 'end'>('start'); + const [month, setMonth] = useState(() => parseDate(value.start)); + const [hovered, setHovered] = useState(''); + const today = ymd(new Date()); + const count = draft.end ? dayCount(draft.start, draft.end) : 0; + const valid = count > 0 && count <= 370; + const monthStart = new Date(month.getFullYear(), month.getMonth(), 1); + const previewEnd = draft.end || hovered || draft.start; + const rangeStart = draft.start < previewEnd ? draft.start : previewEnd; + const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd; + const quickPicks = [ + { label: '今天', range: { start: today, end: today } }, + { label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } }, + { label: '业务周', range: getWeeklyFlowRange() }, + { label: '近 7 天', range: { start: ymd(shiftDay(new Date(), -6)), end: today } }, + { label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } }, + ]; + const open = () => { + setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered(''); + dialog.current?.showModal(); + }; + const choose = (date: string) => { + if (selecting === 'start') { + setDraft({ start: date, end: '' }); setSelecting('end'); + } else { + setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date }); + setSelecting('start'); + } + setHovered(''); + }; + const calendar = (offset: number) => { + const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1); + const year = current.getFullYear(), m = current.getMonth(); + const padding = (current.getDay() + 6) % 7; + const length = new Date(year, m + 1, 0).getDate(); + return
+
{year} 年 {m + 1} 月
+
+ {weekdays.map(day =>
{day}
)} + {Array.from({ length: padding }, (_, i) =>
)} + {Array.from({ length }, (_, i) => { + const date = ymd(new Date(year, m, i + 1)); + const endpoint = date === draft.start || date === draft.end; + const inRange = date >= rangeStart && date <= rangeEnd; + return ; + })} +
+
; + }; + return <> + + { if (e.target === e.currentTarget) dialog.current?.close(); }} + className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm"> +
+
+

选择统计日期

先选开始日期,再选结束日期

+ +
+
+ {quickPicks.map(pick => )} +
+
+ {(['start', 'end'] as const).map(field => )} +
+
+ +
+ + +
+ +
+
setHovered('')}>{calendar(0)}{calendar(1)}
+
+
+ 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`} +
+ + +
+
+
+ ; +} diff --git a/src/modules/assets/components/overview/FlowStatisticsCard.tsx b/src/modules/assets/components/overview/FlowStatisticsCard.tsx index be6dde4..a4f867d 100644 --- a/src/modules/assets/components/overview/FlowStatisticsCard.tsx +++ b/src/modules/assets/components/overview/FlowStatisticsCard.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react'; import type { FlowType } from '../../api'; import { FLOW_META } from '../FlowDetailModal'; import type { FlowStatisticsCardProps } from './types'; +import { FlowDateRangePicker } from './FlowDateRangePicker'; const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced']; @@ -23,7 +24,7 @@ export function FlowStatisticsCard({
- 交还车统计 + 交还车统计(车次) {flowLoading && }
@@ -39,37 +40,8 @@ export function FlowStatisticsCard({
-
-
- -
- -
-
+

按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。

+
@@ -77,12 +49,16 @@ export function FlowStatisticsCard({
合计
{FLOW_TYPES.map((type) => ( -
+
+
{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}
+ ))}
@@ -114,7 +90,7 @@ export function FlowStatisticsCard({
{day.date}
-
提交时间
+
运维签章时间
@@ -133,7 +109,7 @@ export function FlowStatisticsCard({ className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35" >
{count}
-
{FLOW_META[type].label}
+
{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}
); })} diff --git a/src/modules/assets/components/overview/SummaryMetrics.tsx b/src/modules/assets/components/overview/SummaryMetrics.tsx index ba5ce93..fa412f9 100644 --- a/src/modules/assets/components/overview/SummaryMetrics.tsx +++ b/src/modules/assets/components/overview/SummaryMetrics.tsx @@ -1,4 +1,4 @@ -import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react'; +import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse } from 'lucide-react'; import type { SummaryMetricsProps } from './types'; export function SummaryMetrics({ @@ -11,7 +11,7 @@ export function SummaryMetrics({ return ( <> {/* Header Summary - Ultra Compact */} -
+
{/* Total Assets */}
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}> @@ -49,11 +49,21 @@ export function SummaryMetrics({
总库存
{SUMMARY.inventory.total} - 库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal} +
+ + {/* Pending */}
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}> @@ -70,7 +80,7 @@ export function SummaryMetrics({
运营概览
-
+
运营率
{operatingRate.toFixed(1)}%
@@ -79,6 +89,10 @@ export function SummaryMetrics({
库存率
{inventoryRate.toFixed(1)}%
+
+
异动率
+
{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).toFixed(1)}%
+
待交率
{pendingRate.toFixed(1)}%
diff --git a/src/modules/assets/components/overview/types.ts b/src/modules/assets/components/overview/types.ts index f9be440..a5d4f7b 100644 --- a/src/modules/assets/components/overview/types.ts +++ b/src/modules/assets/components/overview/types.ts @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api'; import type { DateRange, + FlowSelection, InventoryFilters, VehicleModalSelection, } from '../../model'; @@ -24,7 +25,7 @@ export interface FlowStatisticsCardProps { exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void; flowDailyExpanded: boolean; setFlowDailyExpanded: Dispatch>; - setSelectedFlow: Dispatch>; + setSelectedFlow: Dispatch>; } export interface AssetSummarySectionProps { diff --git a/src/modules/assets/model.test.ts b/src/modules/assets/model.test.ts index 789a201..34ad74d 100644 --- a/src/modules/assets/model.test.ts +++ b/src/modules/assets/model.test.ts @@ -296,6 +296,20 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', ['1'], ); + flowStats.details.push( + { ...flowStats.details[0], id: '3', type: 'replaced', typeLabel: '替换交车' }, + { ...flowStats.details[0], id: '4', type: 'replaced', typeLabel: '替换交车', date: '2026-08-13' }, + ); + assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map(x => x.id), ['1', '3']); + assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'replaced' }).map(x => x.id), ['3']); + assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'returned' }).map(x => x.id), ['2']); + + assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['1', '3', '4']); + assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'returned' }).map(x => x.id), ['2']); + assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'replaced' }).map(x => x.id), ['3', '4']); + assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-13', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['4']); + assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-14', end: '2026-08-15', type: 'delivered' }), []); + const customers = [ customer({ region: '广东', total: 2 }), customer({ customer: '客户乙', region: '浙江', total: 5 }), diff --git a/src/modules/assets/model.ts b/src/modules/assets/model.ts index 1b562a1..8b1330f 100644 --- a/src/modules/assets/model.ts +++ b/src/modules/assets/model.ts @@ -50,6 +50,7 @@ export interface ModalVehicleFilters { export type VehicleModalCategory = | 'Inventory' + | 'Abnormal' | 'Pending' | 'Delivered' | 'Returned' @@ -77,7 +78,7 @@ export interface VehicleListRequestParams { batch?: string; model?: string; location?: string; - category?: 'Inventory' | 'Operating' | 'Pending'; + category?: 'Inventory' | 'Abnormal' | 'Operating' | 'Pending'; vehicleType?: string; manager?: string; customer?: string; @@ -97,10 +98,9 @@ export type VehicleModalRequest = } | { kind: 'vehicles'; params: VehicleListRequestParams }; -export interface FlowSelection { - date: string; - type: FlowType; -} +export type FlowSelection = + | { date: string; type: FlowType } + | { start: string; end: string; type: FlowType }; export function formatLocalDateTime(date: Date): string { const year = date.getFullYear(); @@ -172,6 +172,7 @@ export function buildVehicleModalRequest( if (selection.model !== 'All') params.model = selection.model; if (selection.location !== 'All') params.location = selection.location; if (selection.source) params.source = selection.source; + if (selection.category === 'Abnormal') params.category = 'Abnormal'; if (selection.category === 'Inventory') params.category = 'Inventory'; if (selection.category === 'Operating') params.category = 'Operating'; if (selection.category === 'Pending') params.category = 'Pending'; @@ -391,7 +392,10 @@ export function selectFlowDetails( ) { if (!flowStats || !selection) return []; return flowStats.details.filter((detail) => ( - detail.date === selection.date && detail.type === selection.type + ('date' in selection + ? detail.date === selection.date + : detail.date >= selection.start && detail.date <= selection.end) && (detail.type === selection.type || + (selection.type === 'delivered' && detail.type === 'replaced')) )); } diff --git a/src/modules/assets/types.ts b/src/modules/assets/types.ts index 9aef3a5..92ad096 100644 --- a/src/modules/assets/types.ts +++ b/src/modules/assets/types.ts @@ -24,6 +24,7 @@ export interface BatchSummary { batch: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -36,6 +37,7 @@ export interface ModelSummary { model: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -49,6 +51,7 @@ export interface TypeSummary { type: string; totalAssets: number; totalInventory: number; + totalAbnormal: number; totalOperating: number; inventoryRegions: Record; pending: number; @@ -62,6 +65,7 @@ export interface BatchGroup { batch: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -73,6 +77,7 @@ export interface BatchGroup { type: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -153,6 +158,7 @@ export interface RegionTypeBreakdown { total: number; operating: number; inventory: number; + abnormal: number; pending: number; customers: string[]; } diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index b228751..c2e24b4 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -70,7 +70,7 @@ app.get('/summary', async (c) => { hanging: 0, }, inventory: { - total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length, + total: vehicles.filter((v) => v.status === 'Inventory').length, inStock: vehicles.filter((v) => v.status === 'Inventory').length, abnormal: vehicles.filter((v) => v.status === 'Abnormal').length, }, @@ -111,6 +111,7 @@ app.get('/by-type', async (c) => { type: t.name, totalAssets: typeVehicles.length, totalInventory: typeStats.inventory, + totalAbnormal: typeStats.abnormal, totalOperating: typeStats.operating, inventoryRegions: typeStats.inventoryRegions, pending: typeStats.pending, @@ -273,6 +274,7 @@ app.get('/region-stats', async (c) => { total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, + abnormal: tv.filter((v) => v.status === 'Abnormal').length, pending: tv.filter((v) => v.status === 'Pending').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[], }); @@ -405,7 +407,9 @@ app.get('/list', async (c) => { } if (category) { if (category === 'Inventory') { - filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal'); + filtered = filtered.filter((v) => v.status === 'Inventory'); + } else if (category === 'Abnormal') { + filtered = filtered.filter((v) => v.status === 'Abnormal'); } else if (category === 'Operating') { filtered = filtered.filter((v) => v.status === 'Operating'); } else if (category === 'Pending') { @@ -461,7 +465,7 @@ app.get('/list', async (c) => { // GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放 app.get('/inventory-stats', async (c) => { const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles()); - const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal'); + const inventory = vehicles.filter((v) => v.status === 'Inventory'); const TYPE_NAME_MAP: Record = { t4_5: '4.5T普货', @@ -525,7 +529,7 @@ app.get('/weekly-detail', async (c) => { }); // GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD -// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。 +// 资产流转日报:按运维签章时间统计业务车次;交车包含关联替换单的记录,替换作为其中的子项,同车多笔业务分别累计。 app.get('/flow-stats', async (c) => { const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end')); const allowedVehicles = await getVehiclesForUser(c); @@ -555,6 +559,7 @@ app.get('/flow-stats', async (c) => { const stat = dailyMap.get(item.date); if (!stat) continue; stat[item.type] += 1; + if (item.type === 'replaced') stat.delivered += 1; stat.total += 1; } @@ -585,7 +590,7 @@ app.get('/subjects', async (c) => { if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 }); const s = map.get(name)!; s.total += 1; - if (v.status === 'Inventory' || v.status === 'Abnormal') s.inventory += 1; + if (v.status === 'Inventory') s.inventory += 1; if (v.status === 'Operating') s.operating += 1; } diff --git a/src/server/routes/vehicles/flow.test.ts b/src/server/routes/vehicles/flow.test.ts new file mode 100644 index 0000000..9a31236 --- /dev/null +++ b/src/server/routes/vehicles/flow.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import router from '../vehicles.js'; +import { vehicleRepository, type FlowDetailRow } from './repository.js'; +import type { Vehicle } from '../../types.js'; + +test('replacement trips are included in delivery counts without inflating totals or duplicating export rows', async (t) => { + t.mock.method(vehicleRepository, 'getVehicles', async () => [{ id: '1' } as Vehicle]); + const row = (id: string, type: FlowDetailRow['type'], date: string): FlowDetailRow => ({ + id, type, type_label: type === 'replaced' ? '替换交车' : type === 'delivered' ? '交车' : '还车', + 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, + }); + t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [ + row('delivered-1', 'delivered', '2026-09-15'), + row('replaced-2', 'replaced', '2026-09-15'), + row('returned-1', 'returned', '2026-09-15'), + row('replaced-3', 'replaced', '2026-09-16'), + ]); + const response = await router.request('/flow-stats?start=2026-09-15&end=2026-09-17'); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual(data.totals, { delivered: 3, returned: 1, replaced: 2, total: 4 }); + assert.deepEqual(data.daily, [ + { date: '2026-09-15', delivered: 2, returned: 1, replaced: 1, total: 3 }, + { date: '2026-09-16', delivered: 1, returned: 0, replaced: 1, total: 1 }, + { date: '2026-09-17', delivered: 0, returned: 0, replaced: 0, total: 0 }, + ]); + assert.equal(data.details.length, 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') + .every((x: { typeLabel: string }) => x.typeLabel === '替换交车')); +}); + +test('inventory excludes abnormal vehicles in totals, type rollups, regions and drilldowns', async (t) => { + const vehicles = ['Inventory', 'Abnormal', 'Pending', 'Operating'].map((status, i) => ({ + id: String(i), status, operationStatus: status === 'Operating' ? '1' : '3', + plateNumber: `测试${i}`, model: '现代4.5T普货', type: '4.5T', location: '广东', + subjectOrg: i === 1 ? '乙公司' : '甲公司', + } as Vehicle)); + t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles); + t.mock.method(vehicleRepository, 'getWeeklyTruckIds', async () => ({ + pending: new Set(), delivered: new Set(), returned: new Set(), replaced: new Set(), + })); + const get = async (path: string) => { + const response = await router.request(path); + assert.equal(response.status, 200); + return response.json(); + }; + const summary = await get('/summary'); + assert.equal(summary.totalAssets, 4); + assert.equal(summary.inventory.total, 1); + assert.equal(summary.inventory.abnormal, 1); + assert.equal(summary.totalAssets, summary.inventory.total + summary.inventory.abnormal + summary.operating.total + summary.pendingDelivery); + assert.deepEqual((await get('/list?category=Inventory')).map((v: Vehicle) => v.id), ['0']); + assert.deepEqual((await get('/list?category=Abnormal')).map((v: Vehicle) => v.id), ['1']); + assert.deepEqual(await get('/list?category=Abnormal&subject=甲公司'), []); + const groups = await get('/by-type'); + assert.equal(groups.reduce((n: number, g: { totalInventory: number }) => n + g.totalInventory, 0), 1); + assert.equal(groups.reduce((n: number, g: { totalAbnormal: number }) => n + g.totalAbnormal, 0), 1); + const model = groups.flatMap((g: { models: unknown[] }) => g.models).find((m: { abnormal: number }) => m.abnormal === 1); + assert.equal(model.inventory, 1); + assert.equal(model.inventoryRegions['广东'], 1); + const inventory = await get('/inventory-stats'); + assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 1); +}); diff --git a/src/server/routes/vehicles/model.test.ts b/src/server/routes/vehicles/model.test.ts index e434483..8601f79 100644 --- a/src/server/routes/vehicles/model.test.ts +++ b/src/server/routes/vehicles/model.test.ts @@ -213,8 +213,9 @@ test('preserves inventory, weekly-flow and mileage aggregation boundaries', () = }); assert.deepEqual(getStats(vehicles, weeklyIds), { total: 4, - inventory: 2, - inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 1, 新疆: 0, 其他: 0 }, + inventory: 1, + abnormal: 1, + inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 0, 新疆: 0, 其他: 0 }, pending: 1, operating: 1, weeklyDelivered: 1, diff --git a/src/server/routes/vehicles/repository.test.ts b/src/server/routes/vehicles/repository.test.ts index 9778fdf..5c95a11 100644 --- a/src/server/routes/vehicles/repository.test.ts +++ b/src/server/routes/vehicles/repository.test.ts @@ -110,7 +110,52 @@ test('keeps weekly detail SQL selection and flow date parameters unchanged', asy { sql: WEEKLY_DETAIL_SQL.new, values: undefined }, { sql: FLOW_STATS_SQL, - values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'], + values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'], }, ]); }); + +test('flow dates and inclusive day boundaries use operations signing times, retaining creation time only for display', () => { + for (const [alias, field] of [['dv', 'delivery_completion_processed_time'], ['r', 'return_completion_processed_time']]) { + assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.${field}, '%Y-%m-%d') AS stat_date`)); + assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} IS NOT NULL`)); + assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} >= ?`)); + assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} < DATE_ADD(?, INTERVAL 1 DAY)`)); + assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time`)); + } + assert.doesNotMatch(FLOW_STATS_SQL, /AND \w+\.create_time/); + assert.ok(FLOW_STATS_SQL.includes('ORDER BY flow.event_time DESC, flow.id DESC')); +}); + +test('flow retains multiple business events for the same vehicle', async () => { + const events = [ + { id: 'delivered-1', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' }, + { id: 'delivered-2', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' }, + { id: 'returned-1', truck_id: '1', type: 'returned', stat_date: '2026-09-16' }, + ]; + const repository = new VehicleRepository(new FakeDatabase([events])); + assert.deepEqual(await repository.getFlowDetailRows('2026-09-16', '2026-09-16'), events); +}); + + +test('replacement trips come exclusively from linked delivery records', () => { + assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type")); + assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label")); + assert.equal((FLOW_STATS_SQL.match(/FROM delivery_vehicle/g) || []).length, 1); + assert.doesNotMatch(FLOW_STATS_SQL, /FROM vehicle_replacement|dv\.delivery_time|r\.arrival_time|vr\.replace_time/); +}); + +test('completed delivery and replacement trips use vehicle status and exclude suspended subjects', () => { + const [deliveries, returns] = FLOW_STATS_SQL.split('UNION ALL'); + assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,20)')); + assert.ok(deliveries.includes('AND (dts.status IS NULL OR dts.status <> 10)')); + assert.doesNotMatch(deliveries, /dts\.status\s*(?:=|IN)|dv\.delivery_status IN \(2,3,5\)/); + assert.ok(returns.includes('AND r.status IN (2,3,5)')); +}); + +test('return history includes completed, signed and awaiting-signature statuses without arrival or settlement requirements', () => { + const returns = FLOW_STATS_SQL.split('UNION ALL')[1]; + assert.ok(returns.includes('AND r.status IN (2,3,5)')); + assert.ok(returns.includes('AND r.return_completion_processed_time >= ?')); + assert.doesNotMatch(returns, /r\.is_arrived|r\.arrival_time|settlement|AND dts\.status|AND \(dts\.status/); +}); diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index 1c95284..b2d23f6 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -231,13 +231,13 @@ export const FLOW_STATS_SQL = ` SELECT * FROM ( SELECT - CONCAT('delivered-', dv.id) AS id, - 'delivered' AS type, - '交车' AS type_label, - DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date, + CONCAT(CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced-' ELSE 'delivered-' END, dv.id) AS id, + CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type, + CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label, + DATE_FORMAT(dv.delivery_completion_processed_time, '%Y-%m-%d') AS stat_date, CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number, - DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time, + DATE_FORMAT(dv.delivery_completion_processed_time, '%Y-%m-%d %H:%i:%s') AS event_time, 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, @@ -251,10 +251,11 @@ export const FLOW_STATS_SQL = ` AND c.del_flag = '0' WHERE dv.del_flag = '0' AND dv.vehicle_id IS NOT NULL - AND dv.create_time IS NOT NULL - AND dv.delivery_status IN (2,3,5) - AND dv.create_time >= ? - AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY) + AND dv.delivery_completion_processed_time IS NOT NULL + AND dv.delivery_status IN (2,3,20) + AND (dts.status IS NULL OR dts.status <> 10) + AND dv.delivery_completion_processed_time >= ? + AND dv.delivery_completion_processed_time < DATE_ADD(?, INTERVAL 1 DAY) UNION ALL @@ -262,10 +263,10 @@ export const FLOW_STATS_SQL = ` CONCAT('returned-', r.id) AS id, 'returned' AS type, '还车' AS type_label, - DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date, + DATE_FORMAT(r.return_completion_processed_time, '%Y-%m-%d') AS stat_date, CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number, - DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time, + DATE_FORMAT(r.return_completion_processed_time, '%Y-%m-%d %H:%i:%s') AS event_time, 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, @@ -279,40 +280,14 @@ export const FLOW_STATS_SQL = ` AND c.del_flag = '0' WHERE r.del_flag = '0' AND r.vehicle_id IS NOT NULL - AND r.create_time IS NOT NULL + AND r.return_completion_processed_time IS NOT NULL + -- 与还车管理历史记录一致:完成、已签章、待签署;日期按运维签章时间归属。 AND r.status IN (2,3,5) - AND r.create_time >= ? - AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY) + AND r.return_completion_processed_time >= ? + AND r.return_completion_processed_time < DATE_ADD(?, INTERVAL 1 DAY) - UNION ALL - - SELECT - CONCAT('replaced-', vr.id) AS id, - 'replaced' AS type, - '替换' AS type_label, - DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date, - CAST(vr.new_vehicle_id AS CHAR) AS truck_id, - vr.new_vehicle_plate AS plate_number, - DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time, - DATE_FORMAT(vr.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 - FROM vehicle_replacement vr - LEFT JOIN delivery_task_subject dts - ON dts.id = vr.delivery_task_subject_id - AND dts.del_flag = '0' - LEFT JOIN vehicle_lease_contract_info c - ON c.id = vr.contract_id - AND c.del_flag = '0' - WHERE vr.del_flag = '0' - AND vr.new_vehicle_id IS NOT NULL - AND vr.create_time IS NOT NULL - AND vr.status = 20 - AND vr.create_time >= ? - AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY) ) flow - ORDER BY flow.submit_time DESC + ORDER BY flow.event_time DESC, flow.id DESC `; const CACHE_TTL = 60 * 1000; @@ -420,7 +395,7 @@ export class VehicleRepository { } async getFlowDetailRows(start: string, end: string): Promise { - const [rows] = await this.db.query(FLOW_STATS_SQL, [start, end, start, end, start, end]); + const [rows] = await this.db.query(FLOW_STATS_SQL, [start, end, start, end]); return rows as FlowDetailRow[]; } diff --git a/src/server/routes/vehicles/types.ts b/src/server/routes/vehicles/types.ts index a805e7f..dbc9b25 100644 --- a/src/server/routes/vehicles/types.ts +++ b/src/server/routes/vehicles/types.ts @@ -12,6 +12,7 @@ export interface WeeklyTruckIds { export interface VehicleStats { total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; diff --git a/src/server/routes/vehicles/utils.ts b/src/server/routes/vehicles/utils.ts index 96c9a40..ae6b1ff 100644 --- a/src/server/routes/vehicles/utils.ts +++ b/src/server/routes/vehicles/utils.ts @@ -28,10 +28,11 @@ export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]) export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats { const ids = list.map((vehicle) => String(vehicle.id)); - const inventory = list.filter((vehicle) => vehicle.status === 'Inventory' || vehicle.status === 'Abnormal'); + const inventory = list.filter((vehicle) => vehicle.status === 'Inventory'); return { total: list.length, inventory: inventory.length, + abnormal: list.filter((vehicle) => vehicle.status === 'Abnormal').length, inventoryRegions: getRegionCounts(inventory, REGIONS), pending: list.filter((vehicle) => vehicle.status === 'Pending').length, operating: list.filter((vehicle) => vehicle.status === 'Operating').length, diff --git a/src/server/types.ts b/src/server/types.ts index a10135c..02c3587 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -81,6 +81,7 @@ export interface TypeSummary { type: string; totalAssets: number; totalInventory: number; + totalAbnormal: number; totalOperating: number; inventoryRegions: Record; pending: number; @@ -94,6 +95,7 @@ export interface ModelSummary { model: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -107,6 +109,7 @@ export interface BatchSummary { batch: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -119,6 +122,7 @@ export interface BatchGroup { batch: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; @@ -130,6 +134,7 @@ export interface BatchGroup { type: string; total: number; inventory: number; + abnormal: number; inventoryRegions: Record; pending: number; operating: number; diff --git a/woodpecker.yml b/woodpecker.yml index c81bb91..14f4d5e 100644 --- a/woodpecker.yml +++ b/woodpecker.yml @@ -10,6 +10,7 @@ steps: - master - develop - main + - feature/asset-statistics commands: | cd $CI_WORKSPACE npm ci @@ -38,6 +39,7 @@ steps: - master - develop - main + - feature/asset-statistics volumes: - /var/run/docker.sock:/var/run/docker.sock commands: |