Compare commits
12 Commits
dev-ljyang
...
a3dfe7ab8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 |
@@ -82,19 +82,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, 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,11 +19,31 @@ 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+)[辆台](.+)/);
|
||||||
@@ -39,6 +59,7 @@ 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[]>>({});
|
||||||
@@ -46,7 +67,8 @@ 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 [expandedModel, setExpandedModel] = useState<string | null>(null);
|
const [expandedTargetId, setExpandedTargetId] = useState<number | 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('');
|
||||||
@@ -54,12 +76,25 @@ 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 => {
|
||||||
setTargets(data);
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
if (data.length > 0 && !selectedTargetId) {
|
const ordered = focused
|
||||||
setSelectedTargetId(data[0].id);
|
? [focused, ...data.filter(item => item.id !== focused.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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -80,7 +115,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" 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 [overflow-anchor:none]" 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 => (
|
||||||
@@ -103,7 +138,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 = targets.find(t => t.id === selectedTargetId);
|
const sel = selectedTarget;
|
||||||
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">
|
||||||
@@ -130,7 +165,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">
|
||||||
{(sel?.avgCompletion ?? 0).toFixed(1)}
|
{selectedCompletion.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>
|
||||||
@@ -224,12 +259,17 @@ 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={() => {
|
||||||
const name = target.targetName;
|
setExpandedTargetId(expandedTargetId === target.id ? null : target.id);
|
||||||
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 }));
|
||||||
@@ -249,12 +289,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">完成率:</span>
|
<span className="text-[9px] text-slate-400">{assessment ? `${assessment.label}完成:` : '完成率:'}</span>
|
||||||
<span className={`text-[9px] font-bold ${target.avgCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}%</span>
|
<span className={`text-[9px] font-bold ${primaryCompletion >= 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[9px] text-slate-400">达标:</span>
|
<span className="text-[9px] text-slate-400">{primaryQualifiedLabel}</span>
|
||||||
<span className="text-[9px] font-bold text-slate-600">{target.yearQualifiedCount}台</span>
|
<span className="text-[9px] font-bold text-slate-600">{primaryQualified}台</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +309,7 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: expandedModel === target.targetName ? 180 : 0 }}
|
animate={{ rotate: expandedTargetId === target.id ? 180 : 0 }}
|
||||||
className="text-slate-300"
|
className="text-slate-300"
|
||||||
>
|
>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
@@ -277,52 +317,107 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{expandedModel === target.targetName && (
|
{expandedTargetId === target.id && (
|
||||||
<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="space-y-0.5">
|
<div className="col-span-2 flex items-center justify-between gap-3 bg-blue-50/70 p-2 rounded-lg">
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">考核区间</p>
|
<span className="text-[10px] font-black text-blue-700">考核年度</span>
|
||||||
{target.periods.map((p, i) => (
|
<select
|
||||||
<p key={i} className="text-[10px] font-black text-slate-700">{p}</p>
|
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>
|
||||||
<div className="space-y-0.5">
|
<div className="col-span-2 bg-slate-50/80 rounded-lg p-2 grid grid-cols-2 gap-3">
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核里程</p>
|
<div className="space-y-0.5">
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">总考核区间</p>
|
||||||
</div>
|
{target.periods.map((p, i) => (
|
||||||
<div className="space-y-0.5">
|
<p key={i} className="text-[10px] font-black text-slate-700">{p}</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.annualMileagePerVehicle)} 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">总考核里程</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.totalMileagePerVehicle * target.vehicleCount)} km</p>
|
||||||
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} 台</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">本年需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">已完成(截止3.31)</p>
|
|
||||||
<p className="text-[10px] font-black text-emerald-600">{fmtKm(target.currentYearCompleted)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">未完成总数</p>
|
|
||||||
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">日均需完成</p>
|
|
||||||
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{assessment ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}区间</p>
|
||||||
|
{(assessment.periods.length > 0 ? assessment.periods : [`${assessment.startDate} ~ ${assessment.endDate}`]).map((period, i) => (
|
||||||
|
<p key={i} className="text-[10px] font-black text-slate-700">{period}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}任务/辆</p>
|
||||||
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}已进入车辆</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-600">{assessment.vehicleCount} 台</p>
|
||||||
|
{assessment.vehicleCount < target.vehicleCount && (
|
||||||
|
<p className="text-[8px] font-bold text-slate-400">其余 {target.vehicleCount - assessment.vehicleCount} 台尚未进入{assessment.label}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}需完成</p>
|
||||||
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}累计里程</p>
|
||||||
|
<p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.actualMileage)} km</p>
|
||||||
|
<p className="text-[8px] font-bold text-slate-300">
|
||||||
|
数据截至 {assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}计入完成</p>
|
||||||
|
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}完成率</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}达标率</p>
|
||||||
|
<p className="text-[10px] font-black text-emerald-600">{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}剩余里程</p>
|
||||||
|
<p className="text-[10px] font-black text-rose-500">{fmtKm(assessment.remaining)} km</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}日均需完成</p>
|
||||||
|
<p className="text-[10px] font-black text-blue-500">
|
||||||
|
{assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '考核已到期'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 bg-slate-50 p-2 rounded-lg text-[10px] font-bold text-slate-400">
|
||||||
|
暂无第一年度车辆,当前车型车辆已进入后续考核年度。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
<div className="col-span-2 flex items-center justify-between bg-slate-50 p-2 rounded-lg">
|
||||||
<span className="text-[9px] font-bold text-slate-500">剩余考核天数</span>
|
<span className="text-[9px] font-bold text-slate-500">{assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'}</span>
|
||||||
<span className="text-[10px] font-black text-slate-900">{target.daysLeft} 天</span>
|
<span className="text-[10px] font-black text-slate-900">{assessment?.daysLeft ?? target.daysLeft} 天</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vehicle List Detail */}
|
{/* Vehicle List Detail */}
|
||||||
@@ -363,6 +458,8 @@ export default function StatisticsView() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +488,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 + 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 + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? 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">
|
||||||
@@ -417,12 +514,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>
|
||||||
@@ -430,10 +527,22 @@ 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>
|
||||||
@@ -441,28 +550,31 @@ 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 ${target.avgCompletion >= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`}
|
||||||
style={{ width: `${Math.min(target.avgCompletion, 100)}%` }}
|
style={{ width: `${Math.min(completion, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black text-white w-10 text-right">{target.avgCompletion.toFixed(1)}%</span>
|
<span className="text-[10px] font-black text-white w-10 text-right">{completion.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(target.cumulativeTotal)}</span>
|
<span>{fmtKm(completed)}</span>
|
||||||
<span>/ {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km</span>
|
<span>/ {fmtKm(goal)} km</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 text-slate-300 text-right">{fmtKm(taskPerVehicle)} km</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-emerald-400 text-center">{qualified}</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-blue-400 text-center">{halfQualified}</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(target.currentYearTarget)} 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-emerald-400/80 text-right">{fmtKm(target.currentYearCompleted)} 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 font-bold text-rose-400 text-right">{fmtKm(target.remaining)} 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 text-slate-300 text-center">{target.daysLeft}</td>
|
<td className="px-3 py-3 text-xs text-slate-300 text-center">{days}</td>
|
||||||
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">{fmtKm(target.dailyTarget)} km</td>
|
<td className="px-3 py-3 text-xs font-bold text-blue-400 text-right">
|
||||||
|
{assessment && days === 0 ? '考核已到期' : `${fmtKm(daily)} km`}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,38 @@ 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;
|
||||||
|
actualMileage: 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 {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ 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;
|
||||||
|
|||||||
17
src/server/hydrogen-db.ts
Normal file
17
src/server/hydrogen-db.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const hydrogenPool = mysql.createPool({
|
||||||
|
host: process.env.HYDROGEN_DB_HOST || '47.99.185.173',
|
||||||
|
port: Number(process.env.HYDROGEN_DB_PORT) || 3306,
|
||||||
|
user: process.env.HYDROGEN_DB_USER || 'root',
|
||||||
|
password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.',
|
||||||
|
database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 5,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default hydrogenPool;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -19,16 +20,19 @@ app.use('*', async (c, next) => {
|
|||||||
|
|
||||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||||||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
|
||||||
|
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';
|
||||||
|
|
||||||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||||||
function customerClause(field: string, customer: CustomerKind): string {
|
function customerClause(customer: CustomerKind): string {
|
||||||
if (customer === 'external') return `${field} IS NULL`;
|
if (customer === 'external') return '1=0';
|
||||||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
if (customer === 'lingniu') return '1=1';
|
||||||
return '1=1';
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +84,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 pool.query<RowDataPacket[]>(
|
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||||
ORDER BY y DESC`,
|
ORDER BY y DESC`,
|
||||||
[HYDROGEN_MIN_DATE],
|
[HYDROGEN_MIN_DATE],
|
||||||
);
|
);
|
||||||
@@ -92,44 +96,46 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
const isCurrentYear = year === todayYear;
|
const isCurrentYear = year === todayYear;
|
||||||
|
|
||||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
THEN amount_kg ELSE 0 END) AS yearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
THEN cost_total ELSE 0 END) AS yearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
THEN fee_total ELSE 0 END) AS yearRevenue,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
THEN cost_total ELSE 0 END) AS ourYearFee,
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
THEN amount_kg 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 hydrogen_quantity ELSE 0 END) AS monthKg,
|
THEN amount_kg 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_expense ELSE 0 END) AS monthFee,
|
THEN cost_total 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')
|
||||||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
|
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 hydrogen_quantity ELSE 0 END) AS todayKg,
|
THEN amount_kg 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_expense ELSE 0 END) AS todayFee,
|
THEN cost_total 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()
|
||||||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
SUM(CASE WHEN truck_id IS NOT NULL
|
THEN fee_total ELSE 0 END) AS todayRevenue,
|
||||||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||||
FROM tab_energy_hydrogen_bill
|
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
||||||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||||||
|
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,
|
||||||
@@ -166,23 +172,19 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Top5 加氢站(指定年份)
|
// Top5 加氢站(指定年份)
|
||||||
const [top5Rows] = await pool.query<RowDataPacket[]>(
|
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
`SELECT b.station_id AS id,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
SUM(b.amount_kg) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
SUM(b.cost_total) AS fee
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
FROM ${HYDROGEN_TABLE} b
|
||||||
SUM(b.cost_expense) AS fee
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHERE ${HYDROGEN_BASE_WHERE_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.hydrogen_station_id
|
GROUP BY b.station_id
|
||||||
ORDER BY kg DESC
|
ORDER BY kg DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
@@ -197,23 +199,19 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||||||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT b.hydrogen_station_id AS id,
|
`SELECT b.station_id AS id,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
SUM(b.amount_kg) AS kg,
|
||||||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
SUM(b.fee_total) AS revenue
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
FROM ${HYDROGEN_TABLE} b
|
||||||
SUM(b.customer_expense) AS revenue
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
FROM tab_energy_hydrogen_bill b
|
WHERE ${HYDROGEN_BASE_WHERE_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.hydrogen_station_id
|
GROUP BY b.station_id
|
||||||
ORDER BY kg DESC`,
|
ORDER BY kg DESC`,
|
||||||
[HYDROGEN_MIN_DATE, year],
|
[HYDROGEN_MIN_DATE, year],
|
||||||
);
|
);
|
||||||
@@ -228,14 +226,22 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||||||
const [regionRows] = await pool.query<RowDataPacket[]>(
|
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT region, SUM(kg) AS kg FROM (
|
`SELECT region, SUM(kg) AS kg FROM (
|
||||||
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
|
SELECT CASE
|
||||||
b.hydrogen_quantity AS kg
|
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR 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 '乌鲁木齐'
|
||||||
|
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
|
||||||
@@ -257,15 +263,15 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||||||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
||||||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
ROUND(SUM(amount_kg), 2) AS kg,
|
||||||
ROUND(SUM(cost_expense), 2) AS fee,
|
ROUND(SUM(cost_total), 2) AS fee,
|
||||||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
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(customer_expense), 2) AS revenue
|
ROUND(SUM(fee_total), 2) AS revenue
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0
|
WHERE ${HYDROGEN_BASE_WHERE}
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY m
|
GROUP BY m
|
||||||
@@ -290,16 +296,16 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||||||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||||||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
|
||||||
ELSE 'customer' END AS payer,
|
ELSE 'customer' END AS payer,
|
||||||
SUM(hydrogen_quantity) AS kg,
|
SUM(amount_kg) AS kg,
|
||||||
SUM(cost_expense) AS cost,
|
SUM(cost_total) AS cost,
|
||||||
SUM(customer_expense) AS revenue
|
SUM(fee_total) AS revenue
|
||||||
FROM tab_energy_hydrogen_bill
|
FROM ${HYDROGEN_TABLE}
|
||||||
WHERE is_deleted = 0
|
WHERE ${HYDROGEN_BASE_WHERE}
|
||||||
AND ${HYDROGEN_LOCAL} >= ?
|
AND ${HYDROGEN_LOCAL} >= ?
|
||||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
GROUP BY name
|
GROUP BY name
|
||||||
@@ -331,32 +337,28 @@ 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 = [
|
||||||
'b.is_deleted = 0',
|
HYDROGEN_BASE_WHERE_B,
|
||||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
rangeClause(`b.hydrogen_time`, range),
|
rangeClause(`b.${HYDROGEN_LOCAL}`, range),
|
||||||
customerClause('b.truck_id', customer),
|
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||||
].join(' AND ');
|
].join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
// 单价不重算:直接取账本成本价。
|
||||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||||
b.hydrogen_station_id AS stationId,
|
COALESCE(b.station_id, 0) AS stationId,
|
||||||
COALESCE(MAX(s.short_name), MAX(s.name),
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||||||
MAX(os.fixed_station_name), MAX(os.station_name),
|
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||||||
MAX(i.hydrogen_station_name),
|
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||||||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||||
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 tab_energy_hydrogen_bill b
|
FROM ${HYDROGEN_TABLE} b
|
||||||
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
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, b.hydrogen_station_id
|
GROUP BY d, COALESCE(b.station_id, 0)
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,7 +416,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 === 'lingniu' ? 'lingniu' : 'external',
|
customerType: customer,
|
||||||
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,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import mileagePool from '../../mileage-db.js';
|
import mileagePool from '../../mileage-db.js';
|
||||||
import { getCache } from './cache.js';
|
import { getCache, queryDateMileage } from './cache.js';
|
||||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
|
|
||||||
@@ -32,6 +32,83 @@ app.get('/', async (c) => {
|
|||||||
const statsMap = new Map<number, any>();
|
const statsMap = new Map<number, any>();
|
||||||
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
for (const s of vehicleStats) statsMap.set(s.target_id, s);
|
||||||
|
|
||||||
|
const [firstYearRows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
v.target_id,
|
||||||
|
COUNT(*) as first_year_total,
|
||||||
|
SUM(t.annual_mileage_per_vehicle) as first_year_target,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed,
|
||||||
|
SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count,
|
||||||
|
DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date,
|
||||||
|
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date
|
||||||
|
FROM tab_mileage_assessment_vehicle v
|
||||||
|
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
||||||
|
WHERE v.is_deleted = 0
|
||||||
|
GROUP BY v.target_id
|
||||||
|
`) as [any[], unknown];
|
||||||
|
|
||||||
|
const firstYearMap = new Map<number, any>();
|
||||||
|
for (const s of firstYearRows) firstYearMap.set(s.target_id, s);
|
||||||
|
|
||||||
|
const [yearlyRows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
v.target_id,
|
||||||
|
y.year_number,
|
||||||
|
COUNT(*) as vehicle_count,
|
||||||
|
SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage,
|
||||||
|
SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage,
|
||||||
|
SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number))
|
||||||
|
/ NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count,
|
||||||
|
SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
|
||||||
|
DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date,
|
||||||
|
DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date
|
||||||
|
FROM tab_mileage_assessment_vehicle v
|
||||||
|
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
||||||
|
JOIN (
|
||||||
|
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||||
|
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
|
||||||
|
WHERE v.is_deleted = 0
|
||||||
|
GROUP BY v.target_id, y.year_number
|
||||||
|
ORDER BY v.target_id, y.year_number
|
||||||
|
`) as [any[], unknown];
|
||||||
|
|
||||||
|
const yearlyMap = new Map<number, any[]>();
|
||||||
|
for (const row of yearlyRows) {
|
||||||
|
const list = yearlyMap.get(row.target_id) || [];
|
||||||
|
list.push(row);
|
||||||
|
yearlyMap.set(row.target_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [yearlyPeriodRows] = await pool.execute(`
|
||||||
|
SELECT
|
||||||
|
v.target_id,
|
||||||
|
y.year_number,
|
||||||
|
DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date,
|
||||||
|
DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM tab_mileage_assessment_vehicle v
|
||||||
|
JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0
|
||||||
|
JOIN (
|
||||||
|
SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||||
|
) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number)
|
||||||
|
WHERE v.is_deleted = 0
|
||||||
|
GROUP BY v.target_id, y.year_number, v.assessment_start_date
|
||||||
|
ORDER BY v.target_id, y.year_number, v.assessment_start_date
|
||||||
|
`) as [any[], unknown];
|
||||||
|
|
||||||
|
const yearlyPeriodsMap = new Map<string, string[]>();
|
||||||
|
for (const row of yearlyPeriodRows) {
|
||||||
|
const key = `${row.target_id}-${row.year_number}`;
|
||||||
|
const list = yearlyPeriodsMap.get(key) || [];
|
||||||
|
list.push(`${row.start_date} ~ ${row.end_date} (${row.cnt}台)`);
|
||||||
|
yearlyPeriodsMap.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
const [periodRows] = await pool.execute(`
|
const [periodRows] = await pool.execute(`
|
||||||
SELECT target_id,
|
SELECT target_id,
|
||||||
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
|
||||||
@@ -58,8 +135,17 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [targetVehicleRows] = await pool.execute(
|
const [targetVehicleRows] = await pool.execute(
|
||||||
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
`SELECT target_id, plate_number, current_mileage, current_year_number,
|
||||||
) as [{ target_id: number; plate_number: string }[], unknown];
|
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as assessment_start_date
|
||||||
|
FROM tab_mileage_assessment_vehicle
|
||||||
|
WHERE is_deleted = 0`
|
||||||
|
) as [{
|
||||||
|
target_id: number;
|
||||||
|
plate_number: string;
|
||||||
|
current_mileage: string | number | null;
|
||||||
|
current_year_number: number;
|
||||||
|
assessment_start_date: string;
|
||||||
|
}[], unknown];
|
||||||
|
|
||||||
const targetIdPlatesMap = new Map<number, string[]>();
|
const targetIdPlatesMap = new Map<number, string[]>();
|
||||||
for (const r of targetVehicleRows) {
|
for (const r of targetVehicleRows) {
|
||||||
@@ -68,15 +154,156 @@ app.get('/', async (c) => {
|
|||||||
targetIdPlatesMap.set(r.target_id, list);
|
targetIdPlatesMap.set(r.target_id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetRuleMap = new Map<number, any>();
|
||||||
|
for (const t of targets) targetRuleMap.set(t.id, t);
|
||||||
|
|
||||||
|
function formatUtcDate(date: Date): string {
|
||||||
|
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addYearsMinusOneDay(dateStr: string, years: number): string {
|
||||||
|
const date = new Date(`${dateStr}T00:00:00Z`);
|
||||||
|
date.setUTCFullYear(date.getUTCFullYear() + years);
|
||||||
|
date.setUTCDate(date.getUTCDate() - 1);
|
||||||
|
return formatUtcDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayStr = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date());
|
||||||
|
const endedPeriodDates: string[] = [];
|
||||||
|
for (const row of targetVehicleRows) {
|
||||||
|
const target = targetRuleMap.get(row.target_id);
|
||||||
|
const maxYear = Math.min(Number(target?.assessment_years) || 0, Number(row.current_year_number) || 0);
|
||||||
|
for (let year = 1; year <= maxYear; year++) {
|
||||||
|
const endDate = addYearsMinusOneDay(row.assessment_start_date, year);
|
||||||
|
if (endDate < todayStr) endedPeriodDates.push(endDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoffMileageMapByDate = new Map<string, Map<string, number>>();
|
||||||
|
for (const date of Array.from(new Set(endedPeriodDates))) {
|
||||||
|
const vehicles = await queryDateMileage(date);
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const vehicle of vehicles) {
|
||||||
|
if (vehicle.totalKm != null) map.set(vehicle.plate, vehicle.totalKm);
|
||||||
|
}
|
||||||
|
cutoffMileageMapByDate.set(date, map);
|
||||||
|
}
|
||||||
|
const currentMileageMap = new Map<string, number>();
|
||||||
|
for (const vehicle of await queryDateMileage(todayStr)) {
|
||||||
|
if (vehicle.totalKm != null) currentMileageMap.set(vehicle.plate, vehicle.totalKm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearlyMetricMap = new Map<string, {
|
||||||
|
actualMileage: number;
|
||||||
|
completed: number;
|
||||||
|
remaining: number;
|
||||||
|
completionRate: number;
|
||||||
|
qualifiedCount: number;
|
||||||
|
qualifiedRate: number;
|
||||||
|
halfQualifiedCount: number;
|
||||||
|
}>();
|
||||||
|
const yearlyMetricDraftMap = new Map<string, {
|
||||||
|
target: number;
|
||||||
|
actualMileage: number;
|
||||||
|
completed: number;
|
||||||
|
remaining: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
qualifiedCount: number;
|
||||||
|
halfQualifiedCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const row of targetVehicleRows) {
|
||||||
|
const target = targetRuleMap.get(row.target_id);
|
||||||
|
const annualMileage = Number(target?.annual_mileage_per_vehicle) || 0;
|
||||||
|
const maxYear = Math.min(Number(target?.assessment_years) || 0, Number(row.current_year_number) || 0);
|
||||||
|
|
||||||
|
for (let year = 1; year <= maxYear; year++) {
|
||||||
|
const key = `${row.target_id}-${year}`;
|
||||||
|
const goal = annualMileage * year;
|
||||||
|
const endDate = addYearsMinusOneDay(row.assessment_start_date, year);
|
||||||
|
const cutoffMap = endDate < todayStr ? cutoffMileageMapByDate.get(endDate) : currentMileageMap;
|
||||||
|
const mileageAtCutoff = Math.max(0, cutoffMap?.get(row.plate_number) ?? (Number(row.current_mileage) || 0));
|
||||||
|
const completed = Math.min(mileageAtCutoff, goal);
|
||||||
|
const draft = yearlyMetricDraftMap.get(key) || {
|
||||||
|
target: 0,
|
||||||
|
actualMileage: 0,
|
||||||
|
completed: 0,
|
||||||
|
remaining: 0,
|
||||||
|
vehicleCount: 0,
|
||||||
|
qualifiedCount: 0,
|
||||||
|
halfQualifiedCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
draft.target += goal;
|
||||||
|
draft.actualMileage += mileageAtCutoff;
|
||||||
|
draft.completed += completed;
|
||||||
|
draft.remaining += Math.max(goal - mileageAtCutoff, 0);
|
||||||
|
draft.vehicleCount += 1;
|
||||||
|
if (mileageAtCutoff >= goal) draft.qualifiedCount += 1;
|
||||||
|
if (mileageAtCutoff >= goal * 0.5) draft.halfQualifiedCount += 1;
|
||||||
|
yearlyMetricDraftMap.set(key, draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, draft] of yearlyMetricDraftMap) {
|
||||||
|
yearlyMetricMap.set(key, {
|
||||||
|
actualMileage: round2(draft.actualMileage),
|
||||||
|
completed: round2(draft.completed),
|
||||||
|
remaining: round2(draft.remaining),
|
||||||
|
completionRate: round2(draft.target > 0 ? (draft.completed / draft.target) * 100 : 0),
|
||||||
|
qualifiedCount: draft.qualifiedCount,
|
||||||
|
qualifiedRate: round2(draft.vehicleCount > 0 ? (draft.qualifiedCount / draft.vehicleCount) * 100 : 0),
|
||||||
|
halfQualifiedCount: draft.halfQualifiedCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
const cutoffMetrics = yearlyMetricMap.get(`${row.target_id}-${row.year_number}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
yearNumber,
|
||||||
|
label: `第${yearNumber}年`,
|
||||||
|
vehicleCount,
|
||||||
|
target: Number(row.target_mileage) || 0,
|
||||||
|
actualMileage: cutoffMetrics?.actualMileage ?? (Number(row.completed_mileage) || 0),
|
||||||
|
completed: cutoffMetrics?.completed ?? (Number(row.completed_mileage) || 0),
|
||||||
|
remaining: cutoffMetrics?.remaining ?? remainingMileage,
|
||||||
|
completionRate: cutoffMetrics?.completionRate ?? ((Number(row.completion_rate) || 0) * 100),
|
||||||
|
qualifiedCount: cutoffMetrics?.qualifiedCount ?? qualifiedCount,
|
||||||
|
qualifiedRate: cutoffMetrics?.qualifiedRate ?? (vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0),
|
||||||
|
halfQualifiedCount: cutoffMetrics?.halfQualifiedCount ?? (Number(row.half_qualified_count) || 0),
|
||||||
|
daysLeft: assessmentDaysLeft,
|
||||||
|
dailyTarget: assessmentDaysLeft > 0 ? Math.round(((cutoffMetrics?.remaining ?? 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) {
|
||||||
@@ -104,6 +331,19 @@ 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user