import { Provide, Inject } from '@midwayjs/core';
|
import { BaseService } from '@cool-midway/core';
|
import { InjectEntityModel } from '@midwayjs/typeorm';
|
import { Repository } from 'typeorm';
|
import { ReportDailyVehicleEntity } from '../entity/dailyvehicle';
|
import { PushVehicleGpsEntity } from '../../push/entity/vehiclegps';
|
import { BaseSysParamService } from '../../base/service/sys/param';
|
|
|
|
@Provide()
|
export class TaskServiceVehicle extends BaseService {
|
@InjectEntityModel(ReportDailyVehicleEntity)
|
reportDailyVehicleEntity: Repository<ReportDailyVehicleEntity>;
|
|
@InjectEntityModel(PushVehicleGpsEntity)
|
pushVehicleGpsEntity: Repository<PushVehicleGpsEntity>;
|
|
@Inject()
|
baseSysParamService: BaseSysParamService;
|
|
async aggregateDailyData(targetDate: string) {
|
const startTime = `${targetDate} 00:00:00`;
|
const endTime = `${targetDate} 23:59:59`;
|
|
let HOUR_STARTWORK = parseInt(await this.baseSysParamService.dataByKey('vehicle.hour.startwork')); //'6';
|
let HOUR_OFFWORK = parseInt(await this.baseSysParamService.dataByKey('vehicle.hour.offwork')); //'22';
|
let THRESHOLD_MOVING = parseInt(await this.baseSysParamService.dataByKey('vehicle.speed.threshold.moving')); //'2.0 km/h';
|
let THRESHOLD_SPEEDING = parseInt(await this.baseSysParamService.dataByKey('vehicle.speed.threshold.speeding')); //'30 km/h';
|
|
HOUR_STARTWORK = Number.isNaN(HOUR_STARTWORK) ? 6 : HOUR_STARTWORK;
|
HOUR_OFFWORK = Number.isNaN(HOUR_OFFWORK) ? 22 : HOUR_OFFWORK;
|
THRESHOLD_MOVING = Number.isNaN(THRESHOLD_MOVING) ? 2 : THRESHOLD_MOVING;
|
THRESHOLD_SPEEDING = Number.isNaN(THRESHOLD_SPEEDING) ? 30 : THRESHOLD_SPEEDING;
|
|
|
|
const rawGpsList = await this.pushVehicleGpsEntity.createQueryBuilder('gps')
|
.select([
|
'gps.vehicleNo AS vehicleNo', // 拿最新的自增ID作为每日表的 siteId
|
'gps.departmentId AS departmentId', // 永远拿当前后台最新的站点名称,防止设备传旧名称
|
'gps.gpsTime AS gpsTime',
|
'gps.longitude AS longitude',
|
'gps.latitude AS latitude',
|
'gps.speed AS speed',
|
'gps.direction AS direction',
|
'gps.elevation AS elevation'
|
])
|
.where('gps.gpsTime BETWEEN :start AND :end', { start: startTime, end: endTime })
|
.orderBy({
|
'gps.vehicleNo': 'ASC',
|
'gps.gpsTime': 'ASC',
|
})
|
.getRawMany();
|
|
if (!rawGpsList || rawGpsList.length === 0) return;
|
|
// 2. 按车牌号 vehicleNo 分组
|
const groupedMap = new Map<string, PushVehicleGpsEntity[]>();
|
for (const point of rawGpsList) {
|
if (!groupedMap.has(point.vehicleNo)) {
|
groupedMap.set(point.vehicleNo, []);
|
}
|
groupedMap.get(point.vehicleNo)?.push(point);
|
}
|
|
// 3. 计算截至当前(或全天)经历的总分钟数,用于倒推计算 offlineMinutes
|
const startMs = new Date(`${targetDate}T00:00:00`).getTime();
|
const endMs = new Date(`${targetDate}T23:59:59`).getTime();
|
const nowMs = new Date().getTime();
|
|
// 如果 targetDate 是今天,计算到当前时间;如果是过去/未来,上限算到 23:59:59
|
const effectiveEndMs = nowMs < endMs ? nowMs : endMs;
|
const totalElapsedMinutes = Math.max(1, Math.floor((effectiveEndMs - startMs) / (1000 * 60)));
|
|
const summariesToUpsert: Partial<ReportDailyVehicleEntity>[] = [];
|
|
// 4. 逐车按严格顺序计算各项指标
|
for (const [vehicleNo, points] of groupedMap.entries()) {
|
const summary = this.calculateVehicleMetrics(
|
targetDate,
|
vehicleNo,
|
points,
|
totalElapsedMinutes,
|
HOUR_STARTWORK,
|
HOUR_OFFWORK,
|
THRESHOLD_MOVING,
|
THRESHOLD_SPEEDING
|
);
|
summariesToUpsert.push(summary);
|
}
|
|
// 5. 执行 UPSERT 覆盖更新
|
await this.upsertDailyReports(summariesToUpsert);
|
|
|
return summariesToUpsert.length;
|
}
|
|
|
/**
|
* 核心单车计算引擎(严格按照指标依赖逻辑顺序执行)
|
*/
|
private calculateVehicleMetrics(
|
targetDate: string,
|
vehicleNo: string,
|
points: PushVehicleGpsEntity[],
|
totalElapsedMinutes: number,
|
HOUR_STARTWORK: number,
|
HOUR_OFFWORK: number,
|
HHRESHOLD_MOVING: number,
|
THRESHOLD_SPEEDING: number
|
): Partial<ReportDailyVehicleEntity> {
|
const departmentId = points[0]?.departmentId ?? null;
|
|
// 基础统计量
|
let totalMeter = 0; // 累计行驶距离(米)
|
let secondsMoving = 0; // 纯行驶时长(秒)
|
let secondsParked = 0; // 静止停靠时长(秒)
|
let secondsAbnormalIdle = 0; // 异常怠速时长(秒)
|
|
// 安全驾驶统计量
|
let overspeedCount = 0; // 超速次数
|
let overspeedDurationSec = 0; // 超速总秒数
|
let harshBrakeCount = 0; // 急刹车次数
|
let harshAccelCount = 0; // 急加速次数
|
let harshTurnCount = 0; // 急转弯次数
|
let offHoursMoveCount = 0; // 非工作时段异动次数
|
|
let isOverspeeding = false; // 超速状态标记
|
|
for (let i = 0; i < points.length; i++) {
|
const curr = points[i];
|
const speed = Number(curr.speed) || 0;
|
const currDate = new Date(curr.gpsTime);
|
|
// ====================================================
|
// 顺序 1:单点状态判断(只依赖当前点的数据)
|
// ====================================================
|
|
// A. 判断非工作时段异动 (23:00 - 05:00 且速度 > 2 km/h)
|
const hour = currDate.getHours();
|
if ((hour >= HOUR_OFFWORK || hour < HOUR_STARTWORK) && speed > HHRESHOLD_MOVING) {
|
offHoursMoveCount++;
|
}
|
|
// B. 判断超速告警 (限速 30 km/h)
|
if (speed > THRESHOLD_SPEEDING) {
|
overspeedDurationSec += 1; // 按 1 秒/采样点粗略累加
|
if (!isOverspeeding) {
|
overspeedCount++; // 刚跨过 20 时记录一次新告警
|
isOverspeeding = true;
|
}
|
} else {
|
isOverspeeding = false;
|
}
|
|
// ====================================================
|
// 顺序 2:相邻点微分与物理量计算(必须依赖前一点 i-1)
|
// ====================================================
|
if (i > 0) {
|
const prev = points[i - 1];
|
const prevDate = new Date(prev.gpsTime);
|
|
// 计算相邻两点时间差(秒)
|
const timeDiffSec = Math.floor((currDate.getTime() - prevDate.getTime()) / 1000);
|
|
// 过滤网络断连/关机(时间差 > 120 秒 视为掉线,不参与物理微分计算)
|
if (timeDiffSec > 0 && timeDiffSec <= 120) {
|
|
// ================================================
|
// 顺序 3:基于速度划分为【行驶】或【静止停靠】
|
// ================================================
|
if (speed > HHRESHOLD_MOVING) {
|
// 3.1 累加纯行驶时长
|
secondsMoving += timeDiffSec;
|
|
// 3.2 累加球面真实位移距离
|
totalMeter += this.getHaversineDistance(
|
Number(prev.latitude),
|
Number(prev.longitude),
|
Number(curr.latitude),
|
Number(curr.longitude),
|
);
|
} else {
|
// 3.3 累加静止停靠时长
|
secondsParked += timeDiffSec;
|
|
// 3.4 静止停靠超过 15 分钟(900秒)算作异常怠速
|
if (timeDiffSec > 900) {
|
secondsAbnormalIdle += timeDiffSec;
|
}
|
}
|
|
// ================================================
|
// 顺序 4:安全驾驶行为(加速度与转弯角微分计算)
|
// ================================================
|
|
// 4.1 加速度计算: a = (v2 - v1) / t (需要转换为 m/s)
|
const v1 = (Number(prev.speed) || 0) / 3.6;
|
const v2 = speed / 3.6;
|
const accel = (v2 - v1) / timeDiffSec;
|
|
if (accel > 3.0) {
|
harshAccelCount++; // 急加速 a > 3.0 m/s²
|
} else if (accel < -3.5) {
|
harshBrakeCount++; // 急刹车 a < -3.5 m/s²
|
}
|
|
// 4.2 急转弯计算: 2秒内航向角差 > 60° 且车速 > 15 km/h
|
const prevDir = Number(prev.direction) || 0;
|
const currDir = Number(curr.direction) || 0;
|
let dirDiff = Math.abs(currDir - prevDir);
|
if (dirDiff > 180) dirDiff = 360 - dirDiff; // 跨 0/360 度修正
|
|
if (timeDiffSec <= 2 && dirDiff > 60 && speed > 15) {
|
harshTurnCount++;
|
}
|
}
|
}
|
}
|
|
// ====================================================
|
// 顺序 5:衍生指标倒推计算(必须放在最后)
|
// ====================================================
|
|
// 5.1 秒转化为分钟
|
const minutesMoving = Math.round(secondsMoving / 60);
|
const minutesParked = Math.round(secondsParked / 60);
|
const minutesAbnormalIdle = Math.round(secondsAbnormalIdle / 60);
|
const totalKm = Number((totalMeter / 1000).toFixed(2));
|
|
// 5.2 倒推【未启动/离线时长】 = 当天已过总时长 - 行驶时长 - 停靠时长
|
const minutesOffline = Math.max(
|
0,
|
totalElapsedMinutes - minutesMoving - minutesParked,
|
);
|
|
// 5.3 计算【平均行驶车速】 = 总行驶公里数 / 纯行驶小时数
|
const hoursMoving = secondsMoving / 3600;
|
const avgSpeed =
|
hoursMoving > 0 ? Number((totalKm / hoursMoving).toFixed(1)) : 0.0;
|
|
return {
|
date: targetDate,
|
vehicleNo,
|
departmentId,
|
totalKm,
|
minutesMoving,
|
minutesParked,
|
minutesOffline,
|
minutesAbnormalIdle,
|
avgSpeed,
|
overspeedCount,
|
overspeedDurationSec,
|
harshBrakeCount,
|
harshAccelCount,
|
harshTurnCount,
|
offHoursMoveCount,
|
};
|
}
|
|
/**
|
* 数据库批量覆盖更新 (ON DUPLICATE KEY UPDATE)
|
*/
|
private async upsertDailyReports(finalRecords: Partial<ReportDailyVehicleEntity>[]) {
|
if (finalRecords.length === 0) return;
|
|
// 1. 动态抓取所有的键,将主键、联合唯一键彻底从“待更新字段”中排除
|
// 这样能确保 ON DUPLICATE KEY UPDATE 后面的赋值语句绝对干净
|
const allUpdateFields = Object.keys(finalRecords[0]).filter(
|
k => k !== 'date' && k !== 'vehicleNo' && k !== 'id'
|
);
|
|
// 2. 依然推荐采用 Chunk 分批,这是应对海量数据/多子字段最安全、执行效率最高的做法
|
const chunkSize = 50;
|
for (let i = 0; i < finalRecords.length; i += chunkSize) {
|
const chunk = finalRecords.slice(i, i + chunkSize);
|
|
await this.reportDailyVehicleEntity.createQueryBuilder()
|
.insert()
|
.values(chunk)
|
// 💡 显式声明:当 ['date', 'siteId'] 发生冲突时,强行把 allUpdateFields 里的字段全部更新一遍
|
.orUpdate(allUpdateFields, ['date', 'vehicleNo'])
|
.execute();
|
}
|
|
}
|
|
/**
|
* 纯 JS 原生计算两点之间的球面距离 (单位: 米)
|
*/
|
private getHaversineDistance(
|
lat1: number,
|
lon1: number,
|
lat2: number,
|
lon2: number,
|
): number {
|
if (lat1 === lat2 && lon1 === lon2) return 0;
|
const R = 6371000;
|
const rad = Math.PI / 180;
|
const dLat = (lat2 - lat1) * rad;
|
const dLon = (lon2 - lon1) * rad;
|
const a =
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
Math.cos(lat1 * rad) *
|
Math.cos(lat2 * rad) *
|
Math.sin(dLon / 2) *
|
Math.sin(dLon / 2);
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
return R * c;
|
}
|
}
|