15 Commits

Author SHA1 Message Date
kkfluous
cc778f3701 fix(mileage): 导出保留里程小数
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 14:54:30 +08:00
kkfluous
74d6efe261 Revert "fix(mileage): 区分年度累计里程和计入完成"
This reverts commit 3f0edfaaf5.
2026-06-03 14:53:54 +08:00
kkfluous
a124e31fab Revert "fix(mileage): 未到期年度累计对齐实时监控"
This reverts commit a3dfe7ab8c.
2026-06-03 14:53:54 +08:00
kkfluous
a3dfe7ab8c fix(mileage): 未到期年度累计对齐实时监控
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 11:32:50 +08:00
kkfluous
3f0edfaaf5 fix(mileage): 区分年度累计里程和计入完成
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 11:18:48 +08:00
kkfluous
feb950dd59 fix(mileage): 已到期年度按期末里程统计
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-03 11:13:40 +08:00
kkfluous
5e1c12eba2 fix(mileage): 减少车型展开时页面抖动
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 16:01:47 +08:00
kkfluous
ae24bc7647 fix(mileage): 已到期年度显示考核期末日期
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:50:35 +08:00
kkfluous
0a372e4290 fix(mileage): 消除年度完成日期歧义
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:48:01 +08:00
kkfluous
1e08d1ea62 fix(mileage): 标注年度已进入车辆数
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:34:25 +08:00
kkfluous
2d82918d73 fix(mileage): 年度考核区间按批次展示
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:33:08 +08:00
kkfluous
482243e052 feat(mileage): 支持车型按考核年度查看
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-06-02 15:30:13 +08:00
kkfluous
f1a69c8271 Correct hydrogen daily vehicle split
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-28 22:19:08 +08:00
kkfluous
1d2c3a0cd5 Switch hydrogen BI to ledger data source
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-28 16:47:43 +08:00
lnljyang
e7ba5315e1 拆分菜单 通过url区分访问
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-14 17:44:04 +08:00
8 changed files with 597 additions and 201 deletions

View File

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

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, 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,17 +317,37 @@ 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="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>
))} ))}
@@ -296,33 +356,64 @@ 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">/</p> <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)} km</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>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">50%</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}/</p>
<p className="text-[10px] font-black text-blue-600">{target.halfQualifiedCount} </p> <p className="text-[10px] font-black text-slate-700">{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} 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">{assessment.label}</p>
<p className="text-[10px] font-black text-slate-700">{fmtKm(target.currentYearTarget)} km</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>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">(3.31)</p> <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(target.currentYearCompleted)} km</p> <p className="text-[10px] font-black text-slate-700">{fmtKm(assessment.target)} 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">{assessment.label}</p>
<p className="text-[10px] font-black text-rose-500">{fmtKm(target.remaining)} km</p> <p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300">
{assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel}
</p>
</div> </div>
<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">{assessment.label}</p>
<p className="text-[10px] font-black text-blue-500">{fmtKm(target.dailyTarget)} km</p> <p className="text-[10px] font-black text-blue-600">{fmtPercent(assessment.completionRate)}</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"></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 +454,8 @@ export default function StatisticsView() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
);
})()
))} ))}
</div> </div>
</div> </div>
@@ -391,7 +484,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 +510,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 +523,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 +546,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>

View File

@@ -63,6 +63,37 @@ 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, Math.round(v.dailyKm || 0)); return Math.max(0, v.dailyKm || 0);
} }
return v.totalKm != null ? Math.round(v.totalKm) : '未对接'; return v.totalKm != null ? v.totalKm : '未对接';
} }
export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void { export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportContext): void {
@@ -57,6 +57,13 @@ 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

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

View File

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

View File

@@ -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,160 @@ 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 postPeriodDailyMap = new Map<string, { date: string; km: number }[]>();
const allTargetPlates = Array.from(new Set(targetVehicleRows.map(row => row.plate_number)));
const minEndedDate = endedPeriodDates.sort()[0];
if (minEndedDate && allTargetPlates.length > 0) {
const [postPeriodDailyRows] = await mileagePool.execute(
`SELECT plate,
DATE_FORMAT(stat_date, '%Y-%m-%d') as stat_date,
MAX(GREATEST(daily_km, 0)) as daily_km
FROM v_vehicle_daily_stats
WHERE stat_date > ? AND plate IN (${allTargetPlates.map(() => '?').join(',')})
GROUP BY plate, stat_date`,
[minEndedDate, ...allTargetPlates]
) as [{ plate: string; stat_date: string; daily_km: string | number | null }[], unknown];
for (const row of postPeriodDailyRows) {
const list = postPeriodDailyMap.get(row.plate) || [];
list.push({ date: row.stat_date, km: Number(row.daily_km) || 0 });
postPeriodDailyMap.set(row.plate, list);
}
}
const yearlyMetricMap = new Map<string, {
completed: number;
remaining: number;
completionRate: number;
qualifiedCount: number;
qualifiedRate: number;
halfQualifiedCount: number;
}>();
const yearlyMetricDraftMap = new Map<string, {
target: 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);
const postDailyRows = postPeriodDailyMap.get(row.plate_number) || [];
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 postPeriodMileage = endDate < todayStr
? postDailyRows.reduce((sum, item) => item.date > endDate ? sum + item.km : sum, 0)
: 0;
const mileageAtCutoff = Math.max(0, (Number(row.current_mileage) || 0) - postPeriodMileage);
const completed = Math.min(mileageAtCutoff, goal);
const draft = yearlyMetricDraftMap.get(key) || {
target: 0,
completed: 0,
remaining: 0,
vehicleCount: 0,
qualifiedCount: 0,
halfQualifiedCount: 0,
};
draft.target += goal;
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, {
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,
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 +335,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,
}; };
}); });