2 Commits
Author SHA1 Message Date
shishengliang dae727dfde Merge feature/asset-statistics into main
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-09-16 10:38:20 +08:00
shishengliang 0c26b973ef feat(assets): align flow statistics and separate abnormal inventory
ci/woodpecker/push/woodpecker Pipeline failed
Use operations signing times and delivery completion statuses; include replacement deliveries in delivery totals with range drilldowns and exports. Separate abnormal vehicles from inventory, add a date range picker, and enable CI for feature/asset-statistics.
2026-09-16 10:35:50 +08:00
21 changed files with 371 additions and 119 deletions
+5 -4
View File
@@ -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<FlowStatsResponse | null>(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<FlowSelection | null>(null);
// Dept/Region/Customer data
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
@@ -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 || '',
@@ -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<FlowType, { label: string; tone: string; chip: string }> = {
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<React.SetStateAction<{ date: string; type: FlowType } | null>>;
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
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({
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"></div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400"> · </div>
<h3 className="mt-1 text-lg font-black">
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}
{dateLabel} · {FLOW_META[selectedFlow.type].label}
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
{selectedFlowDetails.length}
{selectedFlowDetails.length}
</div>
</div>
<button
type="button"
aria-label="关闭流转明细"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
@@ -56,7 +61,7 @@ export function FlowDetailModal({
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
@@ -72,8 +77,8 @@ export function FlowDetailModal({
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3"></th>
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}</th>
<th className="w-40 px-3 py-3"></th>
<th className="w-40 px-3 py-3"></th>
<th className="w-40 px-3 py-3"></th>
<th className="w-36 px-3 py-3"></th>
<th className="w-28 px-3 py-3"></th>
<th className="px-3 py-3"></th>
@@ -82,7 +87,7 @@ export function FlowDetailModal({
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600"></span>}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
@@ -105,13 +110,13 @@ export function FlowDetailModal({
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div></div>
<div></div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">{item.typeLabel}</div>
<div className="font-black text-slate-400"></div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
@@ -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' ? '正在运营' : '全部状态'}
</p>
@@ -14,7 +14,7 @@ export function AssetSummaryDesktopTable({
}: AssetSummarySectionProps) {
return (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
<thead>
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
<th className="p-3 font-semibold border-r border-gray-100 w-24"></th>
@@ -26,6 +26,7 @@ export function AssetSummaryDesktopTable({
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">-</th>
<th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24"></th>
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24"></th>
@@ -43,7 +44,7 @@ export function AssetSummaryDesktopTable({
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
}`}
onClick={() => toggleAssetType(typeGroup.type)}>
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
<td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{expandedAssetTypes.has(typeGroup.type) ?
@@ -55,6 +56,7 @@ export function AssetSummaryDesktopTable({
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
<span> <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
@@ -119,6 +121,12 @@ export function AssetSummaryDesktopTable({
) : ''}
</td>
))}
<td className="p-3 text-center border-r border-gray-100">
<button type="button" className="font-bold text-amber-600 hover:underline"
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
{model.abnormal}
</button>
</td>
<td className="p-3 text-center border-r border-gray-100">
{model.pending > 0 ? (
<button
@@ -33,6 +33,7 @@ export function AssetSummaryMobileCards({
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
<span> <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
<span> <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
@@ -55,9 +56,10 @@ export function AssetSummaryMobileCards({
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className="text-xs font-bold text-gray-700">{model.model}</span>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap justify-end gap-2">
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}> {model.total}</span>
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}> {model.inventory}</span>
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}> {model.abnormal}</button>
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}> {model.operating}</span>
</div>
</div>
@@ -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<HTMLDialogElement>(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 <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} {m + 1} </div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${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 <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400"> · </span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} </span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { 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">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900"></h3><p className="mt-1 text-xs text-slate-400"></p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} </option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} </option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100"></button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} /></button>
</div>
</div>
</dialog>
</>;
}
@@ -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({
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span></span>
<span></span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
@@ -39,37 +40,8 @@ export function FlowStatisticsCard({
</button>
</div>
</div>
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.start}
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400"></div>
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
<span className="block text-[9px] font-black text-slate-400"></span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
<CalendarDays size={12} className="text-slate-300" />
</span>
<input
type="date"
value={flowRange.end}
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
</div>
</div>
<p className="mt-1 text-[11px] text-slate-400"></p>
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
@@ -77,12 +49,16 @@ export function FlowStatisticsCard({
<div className="mt-1 text-[10px] font-black text-slate-400"></div>
</div>
{FLOW_TYPES.map((type) => (
<div key={type} className="border-l border-slate-200/70 px-2">
<button key={type} type="button"
data-testid={`asset-flow-total-${type}`}
disabled={flowLoading || !flowStats?.totals[type]}
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-35">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
))}
</div>
</div>
@@ -114,7 +90,7 @@ export function FlowStatisticsCard({
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
<div className="text-[10px] font-bold italic text-slate-300"></div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
@@ -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"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
);
})}
@@ -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 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-2">
{/* Total Assets */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
@@ -49,11 +49,21 @@ export function SummaryMetrics({
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
<span className="text-[8px] text-gray-400 leading-none">{SUMMARY.inventory.inStock} {SUMMARY.inventory.abnormal}</span>
</div>
</div>
</div>
<button type="button" data-testid="asset-abnormal-card"
className="rounded-2xl border border-amber-100 bg-white p-3 shadow-sm flex items-center gap-2 text-left transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '异动车辆' })}>
<div className="w-8 h-8 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600"><TriangleAlert size={14} /></div>
<div>
<div className="text-[9px] text-amber-600 font-bold leading-none mb-0.5"></div>
<div className="text-base font-bold text-amber-600 leading-none">{SUMMARY.inventory.abnormal}</div>
</div>
</button>
{/* Pending */}
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
@@ -70,7 +80,7 @@ export function SummaryMetrics({
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="shrink-0 text-[11px] font-black text-slate-400"></div>
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
<div className="grid min-w-0 flex-1 grid-cols-4 divide-x divide-slate-100 text-center">
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
@@ -79,6 +89,10 @@ export function SummaryMetrics({
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-amber-600">{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).toFixed(1)}%</div>
</div>
<div className="px-2">
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
@@ -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<SetStateAction<boolean>>;
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
}
export interface AssetSummarySectionProps {
+14
View File
@@ -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 }),
+10 -6
View File
@@ -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'))
));
}
+6
View File
@@ -24,6 +24,7 @@ export interface BatchSummary {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -36,6 +37,7 @@ export interface ModelSummary {
model: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -49,6 +51,7 @@ export interface TypeSummary {
type: string;
totalAssets: number;
totalInventory: number;
totalAbnormal: number;
totalOperating: number;
inventoryRegions: Record<string, number>;
pending: number;
@@ -62,6 +65,7 @@ export interface BatchGroup {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -73,6 +77,7 @@ export interface BatchGroup {
type: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -153,6 +158,7 @@ export interface RegionTypeBreakdown {
total: number;
operating: number;
inventory: number;
abnormal: number;
pending: number;
customers: string[];
}
+10 -5
View File
@@ -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<string, string> = {
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;
}
+67
View File
@@ -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<string>(), delivered: new Set<string>(), returned: new Set<string>(), replaced: new Set<string>(),
}));
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);
});
+3 -2
View File
@@ -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,
+46 -1
View File
@@ -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/);
});
+18 -43
View File
@@ -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<FlowDetailRow[]> {
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end, start, end]);
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end]);
return rows as FlowDetailRow[];
}
+1
View File
@@ -12,6 +12,7 @@ export interface WeeklyTruckIds {
export interface VehicleStats {
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
+2 -1
View File
@@ -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,
+5
View File
@@ -81,6 +81,7 @@ export interface TypeSummary {
type: string;
totalAssets: number;
totalInventory: number;
totalAbnormal: number;
totalOperating: number;
inventoryRegions: Record<string, number>;
pending: number;
@@ -94,6 +95,7 @@ export interface ModelSummary {
model: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -107,6 +109,7 @@ export interface BatchSummary {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -119,6 +122,7 @@ export interface BatchGroup {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -130,6 +134,7 @@ export interface BatchGroup {
type: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
+2
View File
@@ -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: |