import { Provide } from '@midwayjs/core';
|
import { BaseService } from '@cool-midway/core';
|
import { InjectEntityModel } from '@midwayjs/typeorm';
|
import { Repository } from 'typeorm';
|
|
import { ReportDailySorterEntity } from '../entity/dailysorter';
|
|
import { BasicdataSorterEntity } from '../../basicdata/entity/sorter';
|
|
import { PushSorterAttendanceEntity } from '../../push/entity/sorterattendance';
|
import { PushSorterGpsEntity } from '../../push/entity/sortergps';
|
import { PushSorterHealthEntity } from '../../push/entity/sorterhealth';
|
import { PushSorterAlarmEntity } from '../../push/entity/sorteralarm';
|
|
import { RecycleOrderEntity } from '../../shop/entity/recycleorder';
|
|
|
/**
|
* 分拣员日报统计
|
*
|
* 统计原则:
|
*
|
* 1. BasicdataSorterEntity 是分拣员主数据
|
* 2. businessId 是分拣员业务唯一标识
|
* 3. 一名分拣员一天一条日报
|
* 4. date + businessId 唯一
|
*
|
* 数据来源:
|
*
|
* BasicdataSorter
|
* ├── Attendance
|
* ├── GPS
|
* ├── Health
|
* ├── Alarm
|
* └── RecycleOrder
|
*
|
* ↓
|
*
|
* ReportDailySorter
|
*/
|
@Provide()
|
export class TaskServiceSorter extends BaseService {
|
|
/**
|
* 分拣员基础信息
|
*/
|
@InjectEntityModel(BasicdataSorterEntity)
|
basicdataSorterEntity: Repository<BasicdataSorterEntity>;
|
|
/**
|
* 分拣员日报
|
*/
|
@InjectEntityModel(ReportDailySorterEntity)
|
reportDailySorterEntity: Repository<ReportDailySorterEntity>;
|
|
/**
|
* 分拣员考勤
|
*/
|
@InjectEntityModel(PushSorterAttendanceEntity)
|
pushSorterAttendanceEntity: Repository<PushSorterAttendanceEntity>;
|
|
/**
|
* 分拣员GPS
|
*/
|
@InjectEntityModel(PushSorterGpsEntity)
|
pushSorterGpsEntity: Repository<PushSorterGpsEntity>;
|
|
/**
|
* 分拣员健康
|
*/
|
@InjectEntityModel(PushSorterHealthEntity)
|
pushSorterHealthEntity: Repository<PushSorterHealthEntity>;
|
|
/**
|
* 分拣员健康告警
|
*/
|
@InjectEntityModel(PushSorterAlarmEntity)
|
pushSorterAlarmEntity: Repository<PushSorterAlarmEntity>;
|
|
/**
|
* 回收订单
|
*/
|
@InjectEntityModel(RecycleOrderEntity)
|
recycleOrderEntity: Repository<RecycleOrderEntity>;
|
|
|
/**
|
* =========================================================
|
* 每日统计
|
* =========================================================
|
*
|
* @param targetDate 统计日期,例如:2026-08-16
|
*/
|
async aggregateDailyData(targetDate: string) {
|
|
const startTime = `${targetDate} 00:00:00`;
|
const endTime = `${targetDate} 23:59:59`;
|
|
// =======================================================
|
// 1. 查询当天有效的分拣员
|
// =======================================================
|
|
const sorterList =
|
await this.getActiveSorters(targetDate);
|
|
if (!sorterList.length) {
|
return 0;
|
}
|
|
|
// =======================================================
|
// 2. 初始化日报
|
//
|
// 注意:
|
// 这里以分拣员基础信息为准,而不是以当天产生的数据为准。
|
//
|
// 即使某个分拣员当天没有:
|
// GPS / 健康 / 订单 / 考勤
|
//
|
// 也会产生一条日报。
|
// =======================================================
|
|
const summaryMap =
|
new Map<string, Partial<ReportDailySorterEntity>>();
|
|
for (const sorter of sorterList) {
|
|
summaryMap.set(
|
sorter.businessId,
|
{
|
date: targetDate,
|
|
businessId: sorter.businessId,
|
businessName: sorter.businessName,
|
|
departmentId: sorter.departmentId,
|
workSiteId: sorter.workSiteId,
|
|
checkInTime: null,
|
checkOutTime: null,
|
workHours: 0,
|
attendanceStatus: '缺卡',
|
|
gpsPointCount: 0,
|
totalKm: 0,
|
minutesMoving: 0,
|
|
orderCount: 0,
|
completedOrderCount: 0,
|
appointmentOrderCount: 0,
|
totalCarbonPoint: 0,
|
|
healthDataCount: 0,
|
healthAbnormalCount: 0,
|
|
alarmCount: 0,
|
generalAlarmCount: 0,
|
mediumAlarmCount: 0,
|
seriousAlarmCount: 0,
|
unhandledAlarmCount: 0,
|
}
|
);
|
}
|
|
|
// =======================================================
|
// 3. 考勤统计
|
// =======================================================
|
|
await this.aggregateAttendance(
|
targetDate,
|
summaryMap
|
);
|
|
|
// =======================================================
|
// 4. GPS统计
|
// =======================================================
|
|
await this.aggregateGps(
|
startTime,
|
endTime,
|
summaryMap
|
);
|
|
|
// =======================================================
|
// 5. 健康数据统计
|
// =======================================================
|
|
await this.aggregateHealth(
|
startTime,
|
endTime,
|
summaryMap
|
);
|
|
|
// =======================================================
|
// 6. 健康告警统计
|
// =======================================================
|
|
await this.aggregateAlarm(
|
startTime,
|
endTime,
|
summaryMap
|
);
|
|
|
// =======================================================
|
// 7. 回收订单统计
|
// =======================================================
|
|
await this.aggregateRecycleOrder(
|
startTime,
|
endTime,
|
sorterList,
|
summaryMap
|
);
|
|
|
// =======================================================
|
// 8. 保存日报
|
// =======================================================
|
|
const records =
|
Array.from(summaryMap.values());
|
|
await this.upsertDailyReports(records);
|
|
return records.length;
|
}
|
|
|
/**
|
* =========================================================
|
* 查询当天有效分拣员
|
* =========================================================
|
*
|
* 状态:
|
*
|
* 0 = 在职
|
* 1 = 离职
|
* 2 = 禁用
|
*
|
* 同时考虑:
|
*
|
* hireDate
|
* leaveDate
|
*/
|
private async getActiveSorters(
|
targetDate: string,
|
): Promise<BasicdataSorterEntity[]> {
|
|
return await this.basicdataSorterEntity
|
.createQueryBuilder('s')
|
|
.where('s.status = :status', {
|
status: 0,
|
})
|
|
.andWhere(
|
'(s.hireDate IS NULL OR s.hireDate <= :targetDate)',
|
{
|
targetDate,
|
}
|
)
|
|
.andWhere(
|
'(s.leaveDate IS NULL OR s.leaveDate >= :targetDate)',
|
{
|
targetDate,
|
}
|
)
|
|
.getMany();
|
}
|
|
|
/**
|
* =========================================================
|
* 考勤统计
|
* =========================================================
|
*/
|
private async aggregateAttendance(
|
targetDate: string,
|
summaryMap: Map<
|
string,
|
Partial<ReportDailySorterEntity>
|
>,
|
) {
|
|
const list =
|
await this.pushSorterAttendanceEntity
|
.createQueryBuilder('a')
|
.where(
|
'a.workDate = :targetDate',
|
{
|
targetDate,
|
}
|
)
|
.getMany();
|
|
|
for (const item of list) {
|
|
const summary =
|
summaryMap.get(item.idCard);
|
|
if (!summary) {
|
continue;
|
}
|
|
|
summary.checkInTime =
|
item.checkInTime || null;
|
|
summary.checkOutTime =
|
item.checkOutTime || null;
|
|
summary.workHours =
|
Number(item.workHours || 0);
|
|
summary.attendanceStatus =
|
item.status || '缺卡';
|
}
|
}
|
|
|
/**
|
* =========================================================
|
* GPS统计
|
* =========================================================
|
*
|
* 统计:
|
*
|
* gpsPointCount
|
* totalKm
|
* minutesMoving
|
*
|
* GPS默认按照上传时间升序排列。
|
*/
|
private async aggregateGps(
|
startTime: string,
|
endTime: string,
|
summaryMap: Map<
|
string,
|
Partial<ReportDailySorterEntity>
|
>,
|
) {
|
|
const list =
|
await this.pushSorterGpsEntity
|
.createQueryBuilder('gps')
|
|
.select([
|
'gps.idCard AS idCard',
|
'gps.uploadTime AS uploadTime',
|
'gps.longitude AS longitude',
|
'gps.latitude AS latitude',
|
'gps.speed AS speed',
|
])
|
|
.where(
|
'gps.uploadTime BETWEEN :startTime AND :endTime',
|
{
|
startTime,
|
endTime,
|
}
|
)
|
|
.orderBy('gps.idCard', 'ASC')
|
.addOrderBy('gps.uploadTime', 'ASC')
|
|
.getRawMany();
|
|
|
// -------------------------------------------------------
|
// 按人员分组
|
// -------------------------------------------------------
|
|
const gpsMap =
|
new Map<string, any[]>();
|
|
|
for (const item of list) {
|
|
if (!item.idCard) {
|
continue;
|
}
|
|
// 只处理当天有效分拣员
|
if (!summaryMap.has(item.idCard)) {
|
continue;
|
}
|
|
|
if (!gpsMap.has(item.idCard)) {
|
|
gpsMap.set(
|
item.idCard,
|
[]
|
);
|
}
|
|
|
gpsMap
|
.get(item.idCard)!
|
.push(item);
|
}
|
|
|
// -------------------------------------------------------
|
// 计算每个人的轨迹
|
// -------------------------------------------------------
|
|
for (const [
|
businessId,
|
points,
|
] of gpsMap.entries()) {
|
|
const summary =
|
summaryMap.get(businessId);
|
|
if (!summary) {
|
continue;
|
}
|
|
|
summary.gpsPointCount =
|
points.length;
|
|
|
let totalMeter = 0;
|
|
let movingSeconds = 0;
|
|
|
for (
|
let i = 1;
|
i < points.length;
|
i++
|
) {
|
|
const prev =
|
points[i - 1];
|
|
const curr =
|
points[i];
|
|
|
const prevTime =
|
new Date(
|
prev.uploadTime
|
).getTime();
|
|
const currTime =
|
new Date(
|
curr.uploadTime
|
).getTime();
|
|
|
const diffSeconds =
|
(currTime - prevTime) / 1000;
|
|
|
// ---------------------------------------------------
|
// 异常GPS时间间隔
|
//
|
// 正常采样约5分钟
|
// 超过10分钟认为这两个点不应该直接连线
|
// ---------------------------------------------------
|
|
if (
|
diffSeconds <= 0 ||
|
diffSeconds > 600
|
) {
|
continue;
|
}
|
|
|
const speed =
|
Number(curr.speed || 0);
|
|
|
// ---------------------------------------------------
|
// speed > 2 km/h
|
// 认为分拣员正在移动
|
// ---------------------------------------------------
|
|
if (speed > 2) {
|
|
movingSeconds +=
|
diffSeconds;
|
|
|
// km/h → m/s → 米
|
const meter =
|
speed *
|
1000 /
|
3600 *
|
diffSeconds;
|
|
|
totalMeter +=
|
meter;
|
}
|
}
|
|
|
summary.totalKm =
|
Number(
|
(
|
totalMeter / 1000
|
).toFixed(2)
|
);
|
|
|
summary.minutesMoving =
|
Math.round(
|
movingSeconds / 60
|
);
|
}
|
}
|
|
|
/**
|
* =========================================================
|
* 健康数据统计
|
* =========================================================
|
*
|
* 不在日报保存:
|
*
|
* 体温
|
* 血压
|
* 血氧
|
* 心率
|
*
|
* 日报只统计:
|
*
|
* 健康数据数量
|
* 健康异常数量
|
*/
|
private async aggregateHealth(
|
startTime: string,
|
endTime: string,
|
summaryMap: Map<
|
string,
|
Partial<ReportDailySorterEntity>
|
>,
|
) {
|
|
const list =
|
await this.pushSorterHealthEntity
|
.createQueryBuilder('h')
|
|
.select([
|
'h.idCard AS idCard',
|
'h.dataType AS dataType',
|
])
|
|
.where(
|
'h.uploadTime BETWEEN :startTime AND :endTime',
|
{
|
startTime,
|
endTime,
|
}
|
)
|
|
.getRawMany();
|
|
|
for (const item of list) {
|
|
const summary =
|
summaryMap.get(item.idCard);
|
|
if (!summary) {
|
continue;
|
}
|
|
|
summary.healthDataCount =
|
Number(
|
summary.healthDataCount || 0
|
) + 1;
|
|
|
if (
|
item.dataType === '异常'
|
) {
|
|
summary.healthAbnormalCount =
|
Number(
|
summary.healthAbnormalCount || 0
|
) + 1;
|
}
|
}
|
}
|
|
|
/**
|
* =========================================================
|
* 健康告警统计
|
* =========================================================
|
*/
|
private async aggregateAlarm(
|
startTime: string,
|
endTime: string,
|
summaryMap: Map<
|
string,
|
Partial<ReportDailySorterEntity>
|
>,
|
) {
|
|
const list =
|
await this.pushSorterAlarmEntity
|
.createQueryBuilder('a')
|
|
.select([
|
'a.idCard AS idCard',
|
'a.alarmLevel AS alarmLevel',
|
'a.status AS status',
|
])
|
|
.where(
|
'a.uploadTime BETWEEN :startTime AND :endTime',
|
{
|
startTime,
|
endTime,
|
}
|
)
|
|
.getRawMany();
|
|
|
for (const item of list) {
|
|
const summary =
|
summaryMap.get(item.idCard);
|
|
if (!summary) {
|
continue;
|
}
|
|
|
// 总告警
|
summary.alarmCount =
|
Number(
|
summary.alarmCount || 0
|
) + 1;
|
|
|
// ---------------------------------------------------
|
// 告警等级
|
// ---------------------------------------------------
|
|
switch (item.alarmLevel) {
|
|
case '一般':
|
|
summary.generalAlarmCount =
|
Number(
|
summary.generalAlarmCount || 0
|
) + 1;
|
|
break;
|
|
|
case '中等':
|
|
summary.mediumAlarmCount =
|
Number(
|
summary.mediumAlarmCount || 0
|
) + 1;
|
|
break;
|
|
|
case '严重':
|
|
summary.seriousAlarmCount =
|
Number(
|
summary.seriousAlarmCount || 0
|
) + 1;
|
|
break;
|
}
|
|
|
// ---------------------------------------------------
|
// status
|
//
|
// 0 = 未处理
|
// 1 = 已处理
|
// ---------------------------------------------------
|
|
if (
|
Number(item.status) === 0
|
) {
|
|
summary.unhandledAlarmCount =
|
Number(
|
summary.unhandledAlarmCount || 0
|
) + 1;
|
}
|
}
|
}
|
|
|
/**
|
* =========================================================
|
* 回收订单统计
|
* =========================================================
|
*
|
* RecycleOrder:
|
*
|
* sorterId
|
* ↓
|
* BasicdataSorter.id
|
* ↓
|
* businessId
|
*
|
* 统计的是:
|
*
|
* 当天完成/处理的回收订单
|
*
|
* 所以使用:
|
*
|
* finishTime
|
*/
|
private async aggregateRecycleOrder(
|
startTime: string,
|
endTime: string,
|
|
sorterList: BasicdataSorterEntity[],
|
|
summaryMap: Map<
|
string,
|
Partial<ReportDailySorterEntity>
|
>,
|
) {
|
|
const list =
|
await this.recycleOrderEntity
|
.createQueryBuilder('o')
|
|
.select([
|
'o.sorterId AS sorterId',
|
'o.orderType AS orderType',
|
'o.status AS status',
|
'o.carbonPoint AS carbonPoint',
|
])
|
|
.where(
|
'o.finishTime BETWEEN :startTime AND :endTime',
|
{
|
startTime,
|
endTime,
|
}
|
)
|
|
.andWhere(
|
'o.sorterId IS NOT NULL'
|
)
|
|
.andWhere(
|
'o.sorterId <> 0'
|
)
|
|
.getRawMany();
|
|
|
// -------------------------------------------------------
|
// 建立 sorterId → businessId 映射
|
// -------------------------------------------------------
|
|
const sorterMap =
|
new Map<number, string>();
|
|
|
for (const sorter of sorterList) {
|
|
sorterMap.set(
|
Number(sorter.id),
|
sorter.businessId
|
);
|
}
|
|
|
// -------------------------------------------------------
|
// 统计订单
|
// -------------------------------------------------------
|
|
for (const order of list) {
|
|
const sorterId =
|
Number(order.sorterId);
|
|
|
const businessId =
|
sorterMap.get(sorterId);
|
|
|
if (!businessId) {
|
continue;
|
}
|
|
|
const summary =
|
summaryMap.get(businessId);
|
|
if (!summary) {
|
continue;
|
}
|
|
|
// 总订单数
|
summary.orderCount =
|
Number(
|
summary.orderCount || 0
|
) + 1;
|
|
|
// 完成订单
|
if (
|
order.status === '完成'
|
) {
|
|
summary.completedOrderCount =
|
Number(
|
summary.completedOrderCount || 0
|
) + 1;
|
}
|
|
|
// 预约上门订单
|
if (
|
order.orderType === '预约'
|
) {
|
|
summary.appointmentOrderCount =
|
Number(
|
summary.appointmentOrderCount || 0
|
) + 1;
|
}
|
|
|
// 发放碳积分
|
summary.totalCarbonPoint =
|
Number(
|
summary.totalCarbonPoint || 0
|
) +
|
Number(
|
order.carbonPoint || 0
|
);
|
}
|
}
|
|
|
/**
|
* =========================================================
|
* 日报 UPSERT
|
* =========================================================
|
*
|
* 唯一键:
|
*
|
* date + businessId
|
*
|
* 因此任务重复执行不会产生重复数据。
|
*/
|
private async upsertDailyReports(
|
records: Partial<ReportDailySorterEntity>[],
|
) {
|
|
if (!records.length) {
|
return;
|
}
|
|
|
await this.reportDailySorterEntity
|
.createQueryBuilder()
|
.insert()
|
.into(ReportDailySorterEntity)
|
.values(records)
|
.orUpdate(
|
[
|
'businessName',
|
'departmentId',
|
'workSiteId',
|
|
'checkInTime',
|
'checkOutTime',
|
'workHours',
|
'attendanceStatus',
|
|
'gpsPointCount',
|
'totalKm',
|
'minutesMoving',
|
|
'orderCount',
|
'completedOrderCount',
|
'appointmentOrderCount',
|
'totalCarbonPoint',
|
|
'healthDataCount',
|
'healthAbnormalCount',
|
|
'alarmCount',
|
'generalAlarmCount',
|
'mediumAlarmCount',
|
'seriousAlarmCount',
|
'unhandledAlarmCount',
|
],
|
[
|
'date',
|
'businessId',
|
],
|
)
|
.execute();
|
}
|
|
}
|