Compare commits
2 Commits
main
...
dev-ljyang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdef0a940a | ||
|
|
0dc45504f2 |
@@ -10,16 +10,6 @@ services:
|
|||||||
DB_USER: "root"
|
DB_USER: "root"
|
||||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
DB_PASSWORD: "LN#Passw0rd@2026"
|
||||||
DB_NAME: "lingniu_prod"
|
DB_NAME: "lingniu_prod"
|
||||||
HYDROGEN_DB_HOST: "47.99.185.173"
|
|
||||||
HYDROGEN_DB_PORT: "3306"
|
|
||||||
HYDROGEN_DB_USER: "root"
|
|
||||||
HYDROGEN_DB_PASSWORD: "lnMysql."
|
|
||||||
HYDROGEN_DB_NAME: "ln_asset_management"
|
|
||||||
MILEAGE_DB_HOST: "101.133.130.65"
|
|
||||||
MILEAGE_DB_PORT: "3306"
|
|
||||||
MILEAGE_DB_USER: "bi_reader_02"
|
|
||||||
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
|
||||||
MILEAGE_DB_NAME: "hydrogen_energy"
|
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>羚牛氢能</title>
|
<title>羚牛氢能车辆资产</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -128,10 +128,6 @@ function AuthGate() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
|
||||||
}, [pathSet]);
|
|
||||||
|
|
||||||
const modules = useMemo<ModuleConfig[]>(() => {
|
const modules = useMemo<ModuleConfig[]>(() => {
|
||||||
if (pathSet === "energy") {
|
if (pathSet === "energy") {
|
||||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||||
|
|||||||
@@ -82,7 +82,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const jumpToken = params.get('jumpToken');
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
if (!jumpToken) {
|
if (!jumpToken) {
|
||||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
|
// 临时:本地开发免登录,含智能调度权限
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: {
|
||||||
|
userId: '1105261382487539712',
|
||||||
|
userName: '本地调试',
|
||||||
|
permissionLevel: 'full',
|
||||||
|
depName: '',
|
||||||
|
roles: ['BI-SCHEDULE-OPT'],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,3 @@ body {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes marquee {
|
|
||||||
from { transform: translateX(0); }
|
|
||||||
to { transform: translateX(-50%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility animate-marquee {
|
|
||||||
animation: marquee 30s linear infinite;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,42 +46,6 @@ const TABS = [
|
|||||||
{ id: 'customer', label: '按客户' },
|
{ id: 'customer', label: '按客户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function MarqueeBanner() {
|
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
|
||||||
const innerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [overflow, setOverflow] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const check = () => {
|
|
||||||
if (!trackRef.current || !innerRef.current) return;
|
|
||||||
setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth);
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
const ro = new ResizeObserver(check);
|
|
||||||
ro.observe(trackRef.current!);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const text = '⚠️ 因 OneOS 数据迁移未完成,当前状态暂不准确,预计下周完成对接后恢复正常。';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative -mx-6 mb-4 bg-amber-50 border-y border-amber-200">
|
|
||||||
<div ref={trackRef} className="overflow-hidden">
|
|
||||||
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
|
||||||
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-amber-700 font-medium">
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
{overflow && (
|
|
||||||
<span className="inline-block whitespace-nowrap px-6 text-xs text-amber-700 font-medium">
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AssetsModule() {
|
export default function AssetsModule() {
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview');
|
||||||
const [tabReady, setTabReady] = useState(true);
|
const [tabReady, setTabReady] = useState(true);
|
||||||
@@ -128,7 +92,7 @@ export default function AssetsModule() {
|
|||||||
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdate] = useState<string>('2026-06-03 23:59:59');
|
const [lastUpdate, setLastUpdate] = useState<string>('');
|
||||||
const [modalLoading, setModalLoading] = useState(false);
|
const [modalLoading, setModalLoading] = useState(false);
|
||||||
|
|
||||||
// Dept/Region/Customer data
|
// Dept/Region/Customer data
|
||||||
@@ -198,6 +162,7 @@ export default function AssetsModule() {
|
|||||||
setRegionData(region);
|
setRegionData(region);
|
||||||
setCustomerData(cust);
|
setCustomerData(cust);
|
||||||
setInventoryData(inv);
|
setInventoryData(inv);
|
||||||
|
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : '数据加载失败');
|
setError(e instanceof Error ? e.message : '数据加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -561,7 +526,7 @@ export default function AssetsModule() {
|
|||||||
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
|
<div className="sticky top-0 z-40 -mx-6 -mt-6 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80">
|
||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||||
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能车辆资产</h1>
|
||||||
{/* Right: status + theme */}
|
{/* Right: status + theme */}
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
@@ -725,15 +690,12 @@ export default function AssetsModule() {
|
|||||||
最后更新: {lastUpdate}
|
最后更新: {lastUpdate}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-1 h-1 rounded-full bg-amber-400 inline-block" />
|
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||||
OneOS数据源接入中
|
每分钟更新一次
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OneOS 迁移提示滚动条 */}
|
|
||||||
<MarqueeBanner />
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ import PlateMultiSelect from './PlateMultiSelect';
|
|||||||
import { exportMileageXlsx } from './xlsx-export';
|
import { exportMileageXlsx } from './xlsx-export';
|
||||||
import VehicleDetailModal from './VehicleDetailModal';
|
import VehicleDetailModal from './VehicleDetailModal';
|
||||||
|
|
||||||
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
|
||||||
'交投40辆4.5T普货',
|
|
||||||
'交投190辆4.5T冷链车',
|
|
||||||
]);
|
|
||||||
const HIGH_MILEAGE_ALERT_KM = 800;
|
|
||||||
|
|
||||||
const SearchableSelect = ({
|
const SearchableSelect = ({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -143,12 +137,6 @@ export default function MonitoringView() {
|
|||||||
const departments = filterOptions.departments;
|
const departments = filterOptions.departments;
|
||||||
const plateNumbers = filterOptions.plates;
|
const plateNumbers = filterOptions.plates;
|
||||||
|
|
||||||
const isHighMileageAlert = useCallback((v: MonitoringVehicle) => {
|
|
||||||
const inAlertTarget = v.targetNames?.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name))
|
|
||||||
|| HIGH_MILEAGE_ALERT_TARGETS.has(filterTargetName);
|
|
||||||
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
|
|
||||||
}, [filterTargetName]);
|
|
||||||
|
|
||||||
// 加载首页数据
|
// 加载首页数据
|
||||||
const loadFirstPage = useCallback(() => {
|
const loadFirstPage = useCallback(() => {
|
||||||
setPageLoading(true);
|
setPageLoading(true);
|
||||||
@@ -537,9 +525,7 @@ export default function MonitoringView() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
<tbody className="divide-y divide-slate-800/30">
|
||||||
{fullscreenVehicles.map((v) => {
|
{fullscreenVehicles.map((v) => (
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
|
||||||
return (
|
|
||||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
<div className={`w-2 h-2 rounded-full mx-auto ${v.isOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]' : (v.isDataSynced || v.totalKm != null) ? 'bg-slate-600' : 'bg-amber-400 animate-pulse'}`}></div>
|
||||||
@@ -549,8 +535,8 @@ export default function MonitoringView() {
|
|||||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
|
||||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-400' : 'text-amber-400'}`}>
|
||||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
@@ -559,8 +545,7 @@ export default function MonitoringView() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,7 +581,7 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-500 animate-pulse"></span>
|
||||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">数据监控 • 每15分钟更新</span>
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tight">实时监控 • 每分钟更新</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -911,9 +896,7 @@ export default function MonitoringView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5">
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
{filteredVehicles.map((v) => {
|
{filteredVehicles.map((v) => (
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
|
||||||
return (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -947,8 +930,8 @@ export default function MonitoringView() {
|
|||||||
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" title="未对接车机数据"></div>
|
||||||
)}
|
)}
|
||||||
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
<span className="text-[7px] font-black text-blue-600/40 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none">今</span>
|
||||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? 'text-blue-600' : 'text-amber-600'}`}>
|
||||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -959,8 +942,7 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredVehicles.length === 0 && !loadingMore && (
|
{filteredVehicles.length === 0 && !loadingMore && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
Truck, ChevronDown, Maximize2, Minimize2,
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
@@ -19,31 +19,11 @@ function getDefaultDate(): string {
|
|||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentDateLabel(): string {
|
|
||||||
const now = new Date();
|
|
||||||
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtKm(value: number): string {
|
function fmtKm(value: number): string {
|
||||||
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
if (value >= 10000) return (value / 10000).toFixed(2) + '万';
|
||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPercent(value: number): string {
|
|
||||||
return `${value.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null {
|
|
||||||
if (target.yearlyAssessments.length === 0) return null;
|
|
||||||
return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDateLabel(date: string | null): string {
|
|
||||||
if (!date) return '';
|
|
||||||
const [year, month, day] = date.split('-');
|
|
||||||
return `${year}.${Number(month)}.${Number(day)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortTargetName(name: string): string {
|
function shortTargetName(name: string): string {
|
||||||
// Extract the number and a short description
|
// Extract the number and a short description
|
||||||
const match = name.match(/(\d+)[辆台](.+)/);
|
const match = name.match(/(\d+)[辆台](.+)/);
|
||||||
@@ -59,7 +39,6 @@ function shortTargetName(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
const currentDateLabel = getCurrentDateLabel();
|
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
@@ -67,8 +46,7 @@ export default function StatisticsView() {
|
|||||||
|
|
||||||
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||||
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
||||||
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
|
||||||
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
|
||||||
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||||
@@ -76,25 +54,12 @@ export default function StatisticsView() {
|
|||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
const [viewAllLoading, setViewAllLoading] = useState(false);
|
||||||
|
|
||||||
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
|
||||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
|
||||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
|
||||||
|
|
||||||
// Load targets on mount
|
// Load targets on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
setTargets(data);
|
||||||
const ordered = focused
|
if (data.length > 0 && !selectedTargetId) {
|
||||||
? [focused, ...data.filter(item => item.id !== focused.id)]
|
setSelectedTargetId(data[0].id);
|
||||||
: data;
|
|
||||||
setTargets(ordered);
|
|
||||||
if (ordered.length > 0 && !selectedTargetId) {
|
|
||||||
setSelectedTargetId(focused.id);
|
|
||||||
setExpandedTargetId(focused.id);
|
|
||||||
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
|
||||||
fetchTargetVehicles(focused.id).then(vehicles => {
|
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -115,7 +80,7 @@ export default function StatisticsView() {
|
|||||||
}, [viewAllTargetId, viewAllDate]);
|
}, [viewAllTargetId, viewAllDate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1" style={{ overflowX: 'clip' }}>
|
||||||
{/* Project Selector */}
|
{/* Project Selector */}
|
||||||
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
<div className="bg-white p-2 rounded-2xl shadow-sm border border-slate-100 flex gap-1 overflow-x-auto no-scrollbar flex-shrink-0">
|
||||||
{targets.map(target => (
|
{targets.map(target => (
|
||||||
@@ -138,7 +103,7 @@ export default function StatisticsView() {
|
|||||||
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
|
||||||
{/* KPI Cards in Landscape — linked to selected target */}
|
{/* KPI Cards in Landscape — linked to selected target */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const sel = selectedTarget;
|
const sel = targets.find(t => t.id === selectedTargetId);
|
||||||
return (
|
return (
|
||||||
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
<div className="hidden landscape:grid grid-cols-4 gap-3 flex-shrink-0">
|
||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||||
@@ -165,7 +130,7 @@ export default function StatisticsView() {
|
|||||||
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
<div className="bg-white border border-slate-100 p-3 rounded-2xl shadow-sm">
|
||||||
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
<div className="text-[10px] font-bold text-slate-400 uppercase mb-1">完成率</div>
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
<div className="text-lg font-black text-slate-900 tracking-tighter">
|
||||||
{selectedCompletion.toFixed(1)}
|
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
||||||
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
<span className="text-blue-500 text-[10px] ml-1">%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,17 +224,12 @@ export default function StatisticsView() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
<div className="grid grid-cols-1 gap-1.5 overflow-y-auto no-scrollbar pb-2">
|
||||||
{targets.map((target, idx) => (
|
{targets.map((target, idx) => (
|
||||||
(() => {
|
|
||||||
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
|
||||||
const primaryCompletion = assessment?.completionRate ?? target.avgCompletion;
|
|
||||||
const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
|
||||||
const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:';
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpandedTargetId(expandedTargetId === target.id ? null : target.id);
|
const name = target.targetName;
|
||||||
|
setExpandedModel(expandedModel === name ? null : name);
|
||||||
if (!targetVehiclesMap[target.id]) {
|
if (!targetVehiclesMap[target.id]) {
|
||||||
fetchTargetVehicles(target.id).then(data => {
|
fetchTargetVehicles(target.id).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
|
||||||
@@ -289,12 +249,12 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
<span className="text-[9px] text-slate-400">完成率:</span>
|
||||||
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
<span className="text-[9px] text-slate-400">达标:</span>
|
||||||
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +269,7 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }}
|
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
||||||
className="text-slate-300"
|
className="text-slate-300"
|
||||||
>
|
>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
@@ -317,103 +277,52 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence>
|
||||||
{expandedTargetId === target.id && (
|
{expandedModel === target.targetName && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
<div className="pt-3 mt-2 border-t border-slate-50 grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
<div className="space-y-0.5">
|
||||||
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
||||||
<select
|
{target.periods.map((p, i) => (
|
||||||
value={assessment?.yearNumber || ''}
|
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
||||||
onClick={(e) => e.stopPropagation()}
|
))}
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAssessmentYearMap(prev => ({ ...prev, [target.id]: Number(e.target.value) }));
|
|
||||||
}}
|
|
||||||
className="bg-white border border-blue-100 rounded-lg px-2 py-1 text-[10px] font-bold text-blue-700 outline-none"
|
|
||||||
>
|
|
||||||
{target.yearlyAssessments.map(item => (
|
|
||||||
<option key={item.yearNumber} value={item.yearNumber}>
|
|
||||||
{item.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
<div className="space-y-0.5">
|
||||||
<div className="space-y-0.5">
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||||
{target.periods.map((p, i) => (
|
</div>
|
||||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
<div className="space-y-0.5">
|
||||||
))}
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">年考核任务/辆</p>
|
||||||
</div>
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
|
||||||
<div className="space-y-0.5">
|
</div>
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
<div className="space-y-0.5">
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%达标数</p>
|
||||||
</div>
|
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">本年需完成</p>
|
||||||
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">已完成(截止3.31)</p>
|
||||||
|
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">未完成总数</p>
|
||||||
|
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">日均需完成</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
|
||||||
</div>
|
</div>
|
||||||
{assessment ? (
|
|
||||||
<>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
|
||||||
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
|
|
||||||
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
|
|
||||||
{assessment.vehicleCount < target.vehicleCount && (
|
|
||||||
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已完成</p>
|
|
||||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
|
||||||
<p className="text-[8px] font-bold text-slate-300">
|
|
||||||
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
|
||||||
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
|
||||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-500">
|
|
||||||
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
|
||||||
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
||||||
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
||||||
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vehicle List Detail */}
|
{/* Vehicle List Detail */}
|
||||||
@@ -454,8 +363,6 @@ export default function StatisticsView() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +391,7 @@ export default function StatisticsView() {
|
|||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
<span className="text-slate-500">车辆 <span className="text-white font-black">{targets.reduce((sum, t) => sum + t.vehicleCount, 0)}</span> 台</span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">所选年度完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
|
<span className="text-slate-500">完成率 <span className="text-white font-black">{targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'}</span> <span className="text-blue-400">%</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -510,12 +417,12 @@ export default function StatisticsView() {
|
|||||||
<tr className="border-b border-slate-800/60">
|
<tr className="border-b border-slate-800/60">
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase sticky left-0 bg-slate-900 z-20 min-w-[100px]">车型</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-12">台数</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">年度进度</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase min-w-[140px]">完成进度</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度任务/辆</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年任务/辆</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">年度达标</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-emerald-500 uppercase text-center">达标</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-blue-400 uppercase text-center">50%达标</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-white uppercase text-right">今日里程</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">年度目标</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">本年目标</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-right">已完成</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-rose-400 uppercase text-right">未完成</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase text-center w-14">余天</th>
|
||||||
@@ -523,22 +430,10 @@ export default function StatisticsView() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/30">
|
<tbody className="divide-y divide-slate-800/30">
|
||||||
{targets.map((target, idx) => {
|
{targets.map((target, idx) => (
|
||||||
const assessment = getTargetAssessment(target, assessmentYearMap[target.id]);
|
|
||||||
const completion = assessment?.completionRate ?? target.avgCompletion;
|
|
||||||
const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount;
|
|
||||||
const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount;
|
|
||||||
const goal = assessment?.target ?? target.currentYearTarget;
|
|
||||||
const completed = assessment?.completed ?? target.currentYearCompleted;
|
|
||||||
const remainingMileage = assessment?.remaining ?? target.remaining;
|
|
||||||
const days = assessment?.daysLeft ?? target.daysLeft;
|
|
||||||
const daily = assessment?.dailyTarget ?? target.dailyTarget;
|
|
||||||
const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1);
|
|
||||||
return (
|
|
||||||
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
<tr key={idx} className="hover:bg-slate-800/20 transition-colors">
|
||||||
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
<td className="px-3 py-3 sticky left-0 bg-slate-950 z-10 border-r border-slate-800/40">
|
||||||
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
<div className="text-xs font-bold text-white whitespace-nowrap">{target.targetName}</div>
|
||||||
<div className="text-[9px] text-blue-400 font-bold mt-0.5">{assessment?.label || '当前年度'}</div>
|
|
||||||
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
<div className="text-[9px] text-slate-500 mt-0.5">{target.periods.map((p, i) => <span key={i} className="block">{p}</span>)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
<td className="px-3 py-3 text-xs font-bold text-slate-300 text-center">{target.vehicleCount}</td>
|
||||||
@@ -546,31 +441,28 @@ export default function StatisticsView() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
className={`h-full rounded-full ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||||
style={{ width: `${Math.min(completion, 100)}%` }}
|
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black text-white w-10 text-right">{completion.toFixed(1)}%</span>
|
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
<div className="flex justify-between mt-1 text-[9px] text-slate-500">
|
||||||
<span>{fmtKm(completed)}</span>
|
<span>{fmtKm(target.cumulativeTotal)}</span>
|
||||||
<span>/ {fmtKm(goal)} km</span>
|
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</td>
|
<td className="px-3 py-3 text-xs text-slate-300 text-right">{fmtKm(target.annualMileagePerVehicle)} km</td>
|
||||||
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{qualified}</td>
|
<td className="px-3 py-3 text-xs font-black text-emerald-400 text-center">{target.yearQualifiedCount}</td>
|
||||||
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{halfQualified}</td>
|
<td className="px-3 py-3 text-xs font-black text-blue-400 text-center">{target.halfQualifiedCount}</td>
|
||||||
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
<td className="px-3 py-3 text-xs font-black text-white text-right">{fmtKm(target.todayTotal)} km</td>
|
||||||
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(goal)} km</td>
|
<td className="px-3 py-3 text-xs text-slate-400 text-right">{fmtKm(target.currentYearTarget)} km</td>
|
||||||
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(completed)} km</td>
|
<td className="px-3 py-3 text-xs text-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} km</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(remainingMileage)} km</td>
|
<td className="px-3 py-3 text-xs font-bold text-rose-400 text-right">{fmtKm(target.remaining)} km</td>
|
||||||
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
|
<td className="px-3 py-3 text-xs text-slate-300 text-center">{target.daysLeft}</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
|
||||||
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export interface MonitoringVehicle {
|
|||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
targetNames: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringStats {
|
export interface MonitoringStats {
|
||||||
@@ -64,37 +63,6 @@ export interface TargetSummary {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
daysLeft: number;
|
daysLeft: number;
|
||||||
dailyTarget: number;
|
dailyTarget: number;
|
||||||
firstYearVehicleCount: number;
|
|
||||||
firstYearTarget: number;
|
|
||||||
firstYearCompleted: number;
|
|
||||||
firstYearRemaining: number;
|
|
||||||
firstYearCompletionRate: number;
|
|
||||||
firstYearQualifiedCount: number;
|
|
||||||
firstYearQualifiedRate: number;
|
|
||||||
firstYearHalfQualifiedCount: number;
|
|
||||||
firstYearDaysLeft: number;
|
|
||||||
firstYearDailyTarget: number;
|
|
||||||
firstYearStartDate: string | null;
|
|
||||||
firstYearEndDate: string | null;
|
|
||||||
yearlyAssessments: TargetYearlyAssessment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TargetYearlyAssessment {
|
|
||||||
yearNumber: number;
|
|
||||||
label: string;
|
|
||||||
vehicleCount: number;
|
|
||||||
target: number;
|
|
||||||
completed: number;
|
|
||||||
remaining: number;
|
|
||||||
completionRate: number;
|
|
||||||
qualifiedCount: number;
|
|
||||||
qualifiedRate: number;
|
|
||||||
halfQualifiedCount: number;
|
|
||||||
daysLeft: number;
|
|
||||||
dailyTarget: number;
|
|
||||||
startDate: string | null;
|
|
||||||
endDate: string | null;
|
|
||||||
periods: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetVehicle {
|
export interface TargetVehicle {
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | nu
|
|||||||
if (kind === 'today') {
|
if (kind === 'today') {
|
||||||
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
// 当日未对接但有历史累计,视作今日 0;只有完全无数据才标「未对接」
|
||||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||||
return Math.max(0, v.dailyKm || 0);
|
return Math.max(0, Math.round(v.dailyKm || 0));
|
||||||
}
|
}
|
||||||
return v.totalKm != null ? v.totalKm : '未对接';
|
return v.totalKm != null ? Math.round(v.totalKm) : '未对接';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
|
||||||
@@ -57,13 +57,6 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
|
|
||||||
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
ws['!freeze'] = { xSplit: 0, ySplit: 1 } as never;
|
||||||
|
|
||||||
for (let r = 1; r < data.length; r++) {
|
|
||||||
for (const c of [7, 8]) {
|
|
||||||
const ref = XLSX.utils.encode_cell({ r, c });
|
|
||||||
if (ws[ref]?.t === 'n') ws[ref].z = '0.##########';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
// 表头样式(在客户端 SheetJS 社区版仅基本样式生效)
|
||||||
for (let c = 0; c < HEADERS.length; c++) {
|
for (let c = 0; c < HEADERS.length; c++) {
|
||||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { JwtPayload, AuthUser } from './types.js';
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
|
||||||
|
|
||||||
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
// 临时:跳过所有认证(保留完整逻辑便于快速恢复)
|
||||||
const BYPASS_AUTH = false;
|
// 临时:本地开发跳过认证
|
||||||
|
const BYPASS_AUTH = true;
|
||||||
|
|
||||||
export async function authMiddleware(c: Context, next: Next) {
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
const path = c.req.path;
|
const path = c.req.path;
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import mysql from 'mysql2/promise';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const hydrogenPool = mysql.createPool({
|
|
||||||
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
|
|
||||||
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
|
|
||||||
user: process.env.HYDROGEN_DB_USER || 'root',
|
|
||||||
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
|
|
||||||
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 5,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default hydrogenPool;
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import mysql from 'mysql2/promise';
|
import mysql from 'mysql2/promise';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const mileagePool = mysql.createPool({
|
const mileagePool = mysql.createPool({
|
||||||
host: process.env.MILEAGE_DB_HOST || '101.133.130.65',
|
host: '101.133.130.65',
|
||||||
port: Number(process.env.MILEAGE_DB_PORT) || 3306,
|
port: 3306,
|
||||||
user: process.env.MILEAGE_DB_USER || 'bi_reader_02',
|
user: 'bi_reader_02',
|
||||||
password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass',
|
password: 'bi_reader_02_Pass',
|
||||||
database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy',
|
database: 'hydrogen_energy',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { RowDataPacket } from 'mysql2';
|
import type { RowDataPacket } from 'mysql2';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import hydrogenPool from '../../hydrogen-db.js';
|
|
||||||
import { cached } from './cache.js';
|
import { cached } from './cache.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { canAccessEnergy } from '../../auth/types.js';
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
@@ -20,19 +19,16 @@ app.use('*', async (c, next) => {
|
|||||||
|
|
||||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||||||
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
|
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||||||
const HYDROGEN_LOCAL = `refuel_time`;
|
|
||||||
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
|
|
||||||
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
|
||||||
const ELECTRIC_LOCAL = `charging_start_time`;
|
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||||
|
|
||||||
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||||||
function customerClause(customer: CustomerKind): string {
|
function customerClause(field: string, customer: CustomerKind): string {
|
||||||
if (customer === 'external') return '1=0';
|
if (customer === 'external') return `${field} IS NULL`;
|
||||||
if (customer === 'lingniu') return '1=1';
|
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||||||
return '1=1';
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +80,10 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
|
|
||||||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||||
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||||
FROM ${HYDROGEN_TABLE}
|
FROM tab_energy_hydrogen_bill
|
||||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
||||||
ORDER BY y DESC`,
|
ORDER BY y DESC`,
|
||||||
[HYDROGEN_MIN_DATE],
|
[HYDROGEN_MIN_DATE],
|
||||||
);
|
);
|
||||||
@@ -96,46 +92,44 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
const isCurrentYear = year === todayYear;
|
const isCurrentYear = year === todayYear;
|
||||||
|
|
||||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||||
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN amount_kg ELSE 0 END) AS yearKg,
|
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN cost_total ELSE 0 END) AS yearFee,
|
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||||
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN fee_total ELSE 0 END) AS yearRevenue,
|
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||||
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||||
THEN cost_total ELSE 0 END) AS ourYearFee,
|
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||||
THEN amount_kg ELSE 0 END) AS customerYearKg,
|
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN amount_kg ELSE 0 END) AS monthKg,
|
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN cost_total ELSE 0 END) AS monthFee,
|
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||||
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
|
||||||
|
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
||||||
THEN cost_total ELSE 0 END) AS monthCustomerCost,
|
|
||||||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
|
||||||
THEN fee_total ELSE 0 END) AS monthRevenue,
|
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN amount_kg ELSE 0 END) AS todayKg,
|
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
THEN cost_total ELSE 0 END) AS todayFee,
|
THEN cost_expense ELSE 0 END) AS todayFee,
|
||||||
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
|
||||||
|
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
||||||
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
SUM(CASE WHEN truck_id IS NOT NULL
|
||||||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||||
THEN fee_total ELSE 0 END) AS todayRevenue,
|
SUM(CASE WHEN truck_id IS NOT NULL
|
||||||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||||
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
FROM tab_energy_hydrogen_bill
|
||||||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||||||
THEN cost_total ELSE 0 END) AS lingniuBornFee
|
|
||||||
FROM ${HYDROGEN_TABLE}
|
|
||||||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
|
|
||||||
[year, year, year, year, year, year, year,
|
[year, year, year, year, year, year, year,
|
||||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||||||
@@ -172,19 +166,23 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Top5 加氢站(指定年份)
|
// Top5 加氢站(指定年份)
|
||||||
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT b.station_id AS id,
|
`SELECT b.hydrogen_station_id AS id,
|
||||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
MAX(i.hydrogen_station_name),
|
||||||
SUM(b.amount_kg) AS kg,
|
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||||
SUM(b.cost_total) AS fee
|
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||||
FROM ${HYDROGEN_TABLE} b
|
SUM(b.hydrogen_quantity) AS kg,
|
||||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
SUM(b.cost_expense) AS fee
|
||||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
FROM tab_energy_hydrogen_bill b
|
||||||
|
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||||
|
WHERE b.is_deleted = 0
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY b.station_id
|
GROUP BY b.hydrogen_station_id
|
||||||
ORDER BY kg DESC
|
ORDER BY kg DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
@@ -199,19 +197,23 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||||
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT b.station_id AS id,
|
`SELECT b.hydrogen_station_id AS id,
|
||||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
MAX(i.hydrogen_station_name),
|
||||||
SUM(b.amount_kg) AS kg,
|
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||||
SUM(b.fee_total) AS revenue
|
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||||||
FROM ${HYDROGEN_TABLE} b
|
SUM(b.hydrogen_quantity) AS kg,
|
||||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
SUM(b.customer_expense) AS revenue
|
||||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
FROM tab_energy_hydrogen_bill b
|
||||||
|
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||||
|
WHERE b.is_deleted = 0
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY b.station_id
|
GROUP BY b.hydrogen_station_id
|
||||||
ORDER BY kg DESC`,
|
ORDER BY kg DESC`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
);
|
);
|
||||||
@@ -226,22 +228,14 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||||
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [regionRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT region, SUM(kg) AS kg FROM (
|
`SELECT region, SUM(kg) AS kg FROM (
|
||||||
SELECT CASE
|
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
b.hydrogen_quantity AS kg
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
FROM tab_energy_hydrogen_bill b
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
|
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
|
WHERE b.is_deleted = 0
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
|
|
||||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
|
|
||||||
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
|
|
||||||
END AS region,
|
|
||||||
b.amount_kg AS kg
|
|
||||||
FROM ${HYDROGEN_TABLE} b
|
|
||||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
|
||||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
|
||||||
AND b.${HYDROGEN_LOCAL} >= ?
|
AND b.${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||||
) r
|
) r
|
||||||
@@ -263,15 +257,15 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||||
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
||||||
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [monthRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||||
ROUND(SUM(amount_kg), 2) AS kg,
|
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
||||||
ROUND(SUM(cost_total), 2) AS fee,
|
ROUND(SUM(cost_expense), 2) AS fee,
|
||||||
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
|
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
||||||
ROUND(SUM(fee_total), 2) AS revenue
|
ROUND(SUM(customer_expense), 2) AS revenue
|
||||||
FROM ${HYDROGEN_TABLE}
|
FROM tab_energy_hydrogen_bill
|
||||||
WHERE ${HYDROGEN_BASE_WHERE}
|
WHERE is_deleted = 0
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY m
|
GROUP BY m
|
||||||
@@ -296,16 +290,16 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||||
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
||||||
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [customerRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||||
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
|
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
||||||
ELSE 'customer' END AS payer,
|
ELSE 'customer' END AS payer,
|
||||||
SUM(amount_kg) AS kg,
|
SUM(hydrogen_quantity) AS kg,
|
||||||
SUM(cost_total) AS cost,
|
SUM(cost_expense) AS cost,
|
||||||
SUM(fee_total) AS revenue
|
SUM(customer_expense) AS revenue
|
||||||
FROM ${HYDROGEN_TABLE}
|
FROM tab_energy_hydrogen_bill
|
||||||
WHERE ${HYDROGEN_BASE_WHERE}
|
WHERE is_deleted = 0
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY name
|
GROUP BY name
|
||||||
@@ -337,28 +331,32 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||||
|
|
||||||
const where = [
|
const where = [
|
||||||
HYDROGEN_BASE_WHERE_B,
|
'b.is_deleted = 0',
|
||||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
rangeClause(`b.${HYDROGEN_LOCAL}`, range),
|
rangeClause(`b.hydrogen_time`, range),
|
||||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
customerClause('b.truck_id', customer),
|
||||||
].join(' AND ');
|
].join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||||||
// 单价不重算:直接取账本成本价。
|
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||||||
COALESCE(b.station_id, 0) AS stationId,
|
b.hydrogen_station_id AS stationId,
|
||||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||||
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||||
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
MAX(i.hydrogen_station_name),
|
||||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||||
|
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||||||
|
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||||||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||||
MAX(b.cost_price) AS pricePerKg
|
MAX(b.cost_price) AS pricePerKg
|
||||||
FROM ${HYDROGEN_TABLE} b
|
FROM tab_energy_hydrogen_bill b
|
||||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
|
||||||
|
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY d, COALESCE(b.station_id, 0)
|
GROUP BY d, b.hydrogen_station_id
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -416,7 +414,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
date,
|
date,
|
||||||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||||
chainPct: dayChainPct.get(date) ?? 0,
|
chainPct: dayChainPct.get(date) ?? 0,
|
||||||
customerType: customer,
|
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||||
stations: info
|
stations: info
|
||||||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
|||||||
@@ -65,41 +65,6 @@ interface MileageRow {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TargetRow {
|
|
||||||
id: number;
|
|
||||||
target_name: string;
|
|
||||||
plate_number: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTargetRows(): Promise<TargetRow[]> {
|
|
||||||
return pool.execute(
|
|
||||||
`SELECT t.id, t.target_name, v.plate_number
|
|
||||||
FROM tab_mileage_assessment_target t
|
|
||||||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
|
||||||
WHERE t.is_deleted = 0`
|
|
||||||
).then(([rows]) => rows as TargetRow[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
|
|
||||||
const targetPlatesMap = new Map<string, Set<string>>();
|
|
||||||
for (const r of targetRows) {
|
|
||||||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
|
||||||
set.add(r.plate_number);
|
|
||||||
targetPlatesMap.set(r.target_name, set);
|
|
||||||
}
|
|
||||||
return targetPlatesMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
|
|
||||||
const map = new Map<string, string[]>();
|
|
||||||
for (const r of targetRows) {
|
|
||||||
const list = map.get(r.plate_number) || [];
|
|
||||||
list.push(r.target_name);
|
|
||||||
map.set(r.plate_number, list);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
||||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||||
@@ -150,7 +115,6 @@ function mergeVehicles(
|
|||||||
yesterdayMap: Map<string, number>,
|
yesterdayMap: Map<string, number>,
|
||||||
bizTotalMap: Map<string, number>,
|
bizTotalMap: Map<string, number>,
|
||||||
latestPgTotalMap: Map<string, number>,
|
latestPgTotalMap: Map<string, number>,
|
||||||
targetNamesByPlate: Map<string, string[]>,
|
|
||||||
): CachedVehicle[] {
|
): CachedVehicle[] {
|
||||||
const mileageMap = new Map<string, MileageRow>();
|
const mileageMap = new Map<string, MileageRow>();
|
||||||
for (const row of mileageRows) {
|
for (const row of mileageRows) {
|
||||||
@@ -183,7 +147,6 @@ function mergeVehicles(
|
|||||||
entity: info?.entity || null,
|
entity: info?.entity || null,
|
||||||
project: info?.project || null,
|
project: info?.project || null,
|
||||||
region: regionMap[m.plate] || null,
|
region: regionMap[m.plate] || null,
|
||||||
targetNames: targetNamesByPlate.get(m.plate) || [],
|
|
||||||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -221,16 +184,25 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
return map;
|
return map;
|
||||||
})(),
|
})(),
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
fetchTargetRows(),
|
pool.execute(
|
||||||
|
`SELECT t.id, t.target_name, v.plate_number
|
||||||
|
FROM tab_mileage_assessment_target t
|
||||||
|
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||||||
|
WHERE t.is_deleted = 0`
|
||||||
|
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
|
||||||
fetchBizTotalMileageMap(),
|
fetchBizTotalMileageMap(),
|
||||||
fetchLatestPgTotalMileageMap(),
|
fetchLatestPgTotalMileageMap(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
const targetPlatesMap = new Map<string, Set<string>>();
|
||||||
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
for (const r of targetRows) {
|
||||||
|
const set = targetPlatesMap.get(r.target_name) || new Set();
|
||||||
|
set.add(r.plate_number);
|
||||||
|
targetPlatesMap.set(r.target_name, set);
|
||||||
|
}
|
||||||
const targetNames = Array.from(targetPlatesMap.keys());
|
const targetNames = Array.from(targetPlatesMap.keys());
|
||||||
|
|
||||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
|
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
||||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||||
|
|
||||||
@@ -249,7 +221,7 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||||
const [mileageRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||||
mileagePool.execute(
|
mileagePool.execute(
|
||||||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||||||
[dateStr]
|
[dateStr]
|
||||||
@@ -259,7 +231,6 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
|||||||
[dateStr]
|
[dateStr]
|
||||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
fetchTargetRows(),
|
|
||||||
fetchBizTotalMileageMap(),
|
fetchBizTotalMileageMap(),
|
||||||
fetchLatestPgTotalMileageMap(dateStr),
|
fetchLatestPgTotalMileageMap(dateStr),
|
||||||
]);
|
]);
|
||||||
@@ -271,14 +242,7 @@ export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]
|
|||||||
if (km > existing) yesterdayMap.set(r.plate, km);
|
if (km > existing) yesterdayMap.set(r.plate, km);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mergeVehicles(
|
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
||||||
mileageRows,
|
|
||||||
infoMap,
|
|
||||||
yesterdayMap,
|
|
||||||
bizTotalMap,
|
|
||||||
latestPgTotalMap,
|
|
||||||
buildPlateTargetNamesMap(targetRows),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||||
|
|||||||
@@ -32,83 +32,6 @@ app.get('/', async (c) => {
|
|||||||
const statsMap = new Map<number, any>();
|
const statsMap = new Map<number, any>();
|
||||||
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
||||||
|
|
||||||
const [firstYearRows] = await pool.execute(`
|
|
||||||
SELECT
|
|
||||||
v.target_id,
|
|
||||||
COUNT(*) as first_year_total,
|
|
||||||
SUM(t.annual_mileage_per_vehicle) as first_year_target,
|
|
||||||
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
|
|
||||||
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
|
|
||||||
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
|
|
||||||
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
|
|
||||||
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
|
|
||||||
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
|
|
||||||
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
|
|
||||||
FROM tab_mileage_assessment_vehicle v
|
|
||||||
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
|
||||||
WHERE v.is_deleted = 0
|
|
||||||
GROUP BY v.target_id
|
|
||||||
`) as [any[], unknown];
|
|
||||||
|
|
||||||
const firstYearMap = new Map<number, any>();
|
|
||||||
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
|
|
||||||
|
|
||||||
const [yearlyRows] = await pool.execute(`
|
|
||||||
SELECT
|
|
||||||
v.target_id,
|
|
||||||
y.year_number,
|
|
||||||
COUNT(*) as vehicle_count,
|
|
||||||
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
|
|
||||||
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
|
|
||||||
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
|
|
||||||
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
|
|
||||||
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
|
|
||||||
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
|
|
||||||
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
|
|
||||||
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
|
|
||||||
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
|
|
||||||
FROM tab_mileage_assessment_vehicle v
|
|
||||||
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
|
||||||
JOIN (
|
|
||||||
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
|
||||||
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
|
|
||||||
WHERE v.is_deleted = 0
|
|
||||||
GROUP BY v.target_id, y.year_number
|
|
||||||
ORDER BY v.target_id, y.year_number
|
|
||||||
`) as [any[], unknown];
|
|
||||||
|
|
||||||
const yearlyMap = new Map<number, any[]>();
|
|
||||||
for (const row of yearlyRows) {
|
|
||||||
const list = yearlyMap.get(row.target_id) || [];
|
|
||||||
list.push(row);
|
|
||||||
yearlyMap.set(row.target_id, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [yearlyPeriodRows] = await pool.execute(`
|
|
||||||
SELECT
|
|
||||||
v.target_id,
|
|
||||||
y.year_number,
|
|
||||||
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
|
|
||||||
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
|
|
||||||
COUNT(*) as cnt
|
|
||||||
FROM tab_mileage_assessment_vehicle v
|
|
||||||
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
|
||||||
JOIN (
|
|
||||||
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
|
||||||
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
|
|
||||||
WHERE v.is_deleted = 0
|
|
||||||
GROUP BY v.target_id, y.year_number, v.assessment_start_date
|
|
||||||
ORDER BY v.target_id, y.year_number, v.assessment_start_date
|
|
||||||
`) as [any[], unknown];
|
|
||||||
|
|
||||||
const yearlyPeriodsMap = new Map<string, string[]>();
|
|
||||||
for (const row of yearlyPeriodRows) {
|
|
||||||
const key = `${row.target_id}-${row.year_number}`;
|
|
||||||
const list = yearlyPeriodsMap.get(key) || [];
|
|
||||||
list.push(`${row.start_date} ~ ${row.end_date} (${row.cnt}台)`);
|
|
||||||
yearlyPeriodsMap.set(key, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [periodRows] = await pool.execute(`
|
const [periodRows] = await pool.execute(`
|
||||||
SELECT target_id,
|
SELECT target_id,
|
||||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||||
@@ -148,44 +71,12 @@ app.get('/', async (c) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const result = targets.map((t: any) => {
|
const result = targets.map((t: any) => {
|
||||||
const s = statsMap.get(t.id) || {};
|
const s = statsMap.get(t.id) || {};
|
||||||
const fy = firstYearMap.get(t.id) || {};
|
|
||||||
const currentYearTarget = Number(s.current_year_target) || 0;
|
const currentYearTarget = Number(s.current_year_target) || 0;
|
||||||
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
const currentYearCompleted = Number(s.current_year_completed) || 0;
|
||||||
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
|
||||||
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
|
||||||
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
|
||||||
const dailyTarget = remaining / daysLeft;
|
const dailyTarget = remaining / daysLeft;
|
||||||
const firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now;
|
|
||||||
const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000));
|
|
||||||
const firstYearRemaining = Number(fy.first_year_remaining) || 0;
|
|
||||||
const firstYearVehicleCount = Number(fy.first_year_total) || 0;
|
|
||||||
const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0;
|
|
||||||
const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => {
|
|
||||||
const vehicleCount = Number(row.vehicle_count) || 0;
|
|
||||||
const qualifiedCount = Number(row.qualified_count) || 0;
|
|
||||||
const remainingMileage = Number(row.remaining_mileage) || 0;
|
|
||||||
const endDate = row.end_date ? new Date(row.end_date) : now;
|
|
||||||
const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
|
|
||||||
const yearNumber = Number(row.year_number) || 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
yearNumber,
|
|
||||||
label: `第${yearNumber}年`,
|
|
||||||
vehicleCount,
|
|
||||||
target: Number(row.target_mileage) || 0,
|
|
||||||
completed: Number(row.completed_mileage) || 0,
|
|
||||||
remaining: remainingMileage,
|
|
||||||
completionRate: (Number(row.completion_rate) || 0) * 100,
|
|
||||||
qualifiedCount,
|
|
||||||
qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0,
|
|
||||||
halfQualifiedCount: Number(row.half_qualified_count) || 0,
|
|
||||||
daysLeft: assessmentDaysLeft,
|
|
||||||
dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0,
|
|
||||||
startDate: row.start_date || null,
|
|
||||||
endDate: row.end_date || null,
|
|
||||||
periods: yearlyPeriodsMap.get(`${row.target_id}-${row.year_number}`) || [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const periods = periodsMap.get(t.id) || [];
|
const periods = periodsMap.get(t.id) || [];
|
||||||
if (periods.length === 0) {
|
if (periods.length === 0) {
|
||||||
@@ -213,19 +104,6 @@ app.get('/', async (c) => {
|
|||||||
remaining,
|
remaining,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
dailyTarget: Math.round(dailyTarget * 10) / 10,
|
||||||
firstYearVehicleCount,
|
|
||||||
firstYearTarget: Number(fy.first_year_target) || 0,
|
|
||||||
firstYearCompleted: Number(fy.first_year_completed) || 0,
|
|
||||||
firstYearRemaining,
|
|
||||||
firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100,
|
|
||||||
firstYearQualifiedCount,
|
|
||||||
firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0,
|
|
||||||
firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0,
|
|
||||||
firstYearDaysLeft,
|
|
||||||
firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0,
|
|
||||||
firstYearStartDate: fy.first_year_start_date || null,
|
|
||||||
firstYearEndDate: fy.first_year_end_date || null,
|
|
||||||
yearlyAssessments,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface CachedVehicle {
|
|||||||
entity: string | null;
|
entity: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
targetNames: string[];
|
|
||||||
yesterdayKm: number;
|
yesterdayKm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user