| | |
| | | * 本地开发 npm run dev 读取的配置文件 |
| | | */ |
| | | export default { |
| | | koa: { |
| | | port: 8001, |
| | | hostname: '0.0.0.0', |
| | | }, |
| | | typeorm: { |
| | | dataSource: { |
| | | default: { |
| | |
| | | "remark": "商城碳积分支付:所需积分 = 订单金额 * carbonratio,默认 100" |
| | | }, |
| | | { |
| | | "keyName": "hitup.score", |
| | | "name": "打卡碳积分", |
| | | "data": "1", |
| | | "dataType": 0, |
| | | "remark": "分拣员给学生/老师打卡时,每次增加的碳积分" |
| | | }, |
| | | { |
| | | "keyName": "hitup.maxmium.perday", |
| | | "name": "打卡每日上限", |
| | | "data": "1", |
| | | "dataType": 0, |
| | | "remark": "同一用户同一天最多打卡次数,超过则提示异常" |
| | | }, |
| | | { |
| | | "keyName": "userAgreement", |
| | | "name": "用户协议", |
| | | "data": "<h3 style=\"text-align: center;\"><strong>用户协议</strong></h3><p><br></p><p>xxxxxx</p>", |
| New file |
| | |
| | | import { |
| | | CoolController, |
| | | BaseController, |
| | | CoolUrlTag, |
| | | TagTypes, |
| | | CoolTag, |
| | | } from '@cool-midway/core'; |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { BasicdataOptionsService } from '../../service/options'; |
| | | |
| | | /** |
| | | * 分拣员基础数据下拉 |
| | | */ |
| | | @CoolUrlTag() |
| | | @CoolController() |
| | | export class AppBasicdataOptionsController extends BaseController { |
| | | @Inject() |
| | | basicdataOptionsService: BasicdataOptionsService; |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/sites', { summary: '站点列表' }) |
| | | async sites( |
| | | @Body('sorterId') sorterId: string, |
| | | @Body('siteType') siteType: string |
| | | ) { |
| | | return this.ok(await this.basicdataOptionsService.sites(sorterId, siteType)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/wastetypes', { summary: '可回收垃圾种类' }) |
| | | async wastetypes(@Body('sorterId') sorterId: string) { |
| | | return this.ok(await this.basicdataOptionsService.wastetypes(sorterId)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/buyers', { summary: '收购方列表' }) |
| | | async buyers(@Body('sorterId') sorterId: string) { |
| | | return this.ok(await this.basicdataOptionsService.buyers(sorterId)); |
| | | } |
| | | } |
| New file |
| | |
| | | import { Provide } from '@midwayjs/core'; |
| | | import { BaseService, CoolCommException } from '@cool-midway/core'; |
| | | import { InjectEntityModel } from '@midwayjs/typeorm'; |
| | | import { Repository } from 'typeorm'; |
| | | import { BasicdataSorterEntity } from '../entity/sorter'; |
| | | import { BasicdataSiteEntity } from '../entity/site'; |
| | | import { BasicdataWastetypeEntity } from '../entity/wastetype'; |
| | | import { BasicdataBuyerEntity } from '../entity/buyer'; |
| | | |
| | | /** |
| | | * 分拣员下拉选项 |
| | | */ |
| | | @Provide() |
| | | export class BasicdataOptionsService extends BaseService { |
| | | @InjectEntityModel(BasicdataSorterEntity) |
| | | basicdataSorterEntity: Repository<BasicdataSorterEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSiteEntity) |
| | | basicdataSiteEntity: Repository<BasicdataSiteEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataWastetypeEntity) |
| | | basicdataWastetypeEntity: Repository<BasicdataWastetypeEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataBuyerEntity) |
| | | basicdataBuyerEntity: Repository<BasicdataBuyerEntity>; |
| | | |
| | | async getSorter(sorterId: string) { |
| | | const sorter = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: String(sorterId || '').trim(), |
| | | }); |
| | | if (!sorter || sorter.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | return sorter; |
| | | } |
| | | |
| | | async sites(sorterId: string, siteType?: string) { |
| | | const sorter = await this.getSorter(sorterId); |
| | | const find = this.basicdataSiteEntity |
| | | .createQueryBuilder('a') |
| | | .where('a.status = :st', { st: 1 }); |
| | | if (siteType) { |
| | | find.andWhere('a.siteType = :siteType', { siteType }); |
| | | } |
| | | if (sorter.departmentId) { |
| | | find.andWhere('CAST(a.departmentId AS CHAR) = :deptId', { |
| | | deptId: String(sorter.departmentId), |
| | | }); |
| | | } |
| | | find.orderBy('a.id', 'ASC'); |
| | | const list = await find.getMany(); |
| | | // 收集点为可选项:本部门没有时返回全部收集点,避免下拉空白 |
| | | if (!list.length && siteType === '收集点') { |
| | | return this.basicdataSiteEntity.find({ |
| | | where: { status: 1, siteType: '收集点' }, |
| | | order: { id: 'ASC' }, |
| | | }); |
| | | } |
| | | return list; |
| | | } |
| | | |
| | | async wastetypes(sorterId: string) { |
| | | await this.getSorter(sorterId); |
| | | const list = await this.basicdataWastetypeEntity.find({ |
| | | where: { status: 1, isRecyclable: 1 }, |
| | | order: { id: 'ASC' }, |
| | | }); |
| | | if (list.length) { |
| | | return list; |
| | | } |
| | | return this.basicdataWastetypeEntity.find({ |
| | | where: { status: 1 }, |
| | | order: { id: 'ASC' }, |
| | | }); |
| | | } |
| | | |
| | | async buyers(sorterId: string) { |
| | | const sorter = await this.getSorter(sorterId); |
| | | const find = this.basicdataBuyerEntity.createQueryBuilder('a'); |
| | | if (sorter.departmentId) { |
| | | find.where( |
| | | '(CAST(a.departmentId AS CHAR) = :deptId OR a.departmentId IS NULL)', |
| | | { deptId: String(sorter.departmentId) } |
| | | ); |
| | | } |
| | | find.orderBy('a.id', 'ASC'); |
| | | const list = await find.getMany(); |
| | | if (list.length) { |
| | | return list; |
| | | } |
| | | return this.basicdataBuyerEntity.find({ |
| | | order: { id: 'ASC' }, |
| | | }); |
| | | } |
| | | } |
| | |
| | | import { CoolController, BaseController } from '@cool-midway/core'; |
| | | import { OperationWastesaleEntity } from '../../entity/wastesale'; |
| | | import { OperationWastesaleService } from '../../service/wastesale'; |
| | | import { BaseSysUserEntity } from '../../../base/entity/sys/user'; |
| | | import { BasicdataSorterEntity } from '../../../basicdata/entity/sorter'; |
| | | import { BasicdataBuyerEntity } from '../../../basicdata/entity/buyer' |
| | | import { BaseSysDepartmentEntity } from '../../../base/entity/sys/department'; |
| | | import { BasicdataSiteEntity } from '../../../basicdata/entity/site' |
| | |
| | | 'b.name AS departmentName', |
| | | 'c.businessName AS siteName', |
| | | 'd.name AS wasteName', |
| | | 'e.name AS handlerName', |
| | | 'e.businessName AS handlerName', |
| | | 'f.name AS buyerName', |
| | | ], |
| | | join: [ |
| | |
| | | type: 'leftJoin', |
| | | }, |
| | | { |
| | | entity: BaseSysUserEntity, |
| | | entity: BasicdataSorterEntity, |
| | | alias: 'e', |
| | | condition: 'a.handlerId = e.userId', |
| | | condition: 'a.handlerId = e.businessId', |
| | | type: 'leftJoin', |
| | | }, |
| | | { |
| New file |
| | |
| | | import { |
| | | CoolController, |
| | | BaseController, |
| | | CoolUrlTag, |
| | | TagTypes, |
| | | CoolTag, |
| | | } from '@cool-midway/core'; |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { OperationWastesaleService } from '../../service/wastesale'; |
| | | |
| | | /** |
| | | * 分拣员-可回收物销售 |
| | | */ |
| | | @CoolUrlTag() |
| | | @CoolController() |
| | | export class AppOperationWastesaleController extends BaseController { |
| | | @Inject() |
| | | operationWastesaleService: OperationWastesaleService; |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/saleAdd', { summary: '新增销售记录' }) |
| | | async saleAdd(@Body() body) { |
| | | return this.ok(await this.operationWastesaleService.appAdd(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/salePage', { summary: '我的销售记录' }) |
| | | async salePage(@Body() body) { |
| | | return this.ok(await this.operationWastesaleService.appPage(body)); |
| | | } |
| | | } |
| | |
| | | totalPrice: number; |
| | | |
| | | @Index() |
| | | @Column({ comment: '经手人ID', type: 'bigint' }) |
| | | handlerId: number; |
| | | @Column({ comment: '经手人ID', length: 64 }) |
| | | handlerId: string; |
| | | |
| | | handlerName: string; |
| | | |
| | |
| | | import { Provide } from '@midwayjs/core'; |
| | | import { BaseService } from '@cool-midway/core'; |
| | | import { BaseService, CoolCommException } from '@cool-midway/core'; |
| | | import { InjectEntityModel } from '@midwayjs/typeorm'; |
| | | import { Repository } from 'typeorm'; |
| | | import { OperationWastesaleEntity } from '../entity/wastesale'; |
| | | import { BasicdataSorterEntity } from '../../basicdata/entity/sorter'; |
| | | import { BasicdataWastetypeEntity } from '../../basicdata/entity/wastetype'; |
| | | import { BasicdataBuyerEntity } from '../../basicdata/entity/buyer'; |
| | | import { BasicdataSiteEntity } from '../../basicdata/entity/site'; |
| | | |
| | | /** |
| | | * 可回收物收益服务 |
| | |
| | | @InjectEntityModel(OperationWastesaleEntity) |
| | | operationWastesaleEntity: Repository<OperationWastesaleEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSorterEntity) |
| | | basicdataSorterEntity: Repository<BasicdataSorterEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataWastetypeEntity) |
| | | basicdataWastetypeEntity: Repository<BasicdataWastetypeEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataBuyerEntity) |
| | | basicdataBuyerEntity: Repository<BasicdataBuyerEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSiteEntity) |
| | | basicdataSiteEntity: Repository<BasicdataSiteEntity>; |
| | | |
| | | /** |
| | | * 写入前计算总价 |
| | | */ |
| | | async modifyBefore(data: any, type: 'add' | 'update') { |
| | | if (type === 'add' || type === 'update') { |
| | | if (data.weight !== undefined && data.unitPrice !== undefined) { |
| | | data.totalPrice = Number((Number(data.weight) * Number(data.unitPrice)).toFixed(2)); |
| | | data.totalPrice = Number( |
| | | (Number(data.weight) * Number(data.unitPrice)).toFixed(2) |
| | | ); |
| | | } |
| | | } |
| | | } |
| | | |
| | | async getSorter(sorterId: string) { |
| | | const sorter = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: String(sorterId || '').trim(), |
| | | }); |
| | | if (!sorter || sorter.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | return sorter; |
| | | } |
| | | |
| | | /** |
| | | * 分拣员新增销售记录 |
| | | */ |
| | | async appAdd(param: any) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const weight = Number(param.weight); |
| | | let unitPrice = Number(param.unitPrice); |
| | | if (!weight || weight <= 0) { |
| | | throw new CoolCommException('请填写重量'); |
| | | } |
| | | if (!param.wasteCode) { |
| | | throw new CoolCommException('请选择垃圾种类'); |
| | | } |
| | | if (!param.buyerCode) { |
| | | throw new CoolCommException('请选择收购方'); |
| | | } |
| | | if (!unitPrice) { |
| | | const waste = await this.basicdataWastetypeEntity.findOneBy({ |
| | | code: param.wasteCode, |
| | | }); |
| | | unitPrice = Number(waste?.price || 0); |
| | | } |
| | | const data: any = { |
| | | departmentId: param.departmentId || sorter.departmentId, |
| | | siteId: param.siteId, |
| | | wasteCode: param.wasteCode, |
| | | dateTime: param.dateTime || new Date(), |
| | | weight, |
| | | unitPrice, |
| | | totalPrice: Number((weight * unitPrice).toFixed(2)), |
| | | handlerId: sorter.businessId, |
| | | buyerCode: param.buyerCode, |
| | | }; |
| | | await this.operationWastesaleEntity.save(data); |
| | | return data; |
| | | } |
| | | |
| | | /** |
| | | * 查看当前分拣员的销售记录 |
| | | */ |
| | | async appPage(query: any) { |
| | | const { sorterId, page = 1, size = 10 } = query || {}; |
| | | const sorter = await this.getSorter(sorterId); |
| | | const find = this.operationWastesaleEntity |
| | | .createQueryBuilder('a') |
| | | .select([ |
| | | 'a.*', |
| | | 'c.businessName AS siteName', |
| | | 'd.name AS wasteName', |
| | | 'f.name AS buyerName', |
| | | ]) |
| | | .leftJoin(BasicdataSiteEntity, 'c', 'a.siteId = c.businessId') |
| | | .leftJoin(BasicdataWastetypeEntity, 'd', 'a.wasteCode = d.code') |
| | | .leftJoin(BasicdataBuyerEntity, 'f', 'a.buyerCode = f.code') |
| | | .where('a.handlerId = :hid', { hid: sorter.businessId }) |
| | | .orderBy('a.dateTime', 'DESC'); |
| | | return this.entityRenderPage(find, { ...query, page, size }, false); |
| | | } |
| | | } |
| New file |
| | |
| | | import { |
| | | CoolController, |
| | | BaseController, |
| | | CoolUrlTag, |
| | | TagTypes, |
| | | CoolTag, |
| | | } from '@cool-midway/core'; |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { OrderInfoService } from '../../service/info'; |
| | | |
| | | /** |
| | | * 分拣员-校园商城订单 |
| | | */ |
| | | @CoolUrlTag() |
| | | @CoolController() |
| | | export class AppOrderSorterController extends BaseController { |
| | | @Inject() |
| | | orderInfoService: OrderInfoService; |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/orderPage', { summary: '校园商城订单分页' }) |
| | | async orderPage(@Body() body) { |
| | | return this.ok(await this.orderInfoService.sorterPage(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/deliver', { summary: '现场发放' }) |
| | | async deliver( |
| | | @Body('orderId') orderId: number, |
| | | @Body('sorterId') sorterId: string |
| | | ) { |
| | | return this.ok(await this.orderInfoService.sorterDeliver(orderId, sorterId)); |
| | | } |
| | | } |
| | |
| | | import { OrderGoodsEntity } from '../entity/goods'; |
| | | import * as moment from 'moment'; |
| | | import { UserAddressService } from '../../user/service/address'; |
| | | import { UserInfoEntity } from '../../user/entity/info'; |
| | | import { Action, OrderQueue } from '../queue/order'; |
| | | import { OrderPayService } from './pay'; |
| | | import { PluginService } from '../../plugin/service/info'; |
| | |
| | | import { MarketCouponInfoService } from '../../market/service/coupon/info'; |
| | | import { MarketCouponUserEntity } from '../../market/entity/coupon/user'; |
| | | import { BaseSysParamService } from '../../base/service/sys/param'; |
| | | import { BasicdataSorterEntity } from '../../basicdata/entity/sorter'; |
| | | import BigNumber from 'bignumber.js'; |
| | | |
| | | /** |
| | |
| | | |
| | | @InjectEntityModel(MarketCouponUserEntity) |
| | | marketCouponUserEntity: Repository<MarketCouponUserEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSorterEntity) |
| | | basicdataSorterEntity: Repository<BasicdataSorterEntity>; |
| | | |
| | | @Inject() |
| | | orderGoodsService: OrderGoodsService; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 分拣员查看校园商城订单(按学校过滤) |
| | | */ |
| | | async sorterPage(query: any) { |
| | | const { sorterId, status, page = 1, size = 10, keyWord } = query || {}; |
| | | if (!sorterId) { |
| | | throw new CoolCommException('缺少分拣员信息'); |
| | | } |
| | | const sorter = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: String(sorterId), |
| | | }); |
| | | if (!sorter || sorter.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | const find = this.orderInfoEntity |
| | | .createQueryBuilder('a') |
| | | .select([ |
| | | 'a.*', |
| | | 'b.nickName AS nickName', |
| | | 'b.phone AS phone', |
| | | 'b.unionid AS unionid', |
| | | ]) |
| | | .leftJoin(UserInfoEntity, 'b', 'a.userId = b.unionid'); |
| | | if (sorter.departmentId) { |
| | | find.andWhere('b.departmentId = :deptId', { |
| | | deptId: sorter.departmentId, |
| | | }); |
| | | } |
| | | if (status !== undefined && status !== null && status !== '') { |
| | | find.andWhere('a.status = :status', { status: Number(status) }); |
| | | } |
| | | if (keyWord) { |
| | | find.andWhere( |
| | | '(a.orderNum LIKE :kw OR a.title LIKE :kw OR b.nickName LIKE :kw OR b.phone LIKE :kw)', |
| | | { kw: `%${keyWord}%` } |
| | | ); |
| | | } |
| | | find.orderBy('a.createTime', 'DESC'); |
| | | const result = await this.entityRenderPage( |
| | | find, |
| | | { ...query, page, size }, |
| | | false |
| | | ); |
| | | const ids = (result.list || []).map((e: any) => e.id); |
| | | if (ids.length) { |
| | | const goodsList = await this.orderGoodsService.getByOrderIds(ids); |
| | | for (const item of result.list) { |
| | | item.goodsList = goodsList.filter(e => e.orderId == item.id); |
| | | } |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 校园现场发放(不走物流) |
| | | */ |
| | | async sorterDeliver(orderId: number, sorterId: string) { |
| | | if (!sorterId) { |
| | | throw new CoolCommException('缺少分拣员信息'); |
| | | } |
| | | return this.deliver(orderId, { |
| | | company: '现场发放', |
| | | num: String(sorterId), |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 检查库存 |
| | | * @param goodsList |
| | | */ |
| New file |
| | |
| | | import { |
| | | CoolController, |
| | | BaseController, |
| | | CoolUrlTag, |
| | | TagTypes, |
| | | CoolTag, |
| | | } from '@cool-midway/core'; |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { PushSorterAppService } from '../../service/sorterapp'; |
| | | |
| | | /** |
| | | * 分拣员上报 |
| | | */ |
| | | @CoolUrlTag() |
| | | @CoolController() |
| | | export class AppPushSorterController extends BaseController { |
| | | @Inject() |
| | | pushSorterAppService: PushSorterAppService; |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/health', { summary: '健康监测上报' }) |
| | | async health(@Body() body) { |
| | | return this.ok(await this.pushSorterAppService.reportHealth(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/gps', { summary: 'GPS位置上报' }) |
| | | async gps(@Body() body) { |
| | | return this.ok(await this.pushSorterAppService.reportGps(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/siteweightAdd', { summary: '压缩站人工称重上报' }) |
| | | async siteweightAdd(@Body() body) { |
| | | return this.ok(await this.pushSorterAppService.addSiteweight(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/siteweightPage', { summary: '压缩站人工称重记录' }) |
| | | async siteweightPage(@Body() body) { |
| | | return this.ok(await this.pushSorterAppService.siteweightPage(body)); |
| | | } |
| | | } |
| New file |
| | |
| | | import { |
| | | CoolController, |
| | | BaseController, |
| | | CoolUrlTag, |
| | | TagTypes, |
| | | CoolTag, |
| | | } from '@cool-midway/core'; |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { PushSorterAttendanceService } from '../../service/sorterattendance'; |
| | | |
| | | /** |
| | | * 分拣员工作签到 |
| | | */ |
| | | @CoolUrlTag() |
| | | @CoolController() |
| | | export class AppPushSorterattendanceController extends BaseController { |
| | | @Inject() |
| | | pushSorterAttendanceService: PushSorterAttendanceService; |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/checkIn', { summary: '上班签到' }) |
| | | async checkIn(@Body() body) { |
| | | return this.ok(await this.pushSorterAttendanceService.checkIn(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/checkOut', { summary: '下班签退' }) |
| | | async checkOut(@Body() body) { |
| | | return this.ok(await this.pushSorterAttendanceService.checkOut(body)); |
| | | } |
| | | } |
| | |
| | | @Column({ comment: '数据时间' }) |
| | | uploadTime: Date; |
| | | |
| | | @Column({ comment: '体温', type: 'decimal', precision: 3, scale: 1 }) |
| | | @Column({ comment: '体温', type: 'decimal', precision: 3, scale: 1, nullable: true }) |
| | | temperature: number; |
| | | |
| | | @Column({ comment: '心率' }) |
| | | @Column({ comment: '心率', nullable: true }) |
| | | heartRate: number; |
| | | |
| | | @Column({ comment: '收缩压' }) |
| | | @Column({ comment: '收缩压', nullable: true }) |
| | | bloodPressureHigh: number; |
| | | |
| | | @Column({ comment: '舒张压' }) |
| | | @Column({ comment: '舒张压', nullable: true }) |
| | | bloodPressureLow: number; |
| | | |
| | | @Column({ comment: '血氧饱和度', type: 'decimal', precision: 4, scale: 1 }) |
| | | @Column({ comment: '血氧饱和度', type: 'decimal', precision: 4, scale: 1, nullable: true }) |
| | | bloodOxygen: number; |
| | | |
| | | // add more if needed |
| | | @Column({ comment: '步数', nullable: true, default: 0 }) |
| | | stepCount: number; |
| | | |
| | | @Column({ comment: '数据类型', dict: ['周期', '平均', '异常'], default: '周期', nullable: true }) |
| | | dataType: string; |
| | | |
| | | |
| | | } |
| New file |
| | |
| | | import { Provide } from '@midwayjs/core'; |
| | | import { BaseService, CoolCommException } from '@cool-midway/core'; |
| | | import { InjectEntityModel } from '@midwayjs/typeorm'; |
| | | import { Repository } from 'typeorm'; |
| | | import { BasicdataSorterEntity } from '../../basicdata/entity/sorter'; |
| | | import { PushSorterHealthEntity } from '../entity/sorterhealth'; |
| | | import { PushSorterGpsEntity } from '../entity/sortergps'; |
| | | import { PushSiteweightEntity } from '../entity/siteweight'; |
| | | import { BasicdataSiteEntity } from '../../basicdata/entity/site'; |
| | | |
| | | /** |
| | | * 分拣员上报(健康、GPS、压缩站称重) |
| | | */ |
| | | @Provide() |
| | | export class PushSorterAppService extends BaseService { |
| | | @InjectEntityModel(BasicdataSorterEntity) |
| | | basicdataSorterEntity: Repository<BasicdataSorterEntity>; |
| | | |
| | | @InjectEntityModel(PushSorterHealthEntity) |
| | | pushSorterHealthEntity: Repository<PushSorterHealthEntity>; |
| | | |
| | | @InjectEntityModel(PushSorterGpsEntity) |
| | | pushSorterGpsEntity: Repository<PushSorterGpsEntity>; |
| | | |
| | | @InjectEntityModel(PushSiteweightEntity) |
| | | pushSiteweightEntity: Repository<PushSiteweightEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSiteEntity) |
| | | basicdataSiteEntity: Repository<BasicdataSiteEntity>; |
| | | |
| | | async getSorter(sorterId: string) { |
| | | const sorter = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: String(sorterId || '').trim(), |
| | | }); |
| | | if (!sorter || sorter.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | return sorter; |
| | | } |
| | | |
| | | async reportHealth(param: any) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const row = await this.pushSorterHealthEntity.save({ |
| | | idCard: sorter.businessId, |
| | | departmentId: sorter.departmentId, |
| | | uploadTime: param.uploadTime ? new Date(param.uploadTime) : new Date(), |
| | | temperature: param.temperature, |
| | | heartRate: param.heartRate, |
| | | bloodPressureHigh: param.bloodPressureHigh, |
| | | bloodPressureLow: param.bloodPressureLow, |
| | | bloodOxygen: param.bloodOxygen, |
| | | stepCount: param.stepCount || 0, |
| | | dataType: param.dataType || '周期', |
| | | }); |
| | | return row; |
| | | } |
| | | |
| | | async reportGps(param: any) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | if (param.longitude == null || param.latitude == null) { |
| | | throw new CoolCommException('缺少定位信息'); |
| | | } |
| | | return this.pushSorterGpsEntity.save({ |
| | | idCard: sorter.businessId, |
| | | name: sorter.businessName, |
| | | departmentId: sorter.departmentId, |
| | | uploadTime: param.uploadTime ? new Date(param.uploadTime) : new Date(), |
| | | longitude: Number(param.longitude), |
| | | latitude: Number(param.latitude), |
| | | height: Number(param.height || 0), |
| | | speed: Number(param.speed || 0), |
| | | accuracy: Number(param.accuracy || 0), |
| | | }); |
| | | } |
| | | |
| | | async addSiteweight(param: any) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | if (!param.businessId) { |
| | | throw new CoolCommException('请选择压缩站'); |
| | | } |
| | | const weight = Number(param.weight); |
| | | if (!weight || weight <= 0) { |
| | | throw new CoolCommException('请填写垃圾重量'); |
| | | } |
| | | const site = await this.basicdataSiteEntity.findOneBy({ |
| | | businessId: String(param.businessId), |
| | | }); |
| | | return this.pushSiteweightEntity.save({ |
| | | businessId: param.businessId, |
| | | departmentId: param.departmentId || site?.departmentId || sorter.departmentId, |
| | | deviceNo: param.deviceNo || '人工填报', |
| | | uploadTime: param.uploadTime ? new Date(param.uploadTime) : new Date(), |
| | | garbageType: param.garbageType || 'SW64', |
| | | weight, |
| | | remark: param.remark, |
| | | extraData: JSON.stringify({ |
| | | source: 'sorter-app', |
| | | sorterId: sorter.businessId, |
| | | }), |
| | | }); |
| | | } |
| | | |
| | | async siteweightPage(query: any) { |
| | | const { sorterId, page = 1, size = 10 } = query || {}; |
| | | const sorter = await this.getSorter(sorterId); |
| | | const find = this.pushSiteweightEntity |
| | | .createQueryBuilder('a') |
| | | .select([ |
| | | 'a.*', |
| | | 'd.businessName AS businessName', |
| | | 'd.siteType AS siteType', |
| | | ]) |
| | | .leftJoin(BasicdataSiteEntity, 'd', 'a.businessId = d.businessId') |
| | | .where('d.siteType = :st', { st: '压缩站' }) |
| | | .andWhere('a.deviceNo = :dn', { dn: '人工填报' }); |
| | | if (sorter.departmentId) { |
| | | find.andWhere('a.departmentId = :deptId', { |
| | | deptId: sorter.departmentId, |
| | | }); |
| | | } |
| | | find.orderBy('a.uploadTime', 'DESC'); |
| | | return this.entityRenderPage(find, { ...query, page, size }, false); |
| | | } |
| | | } |
| | |
| | | import { Provide } from '@midwayjs/core'; |
| | | import { BaseService } from '@cool-midway/core'; |
| | | import { BaseService, CoolCommException } from '@cool-midway/core'; |
| | | import { InjectEntityModel } from '@midwayjs/typeorm'; |
| | | import { Repository } from 'typeorm'; |
| | | import * as moment from 'moment'; |
| | | import { PushSorterAttendanceEntity } from '../entity/sorterattendance'; |
| | | import { BasicdataSorterEntity } from '../../basicdata/entity/sorter'; |
| | | |
| | | /** |
| | | * 分拣员健康告警服务 |
| | | * 分拣员签到签退 |
| | | */ |
| | | @Provide() |
| | | export class PushSorterAttendanceService extends BaseService { |
| | | @InjectEntityModel(PushSorterAttendanceEntity) |
| | | pushSorterAttendanceEntity: Repository<PushSorterAttendanceEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSorterEntity) |
| | | basicdataSorterEntity: Repository<BasicdataSorterEntity>; |
| | | |
| | | async getSorter(sorterId: string) { |
| | | const sorter = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: String(sorterId || '').trim(), |
| | | }); |
| | | if (!sorter || sorter.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | return sorter; |
| | | } |
| | | |
| | | private locationOf(param: any) { |
| | | if (param.longitude == null || param.latitude == null) { |
| | | throw new CoolCommException('缺少定位信息'); |
| | | } |
| | | return `${Number(param.longitude)},${Number(param.latitude)}`; |
| | | } |
| | | |
| | | private today() { |
| | | return moment().format('YYYY-MM-DD'); |
| | | } |
| | | |
| | | private async todayRow(idCard: string) { |
| | | return this.pushSorterAttendanceEntity |
| | | .createQueryBuilder('a') |
| | | .where('a.idCard = :idCard', { idCard }) |
| | | .andWhere('a.workDate = :workDate', { workDate: this.today() }) |
| | | .getOne(); |
| | | } |
| | | |
| | | async checkIn(param: any) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const loc = this.locationOf(param); |
| | | const now = new Date(); |
| | | const workDate = this.today(); |
| | | let row = await this.todayRow(sorter.businessId); |
| | | if (row?.checkInTime) { |
| | | throw new CoolCommException('今日已签到'); |
| | | } |
| | | if (!row) { |
| | | row = this.pushSorterAttendanceEntity.create({ |
| | | idCard: sorter.businessId, |
| | | departmentId: sorter.departmentId, |
| | | uploadTime: now, |
| | | workDate: workDate as any, |
| | | checkInTime: now, |
| | | checkInLocation: loc, |
| | | workHours: 0, |
| | | status: '缺卡', |
| | | }); |
| | | } else { |
| | | row.departmentId = sorter.departmentId; |
| | | row.uploadTime = now; |
| | | row.checkInTime = now; |
| | | row.checkInLocation = loc; |
| | | } |
| | | await this.pushSorterAttendanceEntity.save(row); |
| | | return { |
| | | message: '签到成功', |
| | | workDate, |
| | | checkInTime: now, |
| | | checkInLocation: loc, |
| | | }; |
| | | } |
| | | |
| | | async checkOut(param: any) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const loc = this.locationOf(param); |
| | | const now = new Date(); |
| | | const workDate = this.today(); |
| | | const row = await this.todayRow(sorter.businessId); |
| | | if (!row?.checkInTime) { |
| | | throw new CoolCommException('请先上班签到'); |
| | | } |
| | | if (row.checkOutTime) { |
| | | throw new CoolCommException('今日已签退'); |
| | | } |
| | | const start = new Date(row.checkInTime).getTime(); |
| | | const hours = Math.max(0, (now.getTime() - start) / 3600000); |
| | | row.uploadTime = now; |
| | | row.checkOutTime = now; |
| | | row.checkOutLocation = loc; |
| | | row.workHours = Math.round(hours * 100) / 100; |
| | | row.status = '正常'; |
| | | await this.pushSorterAttendanceEntity.save(row); |
| | | return { |
| | | message: '签退成功', |
| | | workDate, |
| | | checkOutTime: now, |
| | | checkOutLocation: loc, |
| | | workHours: row.workHours, |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | import { CoolController, BaseController } from '@cool-midway/core'; |
| | | import { ReportDailySorterEntity } from '../../entity/dailysorter'; |
| | | import { ReportDailySorterService } from '../../service/dailysorter'; |
| | | import { BaseSysDepartmentEntity } from '../../../base/entity/sys/department'; |
| | | import { WithDeptFilterWhere } from '../../../base/middleware/with-dept-filter-where'; |
| | | |
| | | /** |
| | | * 分拣员每日汇总 |
| | | */ |
| | | @CoolController({ |
| | | api: ['add', 'delete', 'update', 'info', 'list', 'page'], |
| | | entity: ReportDailySorterEntity, |
| | | service: ReportDailySorterService, |
| | | pageQueryOp: { |
| | | keyWordLikeFields: ['a.businessName', 'a.businessId'], |
| | | fieldEq: ['a.date', 'a.businessId', 'a.attendanceStatus'], |
| | | select: ['a.*', 'b.name AS departmentName'], |
| | | join: [ |
| | | { |
| | | entity: BaseSysDepartmentEntity, |
| | | alias: 'b', |
| | | condition: 'a.departmentId = b.id', |
| | | type: 'leftJoin', |
| | | }, |
| | | ], |
| | | where: async ctx => { |
| | | const conditions: any[][] = []; |
| | | conditions.push(...(await WithDeptFilterWhere(ctx, { alias: 'a', field: 'departmentId' }))); |
| | | return conditions; |
| | | }, |
| | | }, |
| | | }) |
| | | export class AdminReportDailySorterController extends BaseController {} |
| New file |
| | |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { CoolController, BaseController } from '@cool-midway/core'; |
| | | import { ReportScreenService } from '../../service/screen'; |
| | | |
| | | /** |
| | | * 校园绿色运营驾驶舱 |
| | | */ |
| | | @CoolController() |
| | | export class AdminReportScreenController extends BaseController { |
| | | @Inject() |
| | | reportScreenService: ReportScreenService; |
| | | |
| | | @Post('/query', { summary: '驾驶舱汇总' }) |
| | | async query(@Body('range') range: string) { |
| | | return this.ok(await this.reportScreenService.query(range)); |
| | | } |
| | | } |
| New file |
| | |
| | | import { BaseEntity } from '../../base/entity/base'; |
| | | import { Column, Entity, Index, Unique } from 'typeorm'; |
| | | |
| | | /** |
| | | * 分拣员每日统计 |
| | | * |
| | | * 一名分拣员一天一条记录 |
| | | */ |
| | | @Entity('t_report_daily_sorter') |
| | | @Unique('uk_date_sorter', ['date', 'businessId']) |
| | | export class ReportDailySorterEntity extends BaseEntity { |
| | | @Index() |
| | | @Column({ |
| | | comment: '日期', |
| | | type: 'date', |
| | | }) |
| | | date: string; |
| | | |
| | | @Index() |
| | | @Column({ |
| | | comment: '身份证号', |
| | | length: 32, |
| | | }) |
| | | businessId: string; |
| | | |
| | | @Column({ |
| | | comment: '姓名', |
| | | length: 64, |
| | | nullable: true, |
| | | }) |
| | | businessName: string; |
| | | |
| | | @Index() |
| | | @Column({ |
| | | comment: '所属单位ID', |
| | | type: 'bigint', |
| | | nullable: true, |
| | | }) |
| | | departmentId: number; |
| | | |
| | | @Column({ |
| | | comment: '所属单位名称', |
| | | length: 64, |
| | | nullable: true, |
| | | }) |
| | | departmentName: string; |
| | | |
| | | @Index() |
| | | @Column({ |
| | | comment: '工作站点ID', |
| | | nullable: true, |
| | | }) |
| | | workSiteId: string; |
| | | |
| | | @Column({ |
| | | comment: '工作站点名称', |
| | | length: 64, |
| | | nullable: true, |
| | | }) |
| | | workSiteName: string; |
| | | |
| | | @Column({ |
| | | comment: '签到时间', |
| | | nullable: true, |
| | | }) |
| | | checkInTime: Date; |
| | | |
| | | @Column({ |
| | | comment: '签退时间', |
| | | nullable: true, |
| | | }) |
| | | checkOutTime: Date; |
| | | |
| | | @Column({ |
| | | comment: '工作时长(小时)', |
| | | type: 'decimal', |
| | | precision: 5, |
| | | scale: 2, |
| | | default: 0.0, |
| | | }) |
| | | workHours: number; |
| | | |
| | | @Column({ |
| | | comment: '考勤状态', |
| | | dict: ['正常', '迟到', '早退', '缺卡', '旷工'], |
| | | default: '缺卡', |
| | | }) |
| | | attendanceStatus: string; |
| | | |
| | | @Column({ |
| | | comment: 'GPS数据点数量', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | gpsPointCount: number; |
| | | |
| | | @Column({ |
| | | comment: '工作轨迹里程(km)', |
| | | type: 'decimal', |
| | | precision: 10, |
| | | scale: 2, |
| | | default: 0.0, |
| | | }) |
| | | totalKm: number; |
| | | |
| | | @Column({ |
| | | comment: '移动时长(分钟)', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | minutesMoving: number; |
| | | |
| | | @Column({ |
| | | comment: '回收订单数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | orderCount: number; |
| | | |
| | | @Column({ |
| | | comment: '完成订单数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | completedOrderCount: number; |
| | | |
| | | @Column({ |
| | | comment: '预约上门订单数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | appointmentOrderCount: number; |
| | | |
| | | @Column({ |
| | | comment: '发放碳积分', |
| | | type: 'decimal', |
| | | precision: 12, |
| | | scale: 2, |
| | | default: 0.0, |
| | | }) |
| | | totalCarbonPoint: number; |
| | | |
| | | @Column({ |
| | | comment: '健康数据数量', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | healthDataCount: number; |
| | | |
| | | @Column({ |
| | | comment: '健康异常数量', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | healthAbnormalCount: number; |
| | | |
| | | @Column({ |
| | | comment: '健康告警总数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | alarmCount: number; |
| | | |
| | | @Column({ |
| | | comment: '一般告警次数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | generalAlarmCount: number; |
| | | |
| | | @Column({ |
| | | comment: '中等告警次数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | mediumAlarmCount: number; |
| | | |
| | | @Column({ |
| | | comment: '严重告警次数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | seriousAlarmCount: number; |
| | | |
| | | @Column({ |
| | | comment: '未处理告警次数', |
| | | type: 'int', |
| | | default: 0, |
| | | }) |
| | | unhandledAlarmCount: number; |
| | | } |
| New file |
| | |
| | | import { Provide, Inject } from '@midwayjs/core'; |
| | | import { InjectEntityModel } from '@midwayjs/typeorm'; |
| | | import { Repository, In } from 'typeorm'; |
| | | import { ReportJoblogEntity } from '../entity/joblog'; |
| | | import { TaskServiceSorter } from './taskservicesorter' |
| | | |
| | | |
| | | |
| | | @Provide() |
| | | export class ExecutorSorterDaily { |
| | | |
| | | @InjectEntityModel(ReportJoblogEntity) |
| | | reportJoblogEntity: Repository<ReportJoblogEntity>; |
| | | |
| | | @Inject() |
| | | taskServiceSorter: TaskServiceSorter; |
| | | |
| | | async execute() { |
| | | // 捞出所有待处理任务 |
| | | const pendingSiteJobs = await this.reportJoblogEntity.find({ |
| | | where: { |
| | | status: In(['INIT', 'FAILED']), |
| | | jobName: In(['SORTER_DAILY']), |
| | | }, |
| | | order: { |
| | | dataDate: 'ASC', |
| | | }, |
| | | }); |
| | | |
| | | for (const job of pendingSiteJobs) { |
| | | |
| | | // 锁定任务状态为 RUNNING(轻量级乐观锁锁机制) |
| | | const lockResult = await this.reportJoblogEntity.update( |
| | | { id: job.id, status: job.status }, // 带有原始状态检查,防止被别的线程抢跑 |
| | | { status: 'RUNNING', startTime: new Date() } |
| | | ); |
| | | |
| | | if (lockResult.affected === 0) { |
| | | continue; // 没抢到锁,说明别的定时器在跑它,跳过 |
| | | } |
| | | |
| | | // 真正开始跑数据 |
| | | try { |
| | | let recordsProcessed = 0; |
| | | |
| | | |
| | | recordsProcessed = await this.taskServiceSorter.aggregateDailyData(job.dataDate) || 0; |
| | | |
| | | |
| | | // 成功:更新状态 |
| | | await this.reportJoblogEntity.update(job.id, { |
| | | status: 'SUCCESS', |
| | | recordsProcessed: recordsProcessed, |
| | | endTime: new Date(), |
| | | errorMessage: '', |
| | | }); |
| | | |
| | | } catch (error) { |
| | | // 失败:记录堆栈,等待下一小时重试 |
| | | |
| | | var errorMessage = ''; |
| | | if (error instanceof Error) { |
| | | errorMessage = error.stack ?? error.message; |
| | | } |
| | | if (typeof error === 'string') { |
| | | errorMessage = error; |
| | | } |
| | | try { |
| | | errorMessage = JSON.stringify(error); |
| | | } catch { |
| | | errorMessage = String(error); |
| | | } |
| | | |
| | | await this.reportJoblogEntity.update(job.id, { |
| | | status: 'FAILED', |
| | | endTime: new Date(), |
| | | errorMessage: errorMessage, |
| | | failedCount: () => 'failedCount + 1', |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | 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'; |
| | | |
| | | /** |
| | | * 分拣员每日汇总服务 |
| | | */ |
| | | @Provide() |
| | | export class ReportDailySorterService extends BaseService { |
| | | @InjectEntityModel(ReportDailySorterEntity) |
| | | reportDailySorterEntity: Repository<ReportDailySorterEntity>; |
| | | } |
| New file |
| | |
| | | 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, |
| | | }; |
| | | } |
| | | } |
| | |
| | | { date: yesterdayStr, type: 'SITE_DAILY', isToday: false }, |
| | | { date: yesterdayStr, type: 'DEPT_DAILY', isToday: false }, |
| | | { date: yesterdayStr, type: 'VEHICLE_DAILY', isToday: false }, // 新增 |
| | | { date: yesterdayStr, type: 'SORTER_DAILY', isToday: false }, // 新增 |
| | | |
| | | { date: todayStr, type: 'SITE_DAILY', isToday: true }, |
| | | { date: todayStr, type: 'DEPT_DAILY', isToday: true }, |
| | | { date: todayStr, type: 'VEHICLE_DAILY', isToday: true }, // 新增 |
| | | { date: todayStr, type: 'SORTER_DAILY', isToday: true }, // 新增 |
| | | |
| | | ]; |
| | | |
| New file |
| | |
| | | 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(); |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | import { |
| | | CoolController, |
| | | BaseController, |
| | | CoolUrlTag, |
| | | TagTypes, |
| | | CoolTag, |
| | | } from '@cool-midway/core'; |
| | | import { Body, Inject, Post } from '@midwayjs/core'; |
| | | import { ShopSorterService } from '../../service/sorter'; |
| | | |
| | | /** |
| | | * 分拣员 App |
| | | */ |
| | | @CoolUrlTag() |
| | | @CoolController() |
| | | export class AppShopSorterController extends BaseController { |
| | | @Inject() |
| | | shopSorterService: ShopSorterService; |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/checkUser', { summary: '扫描回收码校验平台用户' }) |
| | | async checkUser(@Body('unionid') unionid: string) { |
| | | return this.ok(await this.shopSorterService.checkUser(unionid)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/checkIn', { summary: '打卡积分' }) |
| | | async checkIn( |
| | | @Body() body: { unionid: string; sorterId: string } |
| | | ) { |
| | | return this.ok(await this.shopSorterService.checkIn(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/itemList', { summary: '回收品类列表' }) |
| | | async itemList(@Body('sorterId') sorterId: string) { |
| | | return this.ok(await this.shopSorterService.itemList(sorterId)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/saveOrder', { summary: '保存现场或预约回收清单' }) |
| | | async saveOrder( |
| | | @Body() |
| | | body: { |
| | | sorterId: string; |
| | | unionid: string; |
| | | appointmentId?: number; |
| | | items: any[]; |
| | | } |
| | | ) { |
| | | return this.ok(await this.shopSorterService.saveOrder(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/appointmentPage', { summary: '预约回收订单分页' }) |
| | | async appointmentPage(@Body() body) { |
| | | return this.ok(await this.shopSorterService.appointmentPage(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/confirmAppointment', { summary: '确认预约单' }) |
| | | async confirmAppointment( |
| | | @Body() body: { id: number; sorterId: string } |
| | | ) { |
| | | return this.ok(await this.shopSorterService.confirmAppointment(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/claimAppointment', { summary: '领取预约单到自己名下' }) |
| | | async claimAppointment( |
| | | @Body() body: { id: number; sorterId: string } |
| | | ) { |
| | | return this.ok(await this.shopSorterService.claimAppointment(body)); |
| | | } |
| | | |
| | | @CoolTag(TagTypes.IGNORE_TOKEN) |
| | | @Post('/scanAppointment', { summary: '扫描预约单' }) |
| | | async scanAppointment( |
| | | @Body('appointmentNo') appointmentNo: string, |
| | | @Body('sorterId') sorterId: string |
| | | ) { |
| | | return this.ok( |
| | | await this.shopSorterService.scanAppointment(appointmentNo, sorterId) |
| | | ); |
| | | } |
| | | } |
| | |
| | | @Column({ comment:"预计重量",default: 0 }) |
| | | expectedWeight:number; |
| | | |
| | | @Column({ comment: "状态", dict: ['待审核', '待接单', '已接单', '上门中', '已完成','已取消', '未知'], default: '未知' }) |
| | | @Column({ comment: "状态", dict: ['新增', '待审核', '待接单', '已确认', '已接单', '上门中', '已完成', '已取消', '未知'], default: '新增' }) |
| | | status: string; |
| | | |
| | | @Index() |
| | |
| | | "创建预约", |
| | | "审核通过", |
| | | "分配人员", |
| | | "确认", |
| | | "接单", |
| | | "到达", |
| | | "开始回收", |
| | |
| | | @Column({ comment: "订单编号" }) |
| | | orderNo: string; |
| | | |
| | | @Column({ comment: "订单类型", dict: ['现场', '预约', '大屏', '活动'], default: '现场' }) |
| | | @Column({ comment: "订单类型", dict: ['现场', '预约', '大屏', '活动', '打卡'], default: '现场' }) |
| | | orderType: string; |
| | | |
| | | @Index() |
| New file |
| | |
| | | import { Inject, Provide } from '@midwayjs/core'; |
| | | import { BaseService, CoolCommException, CoolTransaction } from '@cool-midway/core'; |
| | | import { InjectEntityModel } from '@midwayjs/typeorm'; |
| | | import { QueryRunner, Repository } from 'typeorm'; |
| | | import * as moment from 'moment'; |
| | | import { UserInfoEntity } from '../../user/entity/info'; |
| | | import { BasicdataSorterEntity } from '../../basicdata/entity/sorter'; |
| | | import { RecycleOrderEntity } from '../entity/recycleorder'; |
| | | import { RecycleOrderItemEntity } from '../entity/recycleorderitem'; |
| | | import { RecycleItemEntity } from '../entity/recycleitem'; |
| | | import { RecycleTransactionEntity } from '../entity/transaction'; |
| | | import { RecycleAppointmentEntity } from '../entity/recycleappointment'; |
| | | import { RecycleAppointmentLogEntity } from '../entity/recycleappointmentlog'; |
| | | import { BaseSysParamService } from '../../base/service/sys/param'; |
| | | |
| | | /** |
| | | * 分拣员 App 业务 |
| | | */ |
| | | @Provide() |
| | | export class ShopSorterService extends BaseService { |
| | | @InjectEntityModel(UserInfoEntity) |
| | | userInfoEntity: Repository<UserInfoEntity>; |
| | | |
| | | @InjectEntityModel(BasicdataSorterEntity) |
| | | basicdataSorterEntity: Repository<BasicdataSorterEntity>; |
| | | |
| | | @InjectEntityModel(RecycleOrderEntity) |
| | | recycleOrderEntity: Repository<RecycleOrderEntity>; |
| | | |
| | | @InjectEntityModel(RecycleOrderItemEntity) |
| | | recycleOrderItemEntity: Repository<RecycleOrderItemEntity>; |
| | | |
| | | @InjectEntityModel(RecycleItemEntity) |
| | | recycleItemEntity: Repository<RecycleItemEntity>; |
| | | |
| | | @InjectEntityModel(RecycleTransactionEntity) |
| | | recycleTransactionEntity: Repository<RecycleTransactionEntity>; |
| | | |
| | | @InjectEntityModel(RecycleAppointmentEntity) |
| | | recycleAppointmentEntity: Repository<RecycleAppointmentEntity>; |
| | | |
| | | @InjectEntityModel(RecycleAppointmentLogEntity) |
| | | recycleAppointmentLogEntity: Repository<RecycleAppointmentLogEntity>; |
| | | |
| | | @Inject() |
| | | baseSysParamService: BaseSysParamService; |
| | | |
| | | /** |
| | | * 读取打卡参数 |
| | | */ |
| | | async getHitupConfig() { |
| | | const scoreRaw = await this.baseSysParamService.dataByKey('hitup.score'); |
| | | const maxRaw = await this.baseSysParamService.dataByKey( |
| | | 'hitup.maxmium.perday' |
| | | ); |
| | | const score = Number(scoreRaw); |
| | | const maxPerDay = Number(maxRaw); |
| | | return { |
| | | score: Number.isFinite(score) && score >= 0 ? score : 1, |
| | | maxPerDay: Number.isFinite(maxPerDay) && maxPerDay >= 0 ? maxPerDay : 1, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 校验分拣员 |
| | | */ |
| | | async getSorter(sorterId: string) { |
| | | const id = String(sorterId || '').trim(); |
| | | if (!id) { |
| | | throw new CoolCommException('缺少分拣员信息'); |
| | | } |
| | | const sorter = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: id, |
| | | }); |
| | | if (!sorter || sorter.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | return sorter; |
| | | } |
| | | |
| | | /** |
| | | * 校验回收码对应的平台用户(回收码即为 unionid) |
| | | */ |
| | | async checkUser(unionid: string) { |
| | | const uid = String(unionid || '').trim(); |
| | | if (!uid) { |
| | | throw new CoolCommException('请扫描有效的回收码'); |
| | | } |
| | | const user = await this.userInfoEntity.findOneBy({ unionid: uid }); |
| | | if (!user || user.status !== 1) { |
| | | throw new CoolCommException('该用户不是平台用户'); |
| | | } |
| | | return { |
| | | unionid: user.unionid, |
| | | nickName: user.nickName, |
| | | phone: user.phone, |
| | | role: user.role, |
| | | departmentId: user.departmentId, |
| | | carbonBalance: user.carbonBalance, |
| | | collegeName: user.collegeName, |
| | | className: user.className, |
| | | avatarUrl: user.avatarUrl, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 打卡积分 |
| | | */ |
| | | @CoolTransaction() |
| | | async checkIn( |
| | | param: { unionid: string; sorterId: string }, |
| | | queryRunner?: QueryRunner |
| | | ) { |
| | | if (!queryRunner) { |
| | | throw new CoolCommException('事务启动失败'); |
| | | } |
| | | const manager = queryRunner.manager; |
| | | const unionid = String(param.unionid || '').trim(); |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const user = await manager.findOne(UserInfoEntity, { |
| | | where: { unionid }, |
| | | }); |
| | | if (!user || user.status !== 1) { |
| | | throw new CoolCommException('该用户不是平台用户'); |
| | | } |
| | | |
| | | const { score, maxPerDay } = await this.getHitupConfig(); |
| | | const today = moment().format('YYYY-MM-DD'); |
| | | const todayCount = await manager |
| | | .createQueryBuilder(RecycleOrderEntity, 'a') |
| | | .where('a.campusUserId = :unionid', { unionid }) |
| | | .andWhere('a.orderType = :orderType', { orderType: '打卡' }) |
| | | .andWhere('a.createTime >= :start AND a.createTime <= :end', { |
| | | start: `${today} 00:00:00`, |
| | | end: `${today} 23:59:59`, |
| | | }) |
| | | .getCount(); |
| | | |
| | | if (todayCount >= maxPerDay) { |
| | | throw new CoolCommException('该用户今日打卡次数已达上限'); |
| | | } |
| | | |
| | | const orderNo = this.genOrderNo('CI'); |
| | | const now = moment().format('YYYY-MM-DD HH:mm:ss'); |
| | | const order = await manager.save(RecycleOrderEntity, { |
| | | orderNo, |
| | | orderType: '打卡', |
| | | campusUserId: unionid, |
| | | departmentId: user.departmentId, |
| | | siteId: sorter.workSiteId, |
| | | sorterId: sorter.businessId, |
| | | status: '已完成', |
| | | totalWeight: 0, |
| | | totalAmount: 0, |
| | | totalCarbonPoint: score, |
| | | finishTime: now as any, |
| | | }); |
| | | |
| | | if (score > 0) { |
| | | await manager |
| | | .createQueryBuilder() |
| | | .update(UserInfoEntity) |
| | | .set({ |
| | | carbonBalance: () => `carbonBalance + ${score}`, |
| | | }) |
| | | .where('unionid = :unionid', { unionid }) |
| | | .execute(); |
| | | |
| | | await manager.save(RecycleTransactionEntity, { |
| | | userId: unionid, |
| | | departmentId: user.departmentId, |
| | | type: '收入', |
| | | amount: score, |
| | | sourceType: '签到', |
| | | remark: `打卡获得碳积分`, |
| | | orderId: orderNo, |
| | | }); |
| | | } |
| | | |
| | | return { |
| | | orderNo, |
| | | orderId: order.id, |
| | | score, |
| | | nickName: user.nickName, |
| | | unionid, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 回收品类列表(全部启用品类,按类别分组展示) |
| | | */ |
| | | async itemList(_sorterId?: string) { |
| | | const list = await this.recycleItemEntity.find({ |
| | | where: { status: 0 }, |
| | | order: { id: 'ASC' }, |
| | | }); |
| | | if (list.length) { |
| | | return list; |
| | | } |
| | | return this.recycleItemEntity.find({ |
| | | order: { id: 'ASC' }, |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 保存现场/预约回收清单 |
| | | */ |
| | | @CoolTransaction() |
| | | async saveOrder( |
| | | param: { |
| | | sorterId: string; |
| | | unionid: string; |
| | | appointmentId?: number; |
| | | items: any[]; |
| | | }, |
| | | queryRunner?: QueryRunner |
| | | ) { |
| | | if (!queryRunner) { |
| | | throw new CoolCommException('事务启动失败'); |
| | | } |
| | | const manager = queryRunner.manager; |
| | | const unionid = String(param.unionid || '').trim(); |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const user = await manager.findOne(UserInfoEntity, { |
| | | where: { unionid }, |
| | | }); |
| | | if (!user || user.status !== 1) { |
| | | throw new CoolCommException('该用户不是平台用户'); |
| | | } |
| | | |
| | | const items = Array.isArray(param.items) ? param.items : []; |
| | | if (!items.length) { |
| | | throw new CoolCommException('请添加回收清单'); |
| | | } |
| | | |
| | | let appointment: RecycleAppointmentEntity = null; |
| | | if (param.appointmentId) { |
| | | appointment = await manager.findOne(RecycleAppointmentEntity, { |
| | | where: { id: Number(param.appointmentId) }, |
| | | }); |
| | | if (!appointment) { |
| | | throw new CoolCommException('预约单不存在'); |
| | | } |
| | | if (appointment.status === '已完成') { |
| | | throw new CoolCommException('该预约已完成'); |
| | | } |
| | | if (appointment.status === '已取消') { |
| | | throw new CoolCommException('该预约已取消'); |
| | | } |
| | | if (appointment.campusUserId && appointment.campusUserId !== unionid) { |
| | | throw new CoolCommException('回收码与预约单用户不一致'); |
| | | } |
| | | } |
| | | |
| | | const itemRows = []; |
| | | let totalWeight = 0; |
| | | let totalAmount = 0; |
| | | let totalCarbonPoint = 0; |
| | | |
| | | for (const row of items) { |
| | | const catalog = await manager.findOne(RecycleItemEntity, { |
| | | where: { itemId: String(row.itemId) }, |
| | | }); |
| | | if (!catalog) { |
| | | throw new CoolCommException(`回收品类不存在:${row.itemId || ''}`); |
| | | } |
| | | const weight = Number(row.weight); |
| | | if (!weight || weight <= 0) { |
| | | throw new CoolCommException(`请填写${catalog.itemName}的计量`); |
| | | } |
| | | const unitPrice = Number(catalog.price || 0); |
| | | const unitCarbon = Number(catalog.carbonPoint || 0); |
| | | const amount = Number((weight * unitPrice).toFixed(2)); |
| | | const carbonPoint = Number((weight * unitCarbon).toFixed(2)); |
| | | itemRows.push({ |
| | | itemId: catalog.itemId, |
| | | itemName: catalog.itemName, |
| | | category: catalog.category, |
| | | weight, |
| | | unitPrice, |
| | | amount, |
| | | carbonPoint, |
| | | }); |
| | | totalWeight += weight; |
| | | totalAmount += amount; |
| | | totalCarbonPoint += carbonPoint; |
| | | } |
| | | |
| | | totalWeight = Number(totalWeight.toFixed(2)); |
| | | totalAmount = Number(totalAmount.toFixed(2)); |
| | | totalCarbonPoint = Number(totalCarbonPoint.toFixed(2)); |
| | | |
| | | const orderNo = this.genOrderNo(appointment ? 'YY' : 'XC'); |
| | | const now = moment().format('YYYY-MM-DD HH:mm:ss'); |
| | | const order = await manager.save(RecycleOrderEntity, { |
| | | orderNo, |
| | | orderType: appointment ? '预约' : '现场', |
| | | campusUserId: unionid, |
| | | departmentId: user.departmentId || sorter.departmentId, |
| | | siteId: sorter.workSiteId, |
| | | sorterId: sorter.businessId, |
| | | appointmentId: appointment ? appointment.id : null, |
| | | status: '已完成', |
| | | totalWeight, |
| | | totalAmount, |
| | | totalCarbonPoint, |
| | | finishTime: now as any, |
| | | }); |
| | | |
| | | await manager.save( |
| | | RecycleOrderItemEntity, |
| | | itemRows.map(item => ({ |
| | | ...item, |
| | | orderNo, |
| | | })) |
| | | ); |
| | | |
| | | if (totalCarbonPoint > 0) { |
| | | await manager |
| | | .createQueryBuilder() |
| | | .update(UserInfoEntity) |
| | | .set({ |
| | | carbonBalance: () => `carbonBalance + ${totalCarbonPoint}`, |
| | | }) |
| | | .where('unionid = :unionid', { unionid }) |
| | | .execute(); |
| | | |
| | | await manager.save(RecycleTransactionEntity, { |
| | | userId: unionid, |
| | | departmentId: user.departmentId, |
| | | type: '收入', |
| | | amount: totalCarbonPoint, |
| | | sourceType: '回收', |
| | | remark: `回收订单 ${orderNo} 获得碳积分`, |
| | | orderId: orderNo, |
| | | }); |
| | | } |
| | | |
| | | if (appointment) { |
| | | await manager.update(RecycleAppointmentEntity, appointment.id, { |
| | | status: '已完成', |
| | | sorterId: sorter.businessId, |
| | | recycleOrderId: orderNo, |
| | | }); |
| | | await manager.save(RecycleAppointmentLogEntity, { |
| | | appointmentId: appointment.id, |
| | | action: '完成', |
| | | operatorType: '分拣员', |
| | | operatorId: sorter.id, |
| | | remark: `上门回收完成,订单 ${orderNo}`, |
| | | }); |
| | | } |
| | | |
| | | return { |
| | | orderNo, |
| | | orderId: order.id, |
| | | totalWeight, |
| | | totalAmount, |
| | | totalCarbonPoint, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 预约列表 |
| | | */ |
| | | async appointmentPage(query: any) { |
| | | const { sorterId, status, page = 1, size = 10, keyWord, pool } = query || {}; |
| | | const sorter = await this.getSorter(sorterId); |
| | | const find = this.recycleAppointmentEntity |
| | | .createQueryBuilder('a') |
| | | .select([ |
| | | 'a.*', |
| | | 'b.nickName AS campusUserName', |
| | | 'c.businessName AS sorterName', |
| | | ]) |
| | | .leftJoin(UserInfoEntity, 'b', 'a.campusUserId = b.unionid') |
| | | .leftJoin(BasicdataSorterEntity, 'c', 'a.sorterId = c.businessId'); |
| | | |
| | | if (sorter.departmentId) { |
| | | find.andWhere( |
| | | '(CAST(a.departmentId AS CHAR) = :deptId OR a.departmentId IS NULL)', |
| | | { deptId: String(sorter.departmentId) } |
| | | ); |
| | | } |
| | | if (pool === 'new') { |
| | | find.andWhere('a.status IN (:...newSt)', { |
| | | newSt: ['新增', '待审核', '待接单', '未知'], |
| | | }); |
| | | find.andWhere( |
| | | "(a.sorterId IS NULL OR CAST(a.sorterId AS CHAR) = '' OR CAST(a.sorterId AS CHAR) = '0')" |
| | | ); |
| | | } else if (pool === 'mine') { |
| | | find.andWhere('a.sorterId = :sid', { sid: sorter.businessId }); |
| | | find.andWhere('a.status NOT IN (:...done)', { |
| | | done: ['已完成', '已取消'], |
| | | }); |
| | | } else if (status) { |
| | | find.andWhere('a.status = :status', { status }); |
| | | } |
| | | if (keyWord) { |
| | | find.andWhere( |
| | | '(a.appointmentNo LIKE :kw OR a.contactName LIKE :kw OR a.contactPhone LIKE :kw OR a.address LIKE :kw)', |
| | | { kw: `%${keyWord}%` } |
| | | ); |
| | | } |
| | | |
| | | find.orderBy('a.appointmentDate', 'DESC'); |
| | | find.addOrderBy('a.id', 'DESC'); |
| | | return this.entityRenderPage(find, { ...query, page, size }, false); |
| | | } |
| | | |
| | | /** |
| | | * 领取预约单到分拣员名下 |
| | | */ |
| | | async claimAppointment(param: { id: number; sorterId: string }) { |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const appointment = await this.recycleAppointmentEntity.findOneBy({ |
| | | id: Number(param.id), |
| | | }); |
| | | if (!appointment) { |
| | | throw new CoolCommException('预约单不存在'); |
| | | } |
| | | if (appointment.status === '已完成') { |
| | | throw new CoolCommException('该预约已完成'); |
| | | } |
| | | if (appointment.status === '已取消') { |
| | | throw new CoolCommException('该预约已取消'); |
| | | } |
| | | if ( |
| | | appointment.sorterId && |
| | | String(appointment.sorterId) !== '0' && |
| | | appointment.sorterId !== sorter.businessId |
| | | ) { |
| | | throw new CoolCommException('该预约已被其他分拣员领取'); |
| | | } |
| | | await this.recycleAppointmentEntity.update(appointment.id, { |
| | | sorterId: sorter.businessId, |
| | | }); |
| | | await this.recycleAppointmentLogEntity.save({ |
| | | appointmentId: appointment.id, |
| | | action: '接单', |
| | | operatorType: '分拣员', |
| | | operatorId: sorter.id, |
| | | remark: '分拣员领取预约单', |
| | | }); |
| | | return this.recycleAppointmentEntity.findOneBy({ id: appointment.id }); |
| | | } |
| | | |
| | | /** |
| | | * 确认预约 |
| | | */ |
| | | @CoolTransaction() |
| | | async confirmAppointment( |
| | | param: { id: number; sorterId: string }, |
| | | queryRunner?: QueryRunner |
| | | ) { |
| | | if (!queryRunner) { |
| | | throw new CoolCommException('事务启动失败'); |
| | | } |
| | | const manager = queryRunner.manager; |
| | | const sorter = await this.getSorter(param.sorterId); |
| | | const appointment = await manager.findOne(RecycleAppointmentEntity, { |
| | | where: { id: Number(param.id) }, |
| | | }); |
| | | if (!appointment) { |
| | | throw new CoolCommException('预约单不存在'); |
| | | } |
| | | if (appointment.status === '已完成') { |
| | | throw new CoolCommException('该预约已完成'); |
| | | } |
| | | if (appointment.status === '已取消') { |
| | | throw new CoolCommException('该预约已取消'); |
| | | } |
| | | if (appointment.status !== '已确认') { |
| | | await manager.update(RecycleAppointmentEntity, appointment.id, { |
| | | status: '已确认', |
| | | sorterId: sorter.businessId, |
| | | }); |
| | | await manager.save(RecycleAppointmentLogEntity, { |
| | | appointmentId: appointment.id, |
| | | action: '确认', |
| | | operatorType: '分拣员', |
| | | operatorId: sorter.id, |
| | | remark: '分拣员确认预约', |
| | | }); |
| | | } |
| | | return await manager.findOne(RecycleAppointmentEntity, { |
| | | where: { id: appointment.id }, |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 扫描预约单 |
| | | */ |
| | | async scanAppointment(appointmentNo: string, sorterId: string) { |
| | | await this.getSorter(sorterId); |
| | | const no = String(appointmentNo || '').trim(); |
| | | if (!no) { |
| | | throw new CoolCommException('请扫描有效的预约单'); |
| | | } |
| | | const appointment = await this.recycleAppointmentEntity.findOneBy({ |
| | | appointmentNo: no, |
| | | }) || (/^\d+$/.test(no) |
| | | ? await this.recycleAppointmentEntity.findOneBy({ id: Number(no) }) |
| | | : null); |
| | | if (!appointment) { |
| | | throw new CoolCommException('预约单不存在'); |
| | | } |
| | | if (appointment.status === '已取消') { |
| | | throw new CoolCommException('该预约已取消'); |
| | | } |
| | | if (appointment.status === '已完成') { |
| | | throw new CoolCommException('该预约已完成'); |
| | | } |
| | | const user = appointment.campusUserId |
| | | ? await this.checkUser(appointment.campusUserId) |
| | | : null; |
| | | return { |
| | | ...appointment, |
| | | campusUser: user, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 生成订单编号 |
| | | */ |
| | | private genOrderNo(prefix: string) { |
| | | const rand = Math.floor(Math.random() * 90 + 10); |
| | | return `${prefix}${moment().format('YYYYMMDDHHmmssSSS')}${rand}`; |
| | | } |
| | | } |
| | |
| | | const user = await this.basicdataSorterEntity.findOneBy({ phone }); |
| | | |
| | | if (user && user.password == md5(password)) { |
| | | |
| | | if (user.status !== 0) { |
| | | throw new CoolCommException('分拣员账号不可用'); |
| | | } |
| | | const info = { |
| | | id: user.businessId, |
| | | } |
| | | }; |
| | | const { expire, refreshExpire } = this.jwtConfig; |
| | | return { |
| | | expire, |
| | | token: await this.generateSorterToken(info), |
| | | refreshExpire, |
| | | refreshToken: await this.generateSorterToken(info, true), |
| | | sorterId: user.businessId, |
| | | businessName: user.businessName, |
| | | phone: user.phone, |
| | | departmentId: user.departmentId, |
| | | workSiteId: user.workSiteId, |
| | | }; |
| | | |
| | | } else { |
| | | throw new CoolCommException('账号或密码错误'); |
| | | } |
| | |
| | | */ |
| | | async generateSorterToken(info, isRefresh = false) { |
| | | const { expire, refreshExpire, secret } = this.jwtConfig; |
| | | const user = await this.basicdataSorterEntity.findOneBy({ businessId: Equal(info.unionid) }); |
| | | const user = await this.basicdataSorterEntity.findOneBy({ |
| | | businessId: Equal(info.id), |
| | | }); |
| | | const tokenInfo = { |
| | | isRefresh, |
| | | ...info, |