1 Commits

Author SHA1 Message Date
kkfluous
0dc45504f2 chore(debug): 本地开发跳过权限验证
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
前端 AuthProvider 注入测试用户(BI-SCHEDULE-OPT),后端 middleware BYPASS_AUTH=true。
仅用于本地调试,禁止合并回 main。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:06:56 +08:00
34 changed files with 690 additions and 1416 deletions

View File

@@ -5,21 +5,11 @@ services:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" DB_HOST: "47.101.148.99"
DB_PORT: "3306" DB_PORT: "3306"
DB_USER: "oneos_db_prod" DB_USER: "root"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1" DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "ln_asset_management" 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"

View File

@@ -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>

View File

@@ -1,145 +1,60 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react';
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt } from "lucide-react"; import { Truck, Route, Activity, Zap } from 'lucide-react';
import { Shell, type ModuleConfig } from "./components/Shell"; import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from "./modules/assets/AssetsModule"; import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from "./modules/mileage/MileageModule"; import MileageModule from './modules/mileage/MileageModule';
import SchedulingModule from "./modules/scheduling/SchedulingModule"; import SchedulingModule from './modules/scheduling/SchedulingModule';
import HydrogenModule from "./modules/energy/HydrogenModule"; import EnergyModule from './modules/energy/EnergyModule';
import ElectricModule from "./modules/energy/ElectricModule"; import EleImportPage from './modules/ele/EleImportPage';
import EtcModule from "./modules/energy/EtcModule"; import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
import EleImportPage from "./modules/ele/EleImportPage"; import AuthProvider from './auth/AuthProvider';
import FeedbackAdminPage from "./modules/admin/FeedbackAdminPage"; import { useAuth } from './auth/useAuth';
import AuthProvider from "./auth/AuthProvider"; import UnauthorizedPage from './auth/UnauthorizedPage';
import { useAuth } from "./auth/useAuth"; import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
import UnauthorizedPage from "./auth/UnauthorizedPage";
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
const ASSETS_MODULE: ModuleConfig = { const BASE_MODULES: ModuleConfig[] = [
id: "assets", { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
label: "资产管理", { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
icon: Truck, ];
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = { const ENERGY_MODULE: ModuleConfig = {
id: "mileage", id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
label: "里程管理",
icon: Route,
component: MileageModule,
}; };
const SCHEDULING_MODULE: ModuleConfig = { const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling", id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
label: "智能调度",
icon: Activity,
component: SchedulingModule,
}; };
const HYDROGEN_MODULE: ModuleConfig = {
id: "hydrogen",
label: "氢能",
icon: Fuel,
component: HydrogenModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
icon: BatteryCharging,
component: ElectricModule,
};
const ETC_MODULE: ModuleConfig = {
id: "etc",
label: "ETC",
icon: Receipt,
component: EtcModule,
};
/**
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
*/
function normalizePath() {
if (typeof window === "undefined") return;
const { pathname, hash, search } = window.location;
// 主路径 & 隐藏后台页保持不变
if (pathname === "/asset" || pathname === "/energy") return;
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
const legacyMap: Record<string, { path: string; hash?: string }> = {
"/": { path: "/asset" },
"/vehicle": { path: "/asset", hash: "assets" },
"/assets": { path: "/asset", hash: "assets" },
"/mileage": { path: "/asset", hash: "mileage" },
"/scheduling": { path: "/asset", hash: "scheduling" },
};
// 未知路径兜底到 /asset保留原 hash 让 Shell 内部继续解析)
const target = legacyMap[pathname] ?? { path: "/asset" };
const finalHash = target.hash ? `#${target.hash}` : hash || "";
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
}
normalizePath();
type PathSet = "asset" | "energy";
function getPathSet(): PathSet {
return window.location.pathname === "/energy" ? "energy" : "asset";
}
function getRouteKey(): string { function getRouteKey(): string {
if (typeof window === "undefined") return ""; if (typeof window === 'undefined') return '';
const path = window.location.pathname; const path = window.location.pathname;
const hash = window.location.hash; const hash = window.location.hash;
if ( if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
path === "/ele/import" || if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
hash === "#/ele/import" || return '';
hash === "#ele/import"
)
return "ele/import";
if (
path === "/admin/feedback" ||
hash === "#/admin/feedback" ||
hash === "#admin/feedback"
)
return "admin/feedback";
return "";
} }
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const [routeKey, setRouteKey] = useState(getRouteKey); const [routeKey, setRouteKey] = useState(getRouteKey);
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
// 监听 hashchange / popstate让 a href="#/..." 跳转与浏览器前进后退能即时生效 // 监听 hashchange / popstate让 a href="#/..." 跳转能即时生效
useEffect(() => { useEffect(() => {
const update = () => { const update = () => setRouteKey(getRouteKey());
setRouteKey(getRouteKey()); window.addEventListener('hashchange', update);
setPathSet(getPathSet()); window.addEventListener('popstate', update);
};
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
return () => { return () => {
window.removeEventListener("hashchange", update); window.removeEventListener('hashchange', update);
window.removeEventListener("popstate", update); window.removeEventListener('popstate', update);
}; };
}, []); }, []);
useEffect(() => { const modules = useMemo(() => {
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; const result = [...BASE_MODULES];
}, [pathSet]); if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
const modules = useMemo<ModuleConfig[]>(() => {
if (pathSet === "energy") {
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
}
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE];
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE); if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result; return result;
}, [pathSet, user?.roles]); }, [user?.roles]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -157,16 +72,10 @@ function AuthGate() {
} }
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") return <EleImportPage />; if (routeKey === 'ele/import') return <EleImportPage />;
if (routeKey === "admin/feedback") return <FeedbackAdminPage />; if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
// /energy 整组按能源权限控制 return <Shell modules={modules} />;
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
return <Shell key={pathSet} modules={modules} />;
} }
export default function App() { export default function App() {

View File

@@ -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;
} }

View File

@@ -10,20 +10,29 @@ export interface ModuleConfig {
component: ComponentType; component: ComponentType;
} }
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id */ /** path 到模块 id 的映射 */
function getHashHead(): string { const PATH_MAP: Record<string, string> = {
return window.location.hash.slice(1).split('/')[0]; '/vehicle': 'assets',
} '/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
'/energy': 'energy',
};
function getInitialModule(modules: ModuleConfig[]): string { function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead(); // 优先看 hash
if (modules.some((m) => m.id === head)) return head; const hash = window.location.hash.slice(1);
if (modules.some((m) => m.id === hash)) return hash;
// 再看 pathname
const pathModule = PATH_MAP[window.location.pathname];
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
// 默认第一个
return modules[0]?.id ?? ''; return modules[0]?.id ?? '';
} }
function getHashModule(modules: ModuleConfig[]): string { function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead(); const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === head) ? head : ''; return modules.some((m) => m.id === hash) ? hash : '';
} }
export function Shell({ modules }: { modules: ModuleConfig[] }) { export function Shell({ modules }: { modules: ModuleConfig[] }) {
@@ -39,17 +48,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [modules]); }, [modules]);
useEffect(() => { useEffect(() => {
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录, // 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出 // 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。 if (window.location.hash.slice(1) !== activeModule) {
if (getHashHead() !== activeModule) {
const { pathname, search } = window.location; const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`); window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
} }
}, [activeModule]); }, [activeModule]);
const switchModule = (id: string) => { const switchModule = (id: string) => {
if (getHashHead() === id) return; if (window.location.hash.slice(1) === id) return;
const { pathname, search } = window.location; const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`); window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id); setActiveModule(id);

View File

@@ -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;
}

View File

@@ -46,52 +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 = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验';
return (
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-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-green-700 font-medium">
{text}
</span>
{overflow && (
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
{text}
</span>
)}
</div>
</div>
</div>
);
}
function formatLocalDateTime(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
}
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);
@@ -138,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, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date())); const [lastUpdate, setLastUpdate] = useState<string>('');
const [modalLoading, setModalLoading] = useState(false); const [modalLoading, setModalLoading] = useState(false);
// Dept/Region/Customer data // Dept/Region/Customer data
@@ -208,7 +162,7 @@ export default function AssetsModule() {
setRegionData(region); setRegionData(region);
setCustomerData(cust); setCustomerData(cust);
setInventoryData(inv); setInventoryData(inv);
setLastUpdate(formatLocalDateTime(new Date())); setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '数据加载失败'); setError(e instanceof Error ? e.message : '数据加载失败');
} finally { } finally {
@@ -572,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">
@@ -735,11 +689,12 @@ export default function AssetsModule() {
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" /> <span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
: {lastUpdate} : {lastUpdate}
</div> </div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse inline-block" />
</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">

View File

@@ -106,7 +106,6 @@ export interface VehicleListItem {
city: string | null; city: string | null;
status: string; status: string;
ownership: string; ownership: string;
rentCompany?: string | null;
contractNo: string | null; contractNo: string | null;
customerName: string | null; customerName: string | null;
subjectOrg: string | null; subjectOrg: string | null;

View File

@@ -1,23 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<ElectricView sub={sub} />
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import ElectricView, { type ElectricSubTab } from './ElectricView';
import ETCView from './ETCView';
type TopTab = 'hydrogen' | 'electric' | 'etc';
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
{ key: 'hydrogen', label: '氢能', icon: Fuel },
{ key: 'electric', label: '电能', icon: BatteryCharging },
{ key: 'etc', label: 'ETC', icon: Receipt },
];
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
{/* 统一 sticky 头部top tab + (氢能时) 子 tab同一张卡片无间隙 */}
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 顶部 tab氢能 / 电能 / ETC */}
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
{TABS.map(tab => {
const Icon = tab.icon;
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
>
<Icon size={14} />
<span className="text-[11px] font-bold">{tab.label}</span>
{active && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
);
})}
</div>
{/* 子 tab氢能 / 电能 都显示 每日 / 总览 */}
{showSubTabs && (
<div className="p-1 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = currentSub === id;
return (
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
{activeTab === 'etc' && <ETCView />}
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import ETCView from './ETCView';
export default function EtcModule() {
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
<ETCView />
</div>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<HydrogenView sub={sub} />
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import type { ComponentType } from 'react';
interface SubTab<T extends string> {
id: T;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
}
interface Props<T extends string> {
tabs: readonly SubTab<T>[];
active: T;
onChange: (id: T) => void;
}
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
return (
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="p-1 flex gap-1">
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = active === id;
return (
<button
key={id}
onClick={() => onChange(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
isActive ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { useEffect, useState } from 'react';
/**
* 把模块内子 tab 状态同步到 URL hash 二级段。
* hash 形如 `#<moduleId>`= 默认 sub或 `#<moduleId>/<sub>`。
* 默认值不写入 hash刷新页面可恢复。
*/
export function useHashSubTab<T extends string>(
moduleId: string,
subs: readonly T[],
): [T, (sub: T) => void] {
const defaultSub = subs[0];
const parse = (): T => {
const hash = window.location.hash.slice(1);
const [first, second] = hash.split('/');
if (first !== moduleId) return defaultSub;
if (second && (subs as readonly string[]).includes(second)) return second as T;
return defaultSub;
};
const [sub, setSubState] = useState<T>(parse);
useEffect(() => {
const onChange = () => setSubState(parse());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, [moduleId]);
const setSub = (next: T) => {
const { pathname, search } = window.location;
const newHash = next === defaultSub ? `#${moduleId}` : `#${moduleId}/${next}`;
window.history.replaceState(null, '', `${pathname}${search}${newHash}`);
setSubState(next);
};
return [sub, setSub];
}

View File

@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'motion/react';
import { import {
Truck, Filter, ChevronDown, Truck, Filter, ChevronDown,
Maximize2, Minimize2, RotateCcw, Maximize2, Minimize2, RotateCcw,
ArrowUp, ArrowDown, ChevronsUp, Download, Check, ArrowUp, ArrowDown, ChevronsUp, Download,
} from 'lucide-react'; } from 'lucide-react';
import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types';
import { fetchMonitoring } from './api'; import { fetchMonitoring } from './api';
@@ -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,
@@ -98,129 +92,6 @@ const SearchableSelect = ({
); );
}; };
const BatchMultiSelect = ({
options,
selected,
onChange,
placeholder,
}: {
options: string[],
selected: string[],
onChange: (val: string[]) => void,
placeholder: string
}) => {
const rootRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const selectedSet = useMemo(() => new Set(selected), [selected]);
const filtered = useMemo(() => {
if (!search) return options;
return options.filter(opt => opt.toLowerCase().includes(search.toLowerCase()));
}, [options, search]);
const label = selected.length === 0
? placeholder
: selected.length === options.length
? '全部批次'
: selected.length === 1
? selected[0]
: `已选 ${selected.length} 个批次`;
const toggle = (opt: string) => {
if (selectedSet.has(opt)) {
onChange(selected.filter(item => item !== opt));
} else {
onChange([...selected, opt]);
}
};
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 pl-2 pr-6 text-left text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20"
onClick={() => setIsOpen(open => !open)}
>
<span className="block truncate">{label}</span>
<ChevronDown size={10} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-slate-50">
<input
type="text"
className="w-full bg-slate-50 border-none rounded-lg py-1.5 px-2 text-[10px] font-bold text-slate-600 outline-none focus:ring-1 focus:ring-blue-500/20 placeholder:text-slate-400"
placeholder="搜索批次"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-50">
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-blue-600 hover:bg-blue-50 rounded-lg"
onClick={() => onChange(options)}
>
</button>
<button
type="button"
className="flex-1 px-2 py-1 text-[10px] font-bold text-slate-400 hover:bg-slate-50 rounded-lg"
onClick={() => onChange([])}
>
</button>
</div>
<div className="max-h-44 overflow-y-auto">
{filtered.map((opt: string) => {
const checked = selectedSet.has(opt);
return (
<button
type="button"
key={opt}
className="w-full px-3 py-2 text-[10px] font-bold text-slate-600 hover:bg-slate-50 cursor-pointer border-t border-slate-50 flex items-center justify-between gap-2 text-left"
onClick={() => toggle(opt)}
>
<span className="truncate">{opt}</span>
<span className={`w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200 text-transparent'}`}>
<Check size={10} />
</span>
</button>
);
})}
{filtered.length === 0 && (
<div className="px-3 py-2 text-[10px] font-bold text-slate-300 italic">
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function MonitoringView() { export default function MonitoringView() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterDept, setFilterDept] = useState('All'); const [filterDept, setFilterDept] = useState('All');
@@ -240,7 +111,7 @@ export default function MonitoringView() {
const [filterEntity, setFilterEntity] = useState('All'); const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All'); const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All'); const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]); const [filterTargetName, setFilterTargetName] = useState('All');
const [filterRegion, setFilterRegion] = useState('All'); const [filterRegion, setFilterRegion] = useState('All');
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
@@ -266,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))
|| filterTargetNames.some(name => HIGH_MILEAGE_ALERT_TARGETS.has(name));
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback(() => { const loadFirstPage = useCallback(() => {
setPageLoading(true); setPageLoading(true);
@@ -287,7 +152,7 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined, entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
@@ -301,7 +166,7 @@ export default function MonitoringView() {
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
}).catch(() => {}).finally(() => setPageLoading(false)); }).catch(() => {}).finally(() => setPageLoading(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
// 加载更多 // 加载更多
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@@ -320,7 +185,7 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined, entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
@@ -331,7 +196,7 @@ export default function MonitoringView() {
setPage(nextPage); setPage(nextPage);
setHasMore(nextPage < d.totalPages); setHasMore(nextPage < d.totalPages);
}).catch(() => {}).finally(() => setLoadingMore(false)); }).catch(() => {}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate, page, loadingMore, hasMore]);
// 筛选/排序变化时重新加载 // 筛选/排序变化时重新加载
useEffect(() => { useEffect(() => {
@@ -363,7 +228,7 @@ export default function MonitoringView() {
entity: filterEntity !== 'All' ? filterEntity : undefined, entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined, mileageMin: appliedMileageRange.min || undefined,
@@ -376,7 +241,7 @@ export default function MonitoringView() {
} finally { } finally {
setExporting(false); setExporting(false);
} }
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, filterDate]); }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, appliedMileageRange, filterDate]);
// 每分钟自动刷新 // 每分钟自动刷新
useEffect(() => { useEffect(() => {
@@ -442,7 +307,7 @@ export default function MonitoringView() {
customer: filterCustomer !== 'All' ? filterCustomer : undefined, customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, targetName: filterTargetName !== 'All' ? filterTargetName : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined, region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
date: filterDate || undefined, date: filterDate || undefined,
@@ -451,7 +316,7 @@ export default function MonitoringView() {
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
}).catch(() => {}).finally(() => setFullscreenLoading(false)); }).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, filterDate, fullscreenRefresh]); }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetName, filterRegion, filterPlates, filterDate, fullscreenRefresh]);
// 全屏时禁止背景滚动 // 全屏时禁止背景滚动
useEffect(() => { useEffect(() => {
@@ -538,14 +403,14 @@ export default function MonitoringView() {
<div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between"> <div className="flex-shrink-0 px-3 py-1 border-b border-slate-800/60 flex items-center justify-between">
<div className="flex items-center gap-1 overflow-x-auto no-scrollbar"> <div className="flex items-center gap-1 overflow-x-auto no-scrollbar">
<button <button
onClick={() => setFilterTargetNames([])} onClick={() => setFilterTargetName('All')}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.length === 0 ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`} className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === 'All' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
></button> ></button>
{filterOptions.targetNames.map(n => ( {filterOptions.targetNames.map(n => (
<button <button
key={n} key={n}
onClick={() => setFilterTargetNames(prev => prev.includes(n) ? prev.filter(item => item !== n) : [...prev, n])} onClick={() => setFilterTargetName(filterTargetName === n ? 'All' : n)}
className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetNames.includes(n) ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`} className={`px-2 py-0.5 rounded text-[8px] font-bold transition-all whitespace-nowrap ${filterTargetName === n ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`}
>{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button> >{n.replace(/交投|羚牛|恒运/, '').replace(/辆/, '台')}</button>
))} ))}
</div> </div>
@@ -660,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>
@@ -672,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">
@@ -682,8 +545,7 @@ export default function MonitoringView() {
</span> </span>
</td> </td>
</tr> </tr>
); ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -719,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>
@@ -752,10 +614,10 @@ export default function MonitoringView() {
{/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */} {/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 grid grid-cols-3 gap-1.5"> <div className="flex-1 grid grid-cols-3 gap-1.5">
<BatchMultiSelect <SearchableSelect
options={filterOptions.targetNames} options={filterOptions.targetNames}
selected={filterTargetNames} value={filterTargetName}
onChange={setFilterTargetNames} onChange={setFilterTargetName}
placeholder="批次型号" placeholder="批次型号"
/> />
<SearchableSelect <SearchableSelect
@@ -774,7 +636,7 @@ export default function MonitoringView() {
<button <button
onClick={() => setIsFilterOpen(!isFilterOpen)} onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' || filterTargetNames.length > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`} className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${isFilterOpen || searchTerm || filterDept !== 'All' || filterCustomer !== 'All' || filterRentStatus !== 'All' || filterPlates.length > 0 || filterProject !== 'All' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-50 text-slate-400 border border-transparent'}`}
> >
<Filter size={16} /> <Filter size={16} />
</button> </button>
@@ -922,7 +784,7 @@ export default function MonitoringView() {
setFilterProject('All'); setFilterProject('All');
setFilterEntity('All'); setFilterEntity('All');
setFilterPlatePrefix('All'); setFilterPlatePrefix('All');
setFilterTargetNames([]); setFilterTargetName('All');
setFilterRegion('All'); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' });
setAppliedMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
@@ -949,10 +811,7 @@ export default function MonitoringView() {
{/* Active Filter Tags */} {/* Active Filter Tags */}
{(() => { {(() => {
const tags: { label: string; onClear: () => void }[] = []; const tags: { label: string; onClear: () => void }[] = [];
if (filterTargetNames.length > 0) tags.push({ if (filterTargetName !== 'All') tags.push({ label: `批次: ${filterTargetName}`, onClear: () => setFilterTargetName('All') });
label: filterTargetNames.length === filterOptions.targetNames.length ? '批次: 全部批次' : `批次: ${filterTargetNames.length === 1 ? filterTargetNames[0] : `${filterTargetNames[0]}${filterTargetNames.length}`}`,
onClear: () => setFilterTargetNames([])
});
if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') }); if (filterRegion !== 'All') tags.push({ label: `区域: ${filterRegion}`, onClear: () => setFilterRegion('All') });
if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]}${filterPlates.length}`}`, onClear: () => setFilterPlates([]) }); if (filterPlates.length > 0) tags.push({ label: `车牌: ${filterPlates.length === 1 ? filterPlates[0] : `${filterPlates[0]}${filterPlates.length}`}`, onClear: () => setFilterPlates([]) });
if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') }); if (filterRentStatus !== 'All') tags.push({ label: `状态: ${filterRentStatus}`, onClear: () => setFilterRentStatus('All') });
@@ -968,7 +827,7 @@ export default function MonitoringView() {
if (tags.length === 0) return null; if (tags.length === 0) return null;
const clearAll = () => { const clearAll = () => {
setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All'); setFilterDept('All'); setFilterCustomer('All'); setFilterRentStatus('All'); setFilterProject('All'); setFilterEntity('All');
setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetNames([]); setFilterRegion('All'); setFilterPlates([]); setSearchTerm(''); setFilterPlatePrefix('All'); setFilterTargetName('All'); setFilterRegion('All');
setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' }); setFilterMileageRange({ min: '', max: '' }); setAppliedMileageRange({ min: '', max: '' });
setFilterDate(''); setFilterDate('');
}; };
@@ -1037,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 }}
@@ -1073,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">
@@ -1085,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 && (

View File

@@ -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,37 +277,17 @@ 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">
<span className="text-[10px] font-black text-blue-700"></span>
<select
value={assessment?.yearNumber || ''}
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 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>
{target.periods.map((p, i) => ( {target.periods.map((p, i) => (
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p> <p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
))} ))}
@@ -356,64 +296,33 @@ export default function StatisticsView() {
<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> <p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
</div> </div>
</div>
{assessment ? (
<>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">/</p>
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => ( <p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle)} km</p>
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
))}
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}/</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p> <p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} </p> <p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
{assessment.vehicleCount < target.vehicleCount && (
<p className="text-[8px] font-bold text-slate-400"> {target.vehicleCount - assessment.vehicleCount} {assessment.label}</p>
)}
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p> <p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p> <p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
<p className="text-[8px] font-bold text-slate-300">
{assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider"></p>
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p> <p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
</div> </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>

View File

@@ -16,7 +16,6 @@ export async function fetchMonitoring(params?: {
rentStatus?: string; rentStatus?: string;
platePrefix?: string; platePrefix?: string;
targetName?: string; targetName?: string;
targetNames?: string[];
region?: string; region?: string;
plate?: string; plate?: string;
mileageMin?: string; mileageMin?: string;
@@ -36,11 +35,6 @@ export async function fetchMonitoring(params?: {
if (params?.rentStatus) query.set('rentStatus', params.rentStatus); if (params?.rentStatus) query.set('rentStatus', params.rentStatus);
if (params?.platePrefix) query.set('platePrefix', params.platePrefix); if (params?.platePrefix) query.set('platePrefix', params.platePrefix);
if (params?.targetName) query.set('targetName', params.targetName); if (params?.targetName) query.set('targetName', params.targetName);
if (params?.targetNames) {
params.targetNames.forEach(name => {
if (name) query.append('targetName', name);
});
}
if (params?.region) query.set('region', params.region); if (params?.region) query.set('region', params.region);
if (params?.plate) query.set('plate', params.plate); if (params?.plate) query.set('plate', params.plate);
if (params?.mileageMin) query.set('mileageMin', params.mileageMin); if (params?.mileageMin) query.set('mileageMin', params.mileageMin);

View File

@@ -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 {

View File

@@ -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 });

View File

@@ -52,15 +52,11 @@ app.get('/exchange', async (c) => {
// 查询 depCode 对应的部门名称 // 查询 depCode 对应的部门名称
let depName = ''; let depName = '';
if (userInfo.depCode) { if (userInfo.depCode) {
try {
const [rows] = await pool.execute( const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1', 'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode] [userInfo.depCode]
) as [{ dep_name: string }[], unknown]; ) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || ''; depName = rows[0]?.dep_name || '';
} catch (e: any) {
if (e?.code !== 'ER_NO_SUCH_TABLE') throw e;
}
} }
const payload: JwtPayload = { const payload: JwtPayload = {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -149,8 +149,8 @@ async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string
const placeholders = arr.map(() => '?').join(','); const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>( const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id `SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info FROM tab_truck
WHERE del_flag = '0' AND plate_number IN (${placeholders})`, WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
arr, arr,
); );
const map = new Map<string, string>(); const map = new Map<string, string>();

View File

@@ -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有客户单价/收入 → 客户承担;否则 → 羚牛承担 // payercost_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,

View File

@@ -65,47 +65,12 @@ 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 lingniu_prod.tab_mileage_assessment_target t
JOIN lingniu_prod.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 数据源常为 NULLG7 只回传日增量), // v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULLG7 只回传日增量),
// 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值, // 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
// 用它兜底保证 totalKm 汇总完整。 // 用它兜底保证 totalKm 汇总完整。
const [rows] = await pool.execute( const [rows] = await pool.execute(
'SELECT plate_number, vehicle_total_mileage FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0' 'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown]; ) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const r of rows) { for (const r of rows) {
@@ -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 {

View File

@@ -19,7 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
function applyFilters(vehicles: CachedVehicle[], params: { function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string; search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string; entity: string; rentStatus: string; plate: string; platePrefix: string;
targetNames: string[]; region: string; mileageMin: string; mileageMax: string; targetName: string; region: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] { }): CachedVehicle[] {
let result = vehicles; let result = vehicles;
@@ -42,9 +42,10 @@ function applyFilters(vehicles: CachedVehicle[], params: {
} }
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix)); if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.region) result = result.filter(v => v.region === params.region); if (params.region) result = result.filter(v => v.region === params.region);
if (params.targetNames.length > 0) { if (params.targetName) {
const selectedTargets = new Set(params.targetNames); const cache = getCache();
result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName))); const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
} }
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin)); if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax)); if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
@@ -52,18 +53,6 @@ function applyFilters(vehicles: CachedVehicle[], params: {
return result; return result;
} }
function parseTargetNames(reqUrl: string): string[] {
const params = new URL(reqUrl).searchParams;
const raw = [
...params.getAll('targetName'),
...params.getAll('targetNames'),
];
const names = raw.flatMap(item => item.split(','))
.map(item => item.trim())
.filter(Boolean);
return Array.from(new Set(names));
}
app.get('/', async (c) => { app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today'; const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc'; const sortOrder = c.req.query('sortOrder') || 'desc';
@@ -80,7 +69,7 @@ app.get('/', async (c) => {
rentStatus: c.req.query('rentStatus') || '', rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '', plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '', platePrefix: c.req.query('platePrefix') || '',
targetNames: parseTargetNames(c.req.url), targetName: c.req.query('targetName') || '',
region: c.req.query('region') || '', region: c.req.query('region') || '',
mileageMin: c.req.query('mileageMin') || '', mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '', mileageMax: c.req.query('mileageMax') || '',

View File

@@ -10,7 +10,7 @@ const app = new Hono();
app.get('/', async (c) => { app.get('/', async (c) => {
try { try {
const [targets] = await pool.execute( const [targets] = await pool.execute(
'SELECT * FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id' 'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown]; ) as [any[], unknown];
const [vehicleStats] = await pool.execute(` const [vehicleStats] = await pool.execute(`
@@ -25,96 +25,19 @@ app.get('/', async (c) => {
SUM(current_year_mileage_task) as current_year_target, SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed, SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date MAX(current_year_assessment_end_date) as year_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id GROUP BY target_id
`) as [any[], unknown]; `) as [any[], unknown];
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 lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.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 lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.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 lingniu_prod.tab_mileage_assessment_vehicle v
JOIN lingniu_prod.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,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date, DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt COUNT(*) as cnt
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date ORDER BY target_id, assessment_start_date
`) as [any[], unknown]; `) as [any[], unknown];
@@ -135,7 +58,7 @@ app.get('/', async (c) => {
} }
const [targetVehicleRows] = await pool.execute( const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0' 'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown]; ) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>(); const targetIdPlatesMap = new Map<number, string[]>();
@@ -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,
}; };
}); });
@@ -245,7 +123,7 @@ app.get('/:id/vehicles', async (c) => {
`SELECT plate_number, today_mileage, vehicle_total_mileage, `SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified, completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage daily_required_mileage
FROM lingniu_prod.tab_mileage_assessment_vehicle FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0 WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`, ORDER BY today_mileage DESC`,
[targetId] [targetId]

View File

@@ -12,7 +12,7 @@ app.get('/', async (c) => {
let plates: string[] = []; let plates: string[] = [];
if (targetId) { if (targetId) {
const [vehicleRows] = await pool.execute( const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0', 'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId] [targetId]
) as [{ plate_number: string }[], unknown]; ) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number); plates = vehicleRows.map(r => r.plate_number);

View File

@@ -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;
} }

View File

@@ -3,42 +3,24 @@ import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */ /** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT export const VEHICLE_INFO_SQL = `SELECT
vi.plate_number AS plate, truck.plate_number AS plate,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer, cus.customer_name AS customer,
COALESCE(c.business_department_name, vor.business_dept) AS department, dep.dep_name AS department,
COALESCE(c.business_manager_name, vor.business_manager) AS manager, u.user_name AS manager,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id, CAST(c.bd AS CHAR) AS manager_id,
CASE vs.operation_status dic_status.dic_name AS rent_status,
WHEN '1' THEN '租赁' org_truck.org_name AS entity,
WHEN '2' THEN '自营' c.project_name AS project
WHEN '3' THEN '可运营' FROM tab_truck truck
WHEN '4' THEN '待运营' LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
WHEN '5' THEN '退出运营' LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
ELSE vs.operation_status LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
END AS rent_status, LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
NULLIF(vi.registered_ownership, '') AS entity, LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
COALESCE(c.project_name, vor.project_name) AS project LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
FROM vehicle_info vi AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN vehicle_status vs LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
ON vs.vehicle_id = vi.id WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
AND vs.del_flag = 0
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */ /** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> { export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
@@ -54,7 +36,7 @@ export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> { export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map(); if (plates.length === 0) return new Map();
const [rows] = await pool.execute( const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND vi.plate_number IN (${plates.map(() => '?').join(',')})`, `${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates plates
) as [VehicleInfoRow[], unknown]; ) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>(); const map = new Map<string, VehicleInfoRow>();

View File

@@ -28,17 +28,16 @@ function inferTypeFromTargetName(targetName: string): string {
} }
/** /**
* Classify vehicle type from ln_asset_management.vehicle_model. * Classify vehicle type from dic_type.dic_name (e.g. "4.5吨冷链车", "4.5吨货车", "18吨双飞翼货车").
* modelRaw is vehicle_model.vehicle_type, which is not the old dic_truck_type code. * The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
*/ */
function classifyVehicleType(typeName: string, _modelRaw: string): string { function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim(); const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链'; if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货'; if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T'; if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车'; if (t.includes('挂车')) return '挂车';
if (t.includes('49')) return '49T';
if (t.includes('35')) return '35T';
return t || '其他'; return t || '其他';
} }
@@ -55,7 +54,7 @@ app.get('/', async (c) => {
// ---- Query 1: Assessment targets ---- // ---- Query 1: Assessment targets ----
const [targets] = await pool.execute( const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id', 'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown]; ) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>(); const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
@@ -72,20 +71,21 @@ app.get('/', async (c) => {
current_mileage, current_year_mileage, current_year_mileage_task, current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified, completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date daily_required_mileage, current_year_assessment_end_date
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown]; `) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ---- // ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap(); const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from vehicle_info ---- // ---- Query 4: Vehicle types from tab_truck ----
// Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck
// but are still active in the assessment. We need their type info.
const [truckTypeRows] = await pool.execute(` const [truckTypeRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM vehicle_info vi FROM tab_truck truck
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0 LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0' AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE vi.del_flag = '0' WHERE truck.is_operation = 1
AND COALESCE(vs.operation_status, '') <> '5'
`) as [any[], unknown]; `) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>(); const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
@@ -161,13 +161,12 @@ app.get('/', async (c) => {
// ---- Query 7: Inventory vehicles (rent_status = 0) ---- // ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(` const [inventoryTruckRows] = await pool.execute(`
SELECT vi.plate_number, vm.model AS type_name, vm.vehicle_type AS model_raw SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM vehicle_info vi FROM tab_truck truck
LEFT JOIN vehicle_status vs ON vs.vehicle_id = vi.id AND vs.del_flag = 0 LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0' AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE vi.del_flag = '0' WHERE truck.is_deleted = 0 AND truck.is_operation = 1
AND COALESCE(vs.operation_status, '') IN ('3','4') AND truck.truck_rent_status = 0
AND COALESCE(vs.vehicle_status, '') <> '4'
`) as [any[], unknown]; `) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ---- // ---- Build assessment vehicle lookup for inventory cross-reference ----

View File

@@ -17,100 +17,75 @@ import type { Context } from 'hono';
const app = new Hono(); const app = new Hono();
const MAIN_SQL = `SELECT const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id, CAST(truck.id AS CHAR) AS id,
vi.plate_number AS 车牌号, truck.plate_number AS 车牌号,
vi.vin AS vin, truck.vin AS vin,
vm.brand AS 车辆品牌, truck.brand AS 车辆品牌,
vm.model AS 车辆型号, truck.model AS 车辆型号,
vi.body_color AS 车辆颜色, truck.color AS 车辆颜色,
vi.rental_company AS 租赁公司, truck.rent_from_company AS 租赁公司,
CASE vi.vehicle_source dic_ascription_status.dic_name AS 车辆归属状态Label,
WHEN '0' THEN '自有' dic_type.dic_name AS 车辆型号Label,
WHEN '1' THEN '外租' truck.stock_area AS 库存区域,
WHEN '2' THEN '挂靠' truck.truck_rent_status AS 车辆租赁状态,
ELSE vi.actual_ownership dic_status.dic_name AS 车辆租赁状态Label,
END AS 车辆归属状态Label, truck.is_operation AS 是否营运,
vm.model AS 车辆型号Label, info.province AS 省,
vm.vehicle_type AS 车辆类型参数, info.city AS 市,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
info.lat AS 纬度, info.lat AS 纬度,
info.lng AS 经度, info.lng AS 经度,
CASE vm.brand dic_brand.dic_name AS 车辆品牌Label,
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END si.contract_id AS 合同ID,
WHEN 'yuejin' THEN '跃进' COALESCE(c.contract_no, si.contract_no) AS 合同编码,
WHEN 'feichi' THEN '飞驰' cus.customer_name AS 客户名称,
WHEN 'sulong' THEN '苏龙' org.org_name AS 合同归属公司,
WHEN 'higer' THEN '海格' dep.dep_name AS 合同归属部门,
WHEN 'dongfeng' THEN '东风' org_truck.org_name AS 主体,
WHEN 'yutong' THEN '宇通' c.project_name AS 项目名称,
WHEN 'chufeng' THEN '楚风' u.user_name AS 客户经理,
WHEN 'tonghua' THEN '通华' CAST(c.bd AS CHAR) AS 经理ID
WHEN 'maxus' THEN '大通' FROM tab_truck truck
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN tab_truck_remote_sync_realtime_info info LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.plate_number = vi.plate_number ON info.id = truck.id
AND info.is_deleted = 0 LEFT JOIN tab_dic dic_type
LEFT JOIN common_district info_province ON dic_type.parent_code = 'dic_truck_type'
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci AND dic_type.dic_code = truck.model
AND info_province.STATUS = 'VALID' AND dic_type.is_deleted = 0
LEFT JOIN common_district info_city LEFT JOIN tab_dic dic_status
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci ON dic_status.parent_code = 'dic_truck_rent_status'
AND info_city.STATUS = 'VALID' AND dic_status.dic_code = truck.truck_rent_status
LEFT JOIN common_district vi_province AND dic_status.is_deleted = 0
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci LEFT JOIN tab_dic dic_brand
AND vi_province.STATUS = 'VALID' ON dic_brand.parent_code = 'dic_vehicle_brand'
LEFT JOIN common_district vi_city AND dic_brand.dic_code = truck.brand
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci AND dic_brand.is_deleted = 0
AND vi_city.STATUS = 'VALID' LEFT JOIN tab_truck_status_info si
LEFT JOIN common_district vi_operation_city ON si.truck_id = truck.id
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci AND si.is_deleted = 0
AND vi_operation_city.STATUS = 'VALID' LEFT JOIN tab_contract c
LEFT JOIN vehicle_lease_order_record vor ON c.id = si.contract_id
ON vor.vehicle_id = vi.id AND c.is_deleted = 0
AND vor.del_flag = '0' LEFT JOIN tab_customer cus
AND vor.id = ( ON cus.id = c.customer_id
SELECT MAX(vor2.id) AND cus.is_deleted = 0
FROM vehicle_lease_order_record vor2 LEFT JOIN tab_org org
WHERE vor2.vehicle_id = vi.id ON org.id = c.org_id
AND vor2.del_flag = '0' AND org.is_deleted = 0
) LEFT JOIN tab_org org_truck
LEFT JOIN vehicle_lease_contract_info c ON org_truck.id = truck.org_id
ON c.order_id = vor.contract_id AND org_truck.is_deleted = 0
AND c.del_flag = '0' LEFT JOIN tab_dic dic_ascription_status
LEFT JOIN customer_info ci ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
ON ci.id = vi.customer_id AND dic_ascription_status.dic_code = truck.ascription_status
AND ci.del_flag = '0' AND dic_ascription_status.is_deleted = 0
WHERE vi.del_flag = '0' LEFT JOIN tab_user u
AND COALESCE(vs.operation_status, '') <> '5'`; ON u.id = c.bd
AND u.is_deleted = 0
LEFT JOIN tab_department dep
ON dep.id = u.dep_id
AND dep.is_deleted = 0
WHERE truck.is_deleted = 0
AND truck.is_operation = 1`;
// Region mapping: province/city -> display region // Region mapping: province/city -> display region
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const; const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
@@ -173,32 +148,23 @@ function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
return counts; return counts;
} }
// Map operation status to frontend status. // Map rental status to frontend status
// ln_asset_management.vehicle_status.operation_status: // Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营. function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' { if (!rentStatus) return 'Inventory';
const op = (operationStatus || '').trim(); const s = rentStatus.trim();
const vehicle = (vehicleStatus || '').trim(); if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
if (vehicle === '4') return 'Pending'; if (s === '在库') return 'Inventory';
if (vehicle === '14') return 'Abnormal'; if (s === '待交车') return 'Pending';
if (op === '1' || op === '2') return 'Operating'; if (s === '异动') return 'Abnormal';
if (op === '3' || op === '4') return 'Inventory';
if (op === '租赁' || op === '自营') return 'Operating';
if (op === '可运营' || op === '待运营' || op === '在库') return 'Inventory';
if (op === '异动') return 'Abnormal';
return 'Inventory'; return 'Inventory';
} }
// Map ownership from vehicle_info.vehicle_source. // Map ownership from truck_rent_status (rentStatusLabel)
// DB values: 0/自有, 1/外租, 2/挂靠. // DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
function mapOwnership(rentStatusLabel: string | null): string { function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown'; if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim(); const s = rentStatusLabel.trim();
if (s === '0') return 'Self';
if (s === '1') return 'Leased';
if (s === '2') return 'Hanging';
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '自营') return 'Self'; if (s === '自营') return 'Self';
if (s === '租赁') return 'Leased'; if (s === '租赁') return 'Leased';
if (s === '挂靠') return 'Hanging'; if (s === '挂靠') return 'Hanging';
@@ -213,38 +179,26 @@ function resolveCity(city: string | null, province: string | null): string {
return p || '其他'; return p || '其他';
} }
// Derive page category from ln_asset_management.vehicle_model. // Derive vehicle type category from model label
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code: // Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers. // 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string { function deriveType(modelLabel: string | null, brandLabel: string | null): string {
const label = (modelLabel || '').trim(); const label = (modelLabel || '').trim();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
if (label.includes('4.5吨')) return '4.5T'; if (label.includes('4.5吨')) return '4.5T';
if (label.includes('18吨')) return '18T'; if (label.includes('18吨')) return '18T';
if (label.includes('49吨')) return '49T'; if (label.includes('49吨')) return '49T';
if (label.includes('35吨')) return '35T'; if (label.includes('35吨')) return '35T';
if (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车'; if (label.includes('叉车')) return '叉车';
if (label.includes('半挂车')) return '挂车';
return '其他车型'; return '其他车型';
} }
function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// Tag → alias mapping with sort order // Tag → alias mapping with sort order
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租] // tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias) // Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = { const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
// 4.5T 普货 // 4.5T 普货
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 }, '现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货(恒运)', order: 102 }, '现代-4.5吨货车-白': { alias: '现代4.5T普货(恒运)', order: 102 },
// 4.5T 冷链 // 4.5T 冷链
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 }, '帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
@@ -262,11 +216,9 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 }, '宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 }, '飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, '飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 },
'飞驰-49吨牵引车头-红': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above '飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 }, '飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 }, '飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风海珀特', order: 406 }, '楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风海珀特', order: 406 },
// 其他 // 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 }, '红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
@@ -280,11 +232,9 @@ const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 }, '万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 }, '舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 }, '东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
'东风-挂靠油车-白': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 }, '腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 }, '腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 }, '其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 }, '远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 }, '大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
}; };
@@ -297,7 +247,7 @@ function deriveModelTag(
rentCompany: string | null, rentCompany: string | null,
): string { ): string {
const brand = (brandLabel || '').trim(); const brand = (brandLabel || '').trim();
const model = (normalizeModelLabel(modelLabel) || '').trim(); const model = (modelLabel || '').trim();
const c = (color || '').trim(); const c = (color || '').trim();
const isRented = ownershipLabel?.trim() === '外租'; const isRented = ownershipLabel?.trim() === '外租';
const company = isRented ? (rentCompany || '').trim() : ''; const company = isRented ? (rentCompany || '').trim() : '';
@@ -322,16 +272,15 @@ function transformRow(row: VehicleRow): Vehicle {
id: row.id, id: row.id,
plateNumber: row.车牌号 || '', plateNumber: row.车牌号 || '',
vin: row.vin || '', vin: row.vin || '',
type: deriveType(row.Label, row.), type: deriveType(row.Label, row.Label),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.), model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '', color: row.车辆颜色 || '',
location: region, location: region,
region, region,
province: row.省, province: row.省,
city: row.市, city: row.市,
status: mapStatus(row.Label, row.), status: mapStatus(row.Label),
operationStatus: row.车辆租赁状态Label, ownership: mapOwnership(row.Label),
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '', rentCompany: row.租赁公司 || '',
contractNo: row.合同编码, contractNo: row.合同编码,
customerName: row.客户名称, customerName: row.客户名称,
@@ -369,7 +318,7 @@ async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
return maskCustomerNames(list); return maskCustomerNames(list);
} }
// 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg // 归属公司筛选(所属公司 = tab_truck.org_id → org_name, 即 Vehicle.subjectOrg
function getSubjectParam(c: Context): string | null { function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim(); const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null; return raw ? raw : null;
@@ -405,27 +354,24 @@ async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
} }
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([ const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id pool.query<any[]>(`SELECT CAST(id AS CHAR) AS truck_id FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
FROM vehicle_status pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`), LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
FROM delivery_vehicle WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
WHERE del_flag='0' AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
AND vehicle_id IS NOT NULL AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL} pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r
AND delivery_status IN (2,3,5)`), LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
FROM return_vehicle_task WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
WHERE del_flag='0' AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
AND vehicle_id IS NOT NULL pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL} LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
AND status IN (2,3,5)`), LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
FROM vehicle_replacement AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
WHERE del_flag='0' AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`),
]); ]);
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null')); const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
@@ -473,54 +419,59 @@ interface WeeklyStats {
// 交车单 SQL // 交车单 SQL
const DELIVERED_SQL = `SELECT const DELIVERED_SQL = `SELECT
dv.id, DATE(dv.delivery_time) AS handover_date, take.id, DATE(take.handover_date) AS handover_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number, CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
c.contract_type, dic_contract_type.dic_name AS contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name customer.customer_name
FROM delivery_vehicle dv FROM tab_truck_rent_take take
LEFT JOIN delivery_task_subject dts LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
ON dts.id = dv.delivery_task_subject_id LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
AND dts.del_flag = '0' LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN vehicle_lease_contract_info c LEFT JOIN tab_contract contract ON task.contract_id = contract.id
ON c.order_id = dv.contract_id LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
AND c.del_flag = '0' LEFT JOIN tab_dic dic_contract_type
WHERE dv.del_flag = '0' ON dic_contract_type.parent_code = 'dic_contract_type'
AND dv.vehicle_id IS NOT NULL AND dic_contract_type.dic_code = contract.contract_type
AND dv.delivery_time IS NOT NULL AND dic_contract_type.is_deleted = 0
AND dv.delivery_status IN (2,3,5)`; WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
// 还车单 SQL // 还车单 SQL
const RETURNED_SQL = `SELECT const RETURNED_SQL = `SELECT
r.id, DATE(r.arrival_time) AS handover_date, r.id, DATE(r.return_date) AS handover_date,
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number, CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
c.contract_type, dic_contract_type.dic_name AS contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name customer.customer_name
FROM return_vehicle_task r FROM tab_truck_rent_return r
LEFT JOIN delivery_task_subject dts LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
ON dts.id = r.delivery_task_subject_id LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
AND dts.del_flag = '0' LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN vehicle_lease_contract_info c LEFT JOIN tab_contract contract ON task.contract_id = contract.id
ON c.order_id = r.contract_id LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
AND c.del_flag = '0' LEFT JOIN tab_dic dic_contract_type
WHERE r.del_flag = '0' ON dic_contract_type.parent_code = 'dic_contract_type'
AND r.vehicle_id IS NOT NULL AND dic_contract_type.dic_code = contract.contract_type
AND r.arrival_time IS NOT NULL AND dic_contract_type.is_deleted = 0
AND r.status IN (2,3,5)`; WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
// 替换车单 SQL // 替换车单 SQL
const REPLACED_SQL = `SELECT const REPLACED_SQL = `SELECT
vr.id, DATE(vr.replace_time) AS handover_date, take.id, DATE(take.handover_date) AS handover_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number, CAST(truck.id AS CHAR) AS truck_id, truck.plate_number,
c.contract_type, dic_contract_type.dic_name AS contract_type,
c.customer_name customer.customer_name
FROM vehicle_replacement vr FROM tab_truck_rent_take take
LEFT JOIN vehicle_lease_contract_info c LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
ON c.id = vr.contract_id LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
AND c.del_flag = '0' LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
WHERE vr.del_flag = '0' LEFT JOIN tab_contract contract ON task.contract_id = contract.id
AND vr.new_vehicle_id IS NOT NULL LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
AND vr.replace_time IS NOT NULL LEFT JOIN tab_dic dic_contract_type
AND vr.status = 20`; ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
let cachedWeeklyStats: WeeklyStats | null = null; let cachedWeeklyStats: WeeklyStats | null = null;
let weeklyStatsLastFetch = 0; let weeklyStatsLastFetch = 0;
@@ -532,18 +483,23 @@ async function getWeeklyStats(): Promise<WeeklyStats> {
} }
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([ const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5) LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`), WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5) AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`), pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20 WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`), AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]); ]);
cachedWeeklyStats = { cachedWeeklyStats = {
@@ -565,11 +521,11 @@ app.get('/summary', async (c) => {
const summary: SummaryData = { const summary: SummaryData = {
totalAssets: vehicles.length, totalAssets: vehicles.length,
operating: { operating: {
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length, total: vehicles.filter((v) => v.status === 'Operating').length,
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length, self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length, leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length, public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: 0, hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
}, },
inventory: { inventory: {
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length, total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
@@ -739,8 +695,7 @@ app.get('/dept-stats', async (c) => {
const deptMap = new Map<string, Map<string, Vehicle[]>>(); const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) { for (const v of withManager) {
const isPublicServiceVehicle = v.model === '公务车/挂靠车'; const dept = v.departmentName || '公务车';
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
const mgr = v.customerManager || '未分配'; const mgr = v.customerManager || '未分配';
if (EXCLUDED_MANAGERS.has(mgr)) continue; if (EXCLUDED_MANAGERS.has(mgr)) continue;
if (!deptMap.has(dept)) deptMap.set(dept, new Map()); if (!deptMap.has(dept)) deptMap.set(dept, new Map());
@@ -749,6 +704,29 @@ app.get('/dept-stats', async (c) => {
mgrMap.get(mgr)!.push(v); mgrMap.get(mgr)!.push(v);
} }
// 补齐:业务部门内所有在职用户,即使当前无车辆也需显示
const deptNames = Array.from(deptMap.keys()).filter((d) => d !== '公务车');
if (deptNames.length > 0) {
const placeholders = deptNames.map(() => '?').join(',');
const [userRows] = await pool.query<any[]>(
`SELECT u.user_name, dep.dep_name
FROM tab_user u
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
WHERE u.is_deleted = 0
AND dep.dep_name IN (${placeholders})`,
deptNames,
);
for (const r of userRows as any[]) {
const dept = r.dep_name as string | null;
const mgr = r.user_name as string | null;
if (!dept || !mgr) continue;
if (EXCLUDED_MANAGERS.has(mgr)) continue;
const mgrMap = deptMap.get(dept);
if (!mgrMap) continue;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
}
}
// Compute attendance & avg mileage from realtime data // Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => { const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length; const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
@@ -993,11 +971,7 @@ app.get('/list', async (c) => {
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer); filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
} }
if (department) { if (department) {
filtered = filtered.filter((v) => { filtered = filtered.filter((v) => department === '公务车' ? !v.departmentName : v.departmentName === department);
if (department === '公务车') return v.model === '公务车/挂靠车';
if (department === '未分配部门') return v.model !== '公务车/挂靠车' && !v.departmentName;
return v.departmentName === department;
});
} }
if (isColdChain !== undefined) { if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true'; const wantCold = isColdChain === 'true';
@@ -1020,7 +994,6 @@ app.get('/list', async (c) => {
city: v.city, city: v.city,
status: v.status, status: v.status,
ownership: v.ownership, ownership: v.ownership,
rentCompany: v.rentCompany,
contractNo: v.contractNo, contractNo: v.contractNo,
customerName: v.customerName, customerName: v.customerName,
subjectOrg: v.subjectOrg, subjectOrg: v.subjectOrg,
@@ -1076,22 +1049,18 @@ app.get('/weekly-detail', async (c) => {
const source = c.req.query('source'); const source = c.req.query('source');
let sql: string; let sql: string;
if (type === 'delivered') { if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`; sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
} else if (type === 'returned') { } else if (type === 'returned') {
sql = `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`; sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
} else if (type === 'replaced') { } else if (type === 'replaced') {
sql = `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`; sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
} else if (type === 'pending') { } else if (type === 'pending') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`;
} else if (type === 'new') { } else if (type === 'new') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name sql = `SELECT CAST(truck.id AS CHAR) AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`;
} else { } else {
return c.json([]); return c.json([]);
} }
@@ -1155,18 +1124,20 @@ app.get('/debug', async (c) => {
${WEEK_END_SQL} AS week_end, ${WEEK_END_SQL} AS week_end,
CURDATE() AS today, CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`); WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`); LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5) const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`); LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`); AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5) LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`); WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`); WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(r.return_date) AS latest FROM tab_truck_rent_return r WHERE r.is_deleted=0 AND r.return_date IS NOT NULL`);
return c.json({ return c.json({
weekRange: dateRange, weekRange: dateRange,

View File

@@ -8,7 +8,6 @@ export interface VehicleRow {
租赁公司: string; 租赁公司: string;
车辆归属状态Label: string | null; 车辆归属状态Label: string | null;
车辆型号Label: string | null; 车辆型号Label: string | null;
车辆类型参数: string | null;
库存区域: string | null; 库存区域: string | null;
车辆租赁状态: string | null; 车辆租赁状态: string | null;
车辆租赁状态Label: string | null; 车辆租赁状态Label: string | null;
@@ -41,7 +40,6 @@ export interface Vehicle {
province: string | null; province: string | null;
city: string | null; city: string | null;
status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal'; status: 'Operating' | 'Inventory' | 'Pending' | 'Abnormal';
operationStatus: string | null;
ownership: string; ownership: string;
rentCompany: string; rentCompany: string;
contractNo: string | null; contractNo: string | null;