171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
import type {
|
|
MileageReportGroup,
|
|
MileageReportVehicle,
|
|
} from '../api';
|
|
|
|
export type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY';
|
|
export type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
|
|
export type SortDirection = 'asc' | 'desc';
|
|
|
|
const REPORT_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
const HIDDEN_QUALITY_NOTE = '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。';
|
|
|
|
export function shanghaiYmd(offsetDays = 0, now = new Date()): string {
|
|
// 日报按上海自然日归档,不能依赖浏览器所在时区计算“昨天”。
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(now);
|
|
const date = new Date(`${parts}T00:00:00Z`);
|
|
date.setUTCDate(date.getUTCDate() + offsetDays);
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function dateShift(date: string, days: number): string {
|
|
const value = new Date(`${date}T00:00:00Z`);
|
|
value.setUTCDate(value.getUTCDate() + days);
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function monthShift(month: string, months: number): string {
|
|
const value = new Date(`${month}-01T00:00:00Z`);
|
|
value.setUTCMonth(value.getUTCMonth() + months);
|
|
return value.toISOString().slice(0, 7);
|
|
}
|
|
|
|
export function reportDateLabel(value: string): string {
|
|
const date = new Date(`${value}T00:00:00Z`);
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
timeZone: 'UTC',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
weekday: 'short',
|
|
}).format(date).replace(/日(?=周)/, '日 ');
|
|
}
|
|
|
|
export function initialReportDate(search: string, fallbackDate = shanghaiYmd(-1)): string {
|
|
const value = new URLSearchParams(search).get('mileageReportDate');
|
|
return REPORT_DATE_PATTERN.test(value || '') ? value! : fallbackDate;
|
|
}
|
|
|
|
export function fmtKm(value: number): string {
|
|
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`;
|
|
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
|
|
}
|
|
|
|
export function fmtDate(value: string): string {
|
|
const [, month, day] = value.split('-');
|
|
return `${Number(month)}月${Number(day)}日`;
|
|
}
|
|
|
|
export function fmtDateTime(value: string | null): string {
|
|
if (!value) return '数据时间未返回';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString('zh-CN', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
export function deltaLabel(value: number, rate: number | null): string {
|
|
const sign = value >= 0 ? '+' : '';
|
|
return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`;
|
|
}
|
|
|
|
export function currentAssessmentYearLabel(periods: string[], reportDate: string): string | null {
|
|
const report = new Date(`${reportDate}T00:00:00Z`);
|
|
if (Number.isNaN(report.getTime())) return null;
|
|
const years = new Set<number>();
|
|
|
|
// 同一车型可能分批交付,因此一个日报日期可能同时落在多个考核年度。
|
|
for (const period of periods) {
|
|
const dates = Array.from(period.matchAll(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/g));
|
|
const start = dates[0];
|
|
const end = dates[1];
|
|
if (!start || !end) continue;
|
|
const startDate = new Date(Date.UTC(Number(start[1]), Number(start[2]) - 1, Number(start[3])));
|
|
const endDate = new Date(Date.UTC(Number(end[1]), Number(end[2]) - 1, Number(end[3])));
|
|
if (report < startDate || report > endDate) continue;
|
|
|
|
let yearNumber = report.getUTCFullYear() - startDate.getUTCFullYear() + 1;
|
|
if (
|
|
report.getUTCMonth() < startDate.getUTCMonth()
|
|
|| (report.getUTCMonth() === startDate.getUTCMonth() && report.getUTCDate() < startDate.getUTCDate())
|
|
) yearNumber -= 1;
|
|
if (yearNumber > 0) years.add(yearNumber);
|
|
}
|
|
|
|
if (years.size === 0) return null;
|
|
const labels = Array.from(years).sort((a, b) => a - b).map(year => String(year));
|
|
return `考核第${labels.join('/')}年`;
|
|
}
|
|
|
|
export function dailyMileageStatus(group: Pick<
|
|
MileageReportGroup,
|
|
'dailyRequiredMileage' | 'remainingMileage' | 'dailyMileage'
|
|
>): {
|
|
label: string;
|
|
detail: string;
|
|
tone: 'positive' | 'warning' | 'neutral';
|
|
} {
|
|
if (group.dailyRequiredMileage <= 0) {
|
|
if (group.remainingMileage <= 0) {
|
|
return { label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' };
|
|
}
|
|
return { label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' };
|
|
}
|
|
|
|
const gap = group.dailyMileage - group.dailyRequiredMileage;
|
|
const rate = (group.dailyMileage / group.dailyRequiredMileage) * 100;
|
|
if (gap >= 0) {
|
|
return { label: '今日已达标', detail: `超出应完成 ${fmtKm(gap)} km,完成 ${rate.toFixed(1)}%。`, tone: 'positive' };
|
|
}
|
|
return { label: '今日未达标', detail: `距应完成差 ${fmtKm(Math.abs(gap))} km,完成 ${rate.toFixed(1)}%。`, tone: 'warning' };
|
|
}
|
|
|
|
export function sevenDayMileageOf(vehicle: MileageReportVehicle): number {
|
|
return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0);
|
|
}
|
|
|
|
function vehicleSortValue(vehicle: MileageReportVehicle, sortKey: VehicleSortKey): number {
|
|
return sortKey === 'sevenDayMileage' ? sevenDayMileageOf(vehicle) : vehicle[sortKey];
|
|
}
|
|
|
|
export function filterAndSortVehicles(
|
|
vehicles: MileageReportVehicle[],
|
|
filter: VehicleFilter,
|
|
search: string,
|
|
sortKey: VehicleSortKey,
|
|
sortDirection: SortDirection,
|
|
): MileageReportVehicle[] {
|
|
const query = search.trim().toLowerCase();
|
|
return vehicles
|
|
.filter(vehicle => {
|
|
if (filter !== 'ALL' && vehicle.mileageBand !== filter) return false;
|
|
if (!query) return true;
|
|
return vehicle.plate.toLowerCase().includes(query)
|
|
|| (vehicle.customer || '').toLowerCase().includes(query)
|
|
|| (vehicle.department || '').toLowerCase().includes(query);
|
|
})
|
|
.sort((left, right) => {
|
|
const difference = vehicleSortValue(left, sortKey) - vehicleSortValue(right, sortKey);
|
|
if (difference !== 0) return sortDirection === 'asc' ? difference : -difference;
|
|
return left.plate.localeCompare(right.plate, 'zh-CN');
|
|
});
|
|
}
|
|
|
|
export function roundMileageForDisplay(value: number): number {
|
|
return Math.round(value * 10) / 10;
|
|
}
|
|
|
|
export function isVisibleQualityNote(message: string): boolean {
|
|
return message !== HIDDEN_QUALITY_NOTE;
|
|
}
|