import { Inject, Provide } from '@midwayjs/core';
|
import { BaseService } from '@cool-midway/core';
|
import { InjectEntityModel } from '@midwayjs/typeorm';
|
import { Repository, SelectQueryBuilder } from 'typeorm';
|
import { Context } from '@midwayjs/koa';
|
import * as moment from 'moment';
|
import { ReportDailysiteEntity } from '../entity/dailysite';
|
import { ReportDailyVehicleEntity } from '../entity/dailyvehicle';
|
import { ReportDailySorterEntity } from '../entity/dailysorter';
|
import { BasicdataWastetypeEntity } from '../../basicdata/entity/wastetype';
|
import { BasicdataSorterEntity } from '../../basicdata/entity/sorter';
|
import { BasicdataVehicleEntity } from '../../basicdata/entity/vehicle';
|
import { BasicdataSiteEntity } from '../../basicdata/entity/site';
|
import { RecycleOrderEntity } from '../../shop/entity/recycleorder';
|
import { RecycleAppointmentEntity } from '../../shop/entity/recycleappointment';
|
import { RecycleTransactionEntity } from '../../shop/entity/transaction';
|
import { UserInfoEntity } from '../../user/entity/info';
|
import { PushVehicleGpsEntity } from '../../push/entity/vehiclegps';
|
import { BaseSysParamEntity } from '../../base/entity/sys/param';
|
|
const WASTE_COLORS: Record<string, string> = {
|
SW62: '#16a34a',
|
SW64: '#64748b',
|
SW63: '#f59e0b',
|
SW61: '#ef4444',
|
SW60: '#06b6d4',
|
};
|
|
const ITEM_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#a855f7', '#06b6d4', '#ef4444', '#94a3b8'];
|
|
type OrdersByType = {
|
axis: string[];
|
'现场回收': number[];
|
'预约上门': number[];
|
'大屏投递': number[];
|
'活动回收': number[];
|
};
|
|
function n(v: any, d = 0) {
|
const x = Number(v);
|
return Number.isFinite(x) ? x : d;
|
}
|
|
function round(v: number, d = 2) {
|
const p = 10 ** d;
|
return Math.round(n(v) * p) / p;
|
}
|
|
@Provide()
|
export class ReportScreenService extends BaseService {
|
@Inject()
|
ctx: Context;
|
|
@InjectEntityModel(ReportDailysiteEntity)
|
dailySite: Repository<ReportDailysiteEntity>;
|
|
@InjectEntityModel(ReportDailyVehicleEntity)
|
dailyVehicle: Repository<ReportDailyVehicleEntity>;
|
|
@InjectEntityModel(ReportDailySorterEntity)
|
dailySorter: Repository<ReportDailySorterEntity>;
|
|
@InjectEntityModel(BaseSysParamEntity)
|
paramRepo: Repository<BaseSysParamEntity>;
|
|
@InjectEntityModel(BasicdataWastetypeEntity)
|
wasteTypeRepo: Repository<BasicdataWastetypeEntity>;
|
|
@InjectEntityModel(BasicdataSorterEntity)
|
sorterRepo: Repository<BasicdataSorterEntity>;
|
|
@InjectEntityModel(BasicdataVehicleEntity)
|
vehicleRepo: Repository<BasicdataVehicleEntity>;
|
|
@InjectEntityModel(BasicdataSiteEntity)
|
siteRepo: Repository<BasicdataSiteEntity>;
|
|
@InjectEntityModel(RecycleOrderEntity)
|
orderRepo: Repository<RecycleOrderEntity>;
|
|
@InjectEntityModel(RecycleAppointmentEntity)
|
appointmentRepo: Repository<RecycleAppointmentEntity>;
|
|
@InjectEntityModel(RecycleTransactionEntity)
|
transRepo: Repository<RecycleTransactionEntity>;
|
|
@InjectEntityModel(UserInfoEntity)
|
userRepo: Repository<UserInfoEntity>;
|
|
@InjectEntityModel(PushVehicleGpsEntity)
|
vehicleGpsRepo: Repository<PushVehicleGpsEntity>;
|
|
async query(range: string) {
|
const unavailable: string[] = [];
|
const dates = this.getRange(range || 'month');
|
const today = moment().format('YYYY-MM-DD');
|
const yearStart = moment().startOf('year').format('YYYY-MM-DD');
|
const yearEnd = moment().format('YYYY-MM-DD');
|
|
const deptIds = this.deptIds();
|
if (deptIds && deptIds.length === 0) {
|
unavailable.push('当前账号没有部门数据权限');
|
}
|
|
const safe = async <T>(label: string, fn: () => Promise<T>, fallback: T): Promise<T> => {
|
try {
|
return await fn();
|
} catch (e: any) {
|
unavailable.push(`${label}:${e?.message || '查询失败'}`);
|
return fallback;
|
}
|
};
|
|
const emptySum = {};
|
const [period, todayRow, totalRow, yearRow] = await Promise.all([
|
safe('区间站点日汇总', () => this.sumSite(dates.start, dates.end), emptySum),
|
safe('今日站点日汇总', () => this.sumSite(today, today), emptySum),
|
safe('累计站点日汇总', () => this.sumSite('1970-01-01', today), emptySum),
|
safe('年度站点日汇总', () => this.sumSite(yearStart, yearEnd), emptySum),
|
]);
|
|
const wasteNames = await safe('垃圾种类名称', () => this.wasteNameMap(), new Map<string, string>());
|
const wasteMix = this.buildWasteMix(period, wasteNames);
|
const recycleItems = this.buildRecycleItems(period, wasteNames);
|
const { axis, trend } = await safe(
|
'回收趋势',
|
() => this.buildTrend(range || 'month', dates),
|
{ axis: [], trend: { weight: [], value: [], carbon: [] } }
|
);
|
const yearCarbon = await safe('年度碳减排', () => this.buildYearCarbon(), Array.from({ length: 12 }, () => 0));
|
const ranks = await safe(
|
'班级/专业/院系排行',
|
() => this.buildOrgRanks(dates.startTime, dates.endTime, unavailable),
|
{ classRank: [], majorRank: [], collegeRank: [] }
|
);
|
const users = await safe(
|
'师生参与与碳积分',
|
() => this.buildUserPoints(range || 'month', dates, axis, unavailable),
|
{
|
totalParticipants: 0,
|
newParticipants: 0,
|
participateRate: 0,
|
greenActions: 0,
|
issued: 0,
|
consumed: 0,
|
transfer: 0,
|
donate: 0,
|
mall: 0,
|
balance: 0,
|
participateTrend: axis.map(() => 0),
|
deliverTrend: axis.map(() => 0),
|
}
|
);
|
const ops = await safe('运营监控', () => this.buildOperation(today, dates, unavailable), {
|
sorterOnline: 0,
|
sorterTotal: 0,
|
vehicleOnline: 0,
|
vehicleTotal: 0,
|
siteOnline: 0,
|
siteTotal: 0,
|
todayOrders: 0,
|
todayAppointments: 0,
|
pendingAppointments: 0,
|
alerts: 0,
|
healthAlerts: 0,
|
ordersByType: {
|
axis: [],
|
'现场回收': [],
|
'预约上门': [],
|
'大屏投递': [],
|
'活动回收': [],
|
} as OrdersByType,
|
appointmentFlow: [],
|
sorterStatus: [],
|
vehicles: [],
|
sites: [],
|
});
|
|
const weightKg = n(period.weightTotal);
|
const recyclableKg = n(period.weightSw62);
|
const carbonKg = n(period.carbonTotal);
|
const totalCarbonKg = n(totalRow.carbonTotal);
|
|
return {
|
range: range || 'month',
|
axis,
|
kpis: {
|
todayWeightKg: round(n(todayRow.weightTotal), 1),
|
periodWeightTon: round(weightKg / 1000, 2),
|
resourceRate: weightKg > 0 ? round((recyclableKg / weightKg) * 100, 1) : 0,
|
periodValue: round(n(period.estimatedSalesSw62), 0),
|
periodCarbonTon: round(carbonKg / 1000, 2),
|
totalWeightTon: round(n(totalRow.weightTotal) / 1000, 1),
|
totalParticipants: users.totalParticipants,
|
totalPoints: users.balance,
|
recyclableTon: round(recyclableKg / 1000, 2),
|
bulkTon: round(n(period.weightSw63) / 1000, 2),
|
elecTon: round(n(period.weightSw62006) / 1000, 2),
|
fabricTon: round(n(period.weightSw62004) / 1000, 2),
|
yearCarbonTon: round(n(yearRow.carbonTotal) / 1000, 1),
|
monthCarbonTon: round(carbonKg / 1000, 2),
|
totalCarbonTon: round(totalCarbonKg / 1000, 1),
|
greenActions: users.greenActions,
|
newParticipants: users.newParticipants,
|
participateRate: users.participateRate,
|
pointsIssued: users.issued,
|
pointsConsumed: users.consumed,
|
pointsBalance: users.balance,
|
sorterOnline: ops.sorterOnline,
|
sorterTotal: ops.sorterTotal,
|
vehicleOnline: ops.vehicleOnline,
|
vehicleTotal: ops.vehicleTotal,
|
siteOnline: ops.siteOnline,
|
siteTotal: ops.siteTotal,
|
todayOrders: ops.todayOrders,
|
todayAppointments: ops.todayAppointments,
|
pendingAppointments: ops.pendingAppointments,
|
alerts: ops.alerts,
|
healthAlerts: ops.healthAlerts,
|
},
|
wasteMix,
|
recycleItems,
|
trend,
|
yearCarbon,
|
deptRank: ranks.collegeRank,
|
classRank: ranks.classRank,
|
majorRank: ranks.majorRank,
|
pointFlow: {
|
issued: users.issued,
|
transfer: users.transfer,
|
donate: users.donate,
|
mall: users.mall,
|
balance: users.balance,
|
},
|
participateTrend: users.participateTrend,
|
deliverTrend: users.deliverTrend,
|
ordersByType: ops.ordersByType,
|
appointmentFlow: ops.appointmentFlow,
|
sorterStatus: ops.sorterStatus,
|
vehicles: ops.vehicles,
|
sites: ops.sites,
|
unavailable,
|
};
|
}
|
|
private deptIds(): number[] | null {
|
const admin = this.ctx.admin;
|
if (!admin || admin.username === 'admin') return null;
|
return Array.isArray(admin.departmentIds) ? admin.departmentIds.map(Number) : [];
|
}
|
|
private applyDept(qb: SelectQueryBuilder<any>, alias: string) {
|
const ids = this.deptIds();
|
if (ids === null) return qb;
|
if (!ids.length) {
|
qb.andWhere('1 = 0');
|
return qb;
|
}
|
qb.andWhere(`${alias}.departmentId IN (:...deptIds)`, { deptIds: ids });
|
return qb;
|
}
|
|
/** 编制人数:base_sys_param.keyName = student.count.{departmentId} */
|
private async studentCount(unavailable: string[]) {
|
const ids = this.deptIds();
|
if (ids !== null && ids.length === 0) return 0;
|
const keys =
|
ids === null
|
? (
|
await this.paramRepo
|
.createQueryBuilder('p')
|
.select('p.keyName', 'keyName')
|
.addSelect('p.data', 'data')
|
.where("p.keyName LIKE :prefix", { prefix: 'student.count.%' })
|
.getRawMany()
|
).map(r => ({ key: r.keyName, data: r.data }))
|
: await Promise.all(
|
ids.map(async id => {
|
const row = await this.paramRepo.findOneBy({ keyName: `student.count.${id}` });
|
return { key: `student.count.${id}`, data: row?.data };
|
})
|
);
|
|
const missing = keys.filter(e => e.data === undefined || e.data === null || e.data === '');
|
const total = keys.reduce((s, e) => s + n(e.data), 0);
|
if (!keys.length || missing.length === keys.length) {
|
unavailable.push('未配置编制人数参数 student.count.{departmentId},参与率暂为 0');
|
return 0;
|
}
|
if (missing.length) {
|
unavailable.push(`缺少编制人数参数:${missing.map(e => e.key).join('、')}`);
|
}
|
return total;
|
}
|
|
private getRange(range: string) {
|
const now = moment();
|
let startM = now.clone().startOf('month');
|
if (range === 'today') startM = now.clone().startOf('day');
|
else if (range === 'week') startM = now.clone().startOf('isoWeek');
|
else if (range === 'quarter') startM = now.clone().startOf('quarter' as any);
|
else if (range === 'year') startM = now.clone().startOf('year');
|
|
return {
|
start: startM.format('YYYY-MM-DD'),
|
end: now.format('YYYY-MM-DD'),
|
startTime: startM.format('YYYY-MM-DD HH:mm:ss'),
|
endTime: now.clone().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
};
|
}
|
|
private async sumSite(start: string, end: string) {
|
const qb = this.dailySite
|
.createQueryBuilder('a')
|
.select('SUM(a.weightTotal)', 'weightTotal')
|
.addSelect('SUM(a.weightSw60)', 'weightSw60')
|
.addSelect('SUM(a.weightSw61)', 'weightSw61')
|
.addSelect('SUM(a.weightSw62)', 'weightSw62')
|
.addSelect('SUM(a.weightSw63)', 'weightSw63')
|
.addSelect('SUM(a.weightSw64)', 'weightSw64')
|
.addSelect('SUM(a.timeTotal)', 'timeTotal')
|
.addSelect('SUM(a.timeSw60)', 'timeSw60')
|
.addSelect('SUM(a.timeSw61)', 'timeSw61')
|
.addSelect('SUM(a.timeSw62)', 'timeSw62')
|
.addSelect('SUM(a.timeSw63)', 'timeSw63')
|
.addSelect('SUM(a.timeSw64)', 'timeSw64')
|
.addSelect('SUM(a.carbonTotal)', 'carbonTotal')
|
.addSelect('SUM(a.estimatedSalesSw62)', 'estimatedSalesSw62');
|
|
for (const i of ['001', '002', '003', '004', '005', '006', '007']) {
|
qb.addSelect(`SUM(a.weightSw62${i})`, `weightSw62${i}`);
|
qb.addSelect(`SUM(a.timeSw62${i})`, `timeSw62${i}`);
|
qb.addSelect(`SUM(a.carbonSw62${i})`, `carbonSw62${i}`);
|
qb.addSelect(`SUM(a.estimatedSalesSw62${i})`, `estimatedSalesSw62${i}`);
|
}
|
|
qb.where('a.date BETWEEN :start AND :end', { start, end });
|
this.applyDept(qb, 'a');
|
return (await qb.getRawOne()) || {};
|
}
|
|
private async wasteNameMap() {
|
const list = await this.wasteTypeRepo.find({ select: ['code', 'name'] });
|
const map = new Map<string, string>();
|
list.forEach(e => map.set(String(e.code).toUpperCase(), e.name));
|
return map;
|
}
|
|
private buildWasteMix(row: any, names: Map<string, string>) {
|
const items = [
|
{ code: 'SW62', key: 'Sw62' },
|
{ code: 'SW64', key: 'Sw64' },
|
{ code: 'SW63', key: 'Sw63' },
|
{ code: 'SW61', key: 'Sw61' },
|
{ code: 'SW60', key: 'Sw60' },
|
];
|
return items.map(e => ({
|
name: names.get(e.code) || this.defaultWasteName(e.code),
|
code: e.code,
|
weight: round(n(row[`weight${e.key}`]), 1),
|
value: e.code === 'SW62' ? round(n(row.estimatedSalesSw62), 0) : 0,
|
count: round(n(row[`time${e.key}`]), 0),
|
color: WASTE_COLORS[e.code],
|
}));
|
}
|
|
private defaultWasteName(code: string) {
|
return (
|
{
|
SW60: '湿垃圾',
|
SW61: '有害垃圾',
|
SW62: '可回收物',
|
SW63: '大件垃圾',
|
SW64: '其他垃圾',
|
}[code] || code
|
);
|
}
|
|
private buildRecycleItems(row: any, names: Map<string, string>) {
|
const codes = [
|
'900-001-S62',
|
'900-002-S62',
|
'900-003-S62',
|
'900-004-S62',
|
'900-005-S62',
|
'900-006-S62',
|
'900-007-S62',
|
];
|
const fallback = ['废纸', '塑料', '金属', '织物', '玻璃', '电子产品', '其他可回收'];
|
return codes.map((code, i) => {
|
const idx = String(i + 1).padStart(3, '0');
|
return {
|
name: names.get(code.toUpperCase()) || names.get(code) || fallback[i],
|
code,
|
weight: round(n(row[`weightSw62${idx}`]), 1),
|
value: round(n(row[`estimatedSalesSw62${idx}`]), 0),
|
carbon: round(n(row[`carbonSw62${idx}`]), 1),
|
color: ITEM_COLORS[i],
|
};
|
});
|
}
|
|
private async buildTrend(range: string, dates: { start: string; end: string; startTime: string; endTime: string }) {
|
const trendStart =
|
range === 'today' ? moment().subtract(6, 'day').format('YYYY-MM-DD') : dates.start;
|
const trendEnd = dates.end;
|
|
const qb = this.dailySite
|
.createQueryBuilder('a')
|
.select('a.date', 'date')
|
.addSelect('SUM(a.weightTotal)', 'weight')
|
.addSelect('SUM(a.estimatedSalesSw62)', 'value')
|
.addSelect('SUM(a.carbonTotal)', 'carbon')
|
.where('a.date BETWEEN :start AND :end', { start: trendStart, end: trendEnd })
|
.groupBy('a.date')
|
.orderBy('a.date', 'ASC');
|
this.applyDept(qb, 'a');
|
const rows = await qb.getRawMany();
|
const map = new Map(
|
rows.map(r => [
|
moment(r.date).format('YYYY-MM-DD'),
|
{ weight: n(r.weight), value: n(r.value), carbon: n(r.carbon) },
|
])
|
);
|
|
const axis: string[] = [];
|
const weight: number[] = [];
|
const value: number[] = [];
|
const carbon: number[] = [];
|
|
if (range === 'year') {
|
for (let m = 1; m <= 12; m++) {
|
axis.push(`${m}月`);
|
const prefix = `${moment().year()}-${String(m).padStart(2, '0')}`;
|
let w = 0,
|
v = 0,
|
c = 0;
|
map.forEach((item, key) => {
|
if (key.startsWith(prefix)) {
|
w += item.weight;
|
v += item.value;
|
c += item.carbon;
|
}
|
});
|
weight.push(round(w, 1));
|
value.push(round(v, 0));
|
carbon.push(round(c, 1));
|
}
|
} else {
|
const cursor = moment(trendStart);
|
const end = moment(trendEnd);
|
while (cursor.isSameOrBefore(end, 'day')) {
|
const key = cursor.format('YYYY-MM-DD');
|
axis.push(cursor.format('MM-DD'));
|
const item = map.get(key) || { weight: 0, value: 0, carbon: 0 };
|
weight.push(round(item.weight, 1));
|
value.push(round(item.value, 0));
|
carbon.push(round(item.carbon, 1));
|
cursor.add(1, 'day');
|
}
|
}
|
|
return { axis, trend: { weight, value, carbon } };
|
}
|
|
private async buildYearCarbon() {
|
const start = moment().startOf('year').format('YYYY-MM-DD');
|
const end = moment().endOf('year').format('YYYY-MM-DD');
|
const qb = this.dailySite
|
.createQueryBuilder('a')
|
.select('MONTH(a.date)', 'm')
|
.addSelect('SUM(a.carbonTotal)', 'carbon')
|
.where('a.date BETWEEN :start AND :end', { start, end })
|
.groupBy('MONTH(a.date)');
|
this.applyDept(qb, 'a');
|
const rows = await qb.getRawMany();
|
const map = new Map(rows.map(r => [Number(r.m), n(r.carbon)]));
|
return Array.from({ length: 12 }, (_, i) => round((map.get(i + 1) || 0) / 1000, 3));
|
}
|
|
private async buildOrgRanks(startTime: string, endTime: string, unavailable: string[]) {
|
const empty = { classRank: [], majorRank: [], collegeRank: [] };
|
try {
|
const joinOrder = () => {
|
const qb = this.orderRepo
|
.createQueryBuilder('o')
|
.innerJoin(UserInfoEntity, 'u', 'o.campusUserId = u.unionid')
|
.where('o.status = :status', { status: '已完成' })
|
.andWhere('o.finishTime BETWEEN :start AND :end', { start: startTime, end: endTime });
|
this.applyDept(qb, 'o');
|
return qb;
|
};
|
|
const metrics = (qb: any) =>
|
qb
|
.addSelect('COUNT(DISTINCT o.campusUserId)', 'people')
|
.addSelect('SUM(o.totalWeight)', 'weight')
|
.addSelect('SUM(o.totalCarbonPoint)', 'points');
|
|
const classQb = metrics(
|
joinOrder()
|
.select('u.className', 'className')
|
.addSelect('u.collegeName', 'collegeName')
|
.andWhere("u.className IS NOT NULL AND u.className <> ''")
|
.groupBy('u.classId')
|
.addGroupBy('u.className')
|
.addGroupBy('u.collegeName')
|
.orderBy('people', 'DESC')
|
.limit(10)
|
);
|
|
const majorQb = metrics(
|
joinOrder()
|
.select('u.majorName', 'name')
|
.andWhere("u.majorName IS NOT NULL AND u.majorName <> ''")
|
.groupBy('u.majorId')
|
.addGroupBy('u.majorName')
|
.orderBy('people', 'DESC')
|
.limit(10)
|
);
|
|
const collegeQb = metrics(
|
joinOrder()
|
.select('u.collegeName', 'name')
|
.andWhere("u.collegeName IS NOT NULL AND u.collegeName <> ''")
|
.groupBy('u.collegeName')
|
.orderBy('people', 'DESC')
|
.limit(10)
|
);
|
|
const [classRows, majorRows, collegeRows] = await Promise.all([
|
classQb.getRawMany(),
|
majorQb.getRawMany(),
|
collegeQb.getRawMany(),
|
]);
|
|
if (!classRows.length) {
|
unavailable.push('班级排行暂无数据(className 为空或订单未关联用户)');
|
}
|
if (!majorRows.length) {
|
unavailable.push('专业排行暂无数据(majorName 为空或订单未关联用户)');
|
}
|
if (!collegeRows.length) {
|
unavailable.push('院系排行暂无数据(collegeName 为空或订单未关联用户)');
|
}
|
|
const toRank = (name: string, r: any, org?: string) => {
|
const people = n(r.people) || 1;
|
const weight = n(r.weight);
|
const points = n(r.points);
|
return {
|
name,
|
org,
|
weight: round(weight, 1),
|
people: n(r.people),
|
rate: 0,
|
points: round(points, 0),
|
carbon: round(weight, 1),
|
size: n(r.people),
|
weightPerCapita: round(weight / people, 2),
|
pointPerCapita: round(points / people, 1),
|
};
|
};
|
|
return {
|
classRank: classRows.map(r => toRank(r.className, r, r.collegeName)),
|
majorRank: majorRows.map(r => toRank(r.name, r)),
|
collegeRank: collegeRows.map(r => toRank(r.name, r)),
|
};
|
} catch (e) {
|
unavailable.push('师生组织排行查询失败,请检查回收订单与校园用户表');
|
return empty;
|
}
|
}
|
|
private async buildUserPoints(
|
range: string,
|
dates: { start: string; end: string; startTime: string; endTime: string },
|
axis: string[],
|
unavailable: string[]
|
) {
|
const result = {
|
totalParticipants: 0,
|
newParticipants: 0,
|
participateRate: 0,
|
greenActions: 0,
|
issued: 0,
|
consumed: 0,
|
transfer: 0,
|
donate: 0,
|
mall: 0,
|
balance: 0,
|
participateTrend: [] as number[],
|
deliverTrend: [] as number[],
|
};
|
|
try {
|
const partQb = this.orderRepo
|
.createQueryBuilder('o')
|
.select('COUNT(DISTINCT o.campusUserId)', 'c')
|
.where('o.status = :status', { status: '已完成' });
|
this.applyDept(partQb, 'o');
|
result.totalParticipants = n((await partQb.getRawOne())?.c);
|
|
const newQb = partQb.clone().andWhere('o.finishTime BETWEEN :start AND :end', {
|
start: dates.startTime,
|
end: dates.endTime,
|
});
|
result.newParticipants = n((await newQb.getRawOne())?.c);
|
|
const userTotal = await this.studentCount(unavailable);
|
result.participateRate = userTotal > 0 ? round((result.totalParticipants / userTotal) * 100, 1) : 0;
|
|
const actQb = this.orderRepo
|
.createQueryBuilder('o')
|
.select('COUNT(o.id)', 'c')
|
.where('o.status = :status', { status: '已完成' });
|
this.applyDept(actQb, 'o');
|
result.greenActions = n((await actQb.getRawOne())?.c);
|
|
const transQb = this.transRepo
|
.createQueryBuilder('t')
|
.select('t.sourceType', 'sourceType')
|
.addSelect('t.type', 'type')
|
.addSelect('SUM(t.amount)', 'amount')
|
.where('t.createTime BETWEEN :start AND :end', { start: dates.startTime, end: dates.endTime })
|
.groupBy('t.sourceType')
|
.addGroupBy('t.type');
|
this.applyDept(transQb, 't');
|
const transRows = await transQb.getRawMany();
|
transRows.forEach(r => {
|
const amount = n(r.amount);
|
if (r.sourceType === '回收' || r.sourceType === '签到') result.issued += amount;
|
if (r.sourceType === '商城') result.mall += amount;
|
if (r.sourceType === '赠送') result.transfer += amount;
|
if (r.sourceType === '捐赠') result.donate += amount;
|
if (r.type === '支出') result.consumed += amount;
|
});
|
|
const balQb = this.userRepo.createQueryBuilder('u').select('SUM(u.carbonBalance)', 'b');
|
this.applyDept(balQb, 'u');
|
result.balance = round(n((await balQb.getRawOne())?.b), 0);
|
|
const trendStart =
|
range === 'today'
|
? moment().subtract(6, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss')
|
: dates.startTime;
|
const trendQb = this.orderRepo
|
.createQueryBuilder('o')
|
.select(range === 'year' ? 'MONTH(o.finishTime)' : 'DATE(o.finishTime)', 'd')
|
.addSelect('COUNT(DISTINCT o.campusUserId)', 'people')
|
.addSelect('COUNT(o.id)', 'times')
|
.where('o.status = :status', { status: '已完成' })
|
.andWhere('o.finishTime BETWEEN :start AND :end', {
|
start: trendStart,
|
end: dates.endTime,
|
})
|
.groupBy(range === 'year' ? 'MONTH(o.finishTime)' : 'DATE(o.finishTime)')
|
.orderBy('d', 'ASC');
|
this.applyDept(trendQb, 'o');
|
const trendRows = await trendQb.getRawMany();
|
const peopleMap = new Map<string, number>();
|
const timesMap = new Map<string, number>();
|
trendRows.forEach(r => {
|
const key = range === 'year' ? `${Number(r.d)}月` : moment(r.d).format('MM-DD');
|
peopleMap.set(key, n(r.people));
|
timesMap.set(key, n(r.times));
|
});
|
result.participateTrend = axis.map(k => peopleMap.get(k) || 0);
|
result.deliverTrend = axis.map(k => timesMap.get(k) || 0);
|
} catch (e) {
|
unavailable.push('师生参与 / 碳积分无法从用户、订单、流水表汇总');
|
}
|
|
return result;
|
}
|
|
private async buildOperation(today: string, _dates: any, unavailable: string[]) {
|
const start15 = moment().subtract(15, 'minute').format('YYYY-MM-DD HH:mm:ss');
|
const todayStart = moment().startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
const todayEnd = moment().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
|
const sorterQb = this.sorterRepo.createQueryBuilder('a').select('COUNT(a.id)', 'c');
|
this.applyDept(sorterQb, 'a');
|
const sorterTotal = n((await sorterQb.getRawOne())?.c);
|
|
const dsQb = this.dailySorter.createQueryBuilder('a').where('a.date = :today', { today });
|
this.applyDept(dsQb, 'a');
|
const dsRows = await dsQb.getMany();
|
if (!dsRows.length) {
|
unavailable.push('分拣员日表暂无当日数据,状态按离线统计');
|
}
|
|
let working = 0;
|
let resting = 0;
|
let abnormal = 0;
|
let offlineFromTable = 0;
|
let alerts = 0;
|
let healthAlerts = 0;
|
dsRows.forEach(r => {
|
alerts += n(r.unhandledAlarmCount);
|
healthAlerts += n(r.healthAbnormalCount);
|
if (n(r.healthAbnormalCount) > 0 || n(r.unhandledAlarmCount) > 0) {
|
abnormal += 1;
|
} else if (r.checkInTime && !r.checkOutTime) {
|
working += 1;
|
} else if (r.checkInTime && r.checkOutTime) {
|
resting += 1;
|
} else {
|
offlineFromTable += 1;
|
}
|
});
|
const sorterOnline = working;
|
const offline = offlineFromTable + Math.max(sorterTotal - dsRows.length, 0);
|
const sorterStatus = [
|
{ name: '工作中', value: working, color: '#22c55e' },
|
{ name: '休息', value: resting, color: '#f59e0b' },
|
{ name: '异常', value: abnormal, color: '#ef4444' },
|
{ name: '离线', value: offline, color: '#94a3b8' },
|
];
|
|
const vehicleQb = this.vehicleRepo.createQueryBuilder('a').select('COUNT(a.id)', 'c');
|
this.applyDept(vehicleQb, 'a');
|
const vehicleTotal = n((await vehicleQb.getRawOne())?.c);
|
|
const vGpsQb = this.vehicleGpsRepo
|
.createQueryBuilder('a')
|
.select('COUNT(DISTINCT a.vehicleNo)', 'c')
|
.where('a.gpsTime >= :start15', { start15 });
|
this.applyDept(vGpsQb, 'a');
|
const vehicleOnline = n((await vGpsQb.getRawOne())?.c);
|
|
const siteQb = this.siteRepo
|
.createQueryBuilder('a')
|
.select('COUNT(a.id)', 'c')
|
.where("a.siteType = '收集点'");
|
this.applyDept(siteQb, 'a');
|
const siteTotal = n((await siteQb.getRawOne())?.c);
|
const siteOnlineQb = siteQb.clone().andWhere('a.status = :st', { st: 1 });
|
const siteOnline = n((await siteOnlineQb.getRawOne())?.c);
|
|
const orderQb = this.orderRepo
|
.createQueryBuilder('o')
|
.select('COUNT(o.id)', 'c')
|
.where('o.createTime BETWEEN :start AND :end', { start: todayStart, end: todayEnd });
|
this.applyDept(orderQb, 'o');
|
const todayOrders = n((await orderQb.getRawOne())?.c);
|
|
const apptQb = this.appointmentRepo
|
.createQueryBuilder('a')
|
.select('a.status', 'status')
|
.addSelect('COUNT(a.id)', 'c')
|
.where('a.appointmentDate = :today', { today })
|
.groupBy('a.status');
|
this.applyDept(apptQb, 'a');
|
const apptRows = await apptQb.getRawMany();
|
const apptMap = new Map(apptRows.map(r => [r.status, n(r.c)]));
|
const todayAppointments = apptRows.reduce((s, r) => s + n(r.c), 0);
|
const pendingAppointments =
|
n(apptMap.get('新增')) +
|
n(apptMap.get('待审核')) +
|
n(apptMap.get('待接单')) +
|
n(apptMap.get('已确认'));
|
|
const appointmentFlow = [
|
{ name: '预约', value: n(apptMap.get('新增')) + n(apptMap.get('待审核')) },
|
{ name: '接单', value: n(apptMap.get('已确认')) + n(apptMap.get('已接单')) },
|
{ name: '完成', value: n(apptMap.get('已完成')) },
|
];
|
|
const typeQb = this.orderRepo
|
.createQueryBuilder('o')
|
.select('DATE(o.createTime)', 'd')
|
.addSelect('o.orderType', 'orderType')
|
.addSelect('COUNT(o.id)', 'c')
|
.where('o.createTime BETWEEN :start AND :end', {
|
start: moment().subtract(6, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
end: todayEnd,
|
})
|
.groupBy('DATE(o.createTime)')
|
.addGroupBy('o.orderType');
|
this.applyDept(typeQb, 'o');
|
const typeRows = await typeQb.getRawMany();
|
const days = Array.from({ length: 7 }, (_, i) => moment().subtract(6 - i, 'day').format('YYYY-MM-DD'));
|
const typeMap: Omit<OrdersByType, 'axis'> = {
|
'现场回收': days.map(() => 0),
|
'预约上门': days.map(() => 0),
|
'大屏投递': days.map(() => 0),
|
'活动回收': days.map(() => 0),
|
};
|
const typeAlias: Record<string, keyof Omit<OrdersByType, 'axis'>> = {
|
'现场': '现场回收',
|
'预约': '预约上门',
|
'大屏': '大屏投递',
|
'活动': '活动回收',
|
};
|
typeRows.forEach(r => {
|
const key = typeAlias[r.orderType];
|
const idx = days.indexOf(moment(r.d).format('YYYY-MM-DD'));
|
if (idx >= 0 && key) typeMap[key][idx] = n(r.c);
|
});
|
|
const vQb = this.dailyVehicle
|
.createQueryBuilder('a')
|
.select('a.vehicleNo', 'vehicleNo')
|
.addSelect('a.totalKm', 'km')
|
.addSelect('a.minutesAbnormalIdle', 'idle')
|
.addSelect('a.overspeedCount', 'overspeed')
|
.where('a.date = :today', { today })
|
.orderBy('a.totalKm', 'DESC')
|
.limit(8);
|
this.applyDept(vQb, 'a');
|
const vRows = await vQb.getRawMany();
|
const onlineSet = new Set(
|
(
|
await this.vehicleGpsRepo
|
.createQueryBuilder('a')
|
.select('DISTINCT a.vehicleNo', 'vehicleNo')
|
.where('a.gpsTime >= :start15', { start15 })
|
.getRawMany()
|
).map(r => r.vehicleNo)
|
);
|
const vehicles = vRows.map(r => ({
|
no: r.vehicleNo,
|
km: round(n(r.km), 1),
|
idle: n(r.idle),
|
overspeed: n(r.overspeed),
|
status: onlineSet.has(r.vehicleNo) ? '在线' : '离线',
|
}));
|
|
const sQb = this.dailySite
|
.createQueryBuilder('a')
|
.select('a.siteName', 'name')
|
.addSelect('SUM(a.weightTotal)', 'weight')
|
.where('a.date = :today', { today })
|
.groupBy('a.siteName')
|
.orderBy('weight', 'DESC')
|
.limit(8);
|
this.applyDept(sQb, 'a');
|
const sRows = await sQb.getRawMany();
|
const sites = sRows.map(r => ({
|
name: r.name,
|
weight: round(n(r.weight), 1),
|
online: true,
|
}));
|
|
return {
|
sorterOnline,
|
sorterTotal,
|
vehicleOnline,
|
vehicleTotal,
|
siteOnline,
|
siteTotal,
|
todayOrders,
|
todayAppointments,
|
pendingAppointments,
|
alerts,
|
healthAlerts,
|
ordersByType: {
|
axis: days.map(d => d.slice(5)),
|
...typeMap,
|
} as OrdersByType,
|
appointmentFlow,
|
sorterStatus,
|
vehicles,
|
sites,
|
};
|
}
|
}
|