wangzhibo
4 天以前 c19f1f7e21c93d1f13b4f44a8a615f65af577559
添加 android 端接口
16个文件已添加
13个文件已修改
3478 ■■■■■ 已修改文件
src/config/config.local.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/db.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/basicdata/controller/app/options.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/basicdata/service/options.ts 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/operation/controller/admin/wastesale.ts 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/operation/controller/app/wastesale.ts 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/operation/entity/wastesale.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/operation/service/wastesale.ts 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/order/controller/app/sorter.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/order/service/info.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/controller/app/sorter.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/controller/app/sorterattendance.ts 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/entity/sorterhealth.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/service/sorterapp.ts 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/service/sorterattendance.ts 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/controller/admin/dailysorter.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/controller/admin/screen.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/entity/dailysorter.ts 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/ExecutorSorterDaily.ts 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/dailysorter.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/screen.ts 869 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/taskservice.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/service/taskservicesorter.ts 922 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/shop/controller/app/sorter.ts 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/shop/entity/recycleappointment.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/shop/entity/recycleappointmentlog.ts 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/shop/entity/recycleorder.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/shop/service/sorter.ts 531 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/user/service/login.ts 16 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/config.local.ts
@@ -6,6 +6,10 @@
 * 本地开发 npm run dev 读取的配置文件
 */
export default {
  koa: {
    port: 8001,
    hostname: '0.0.0.0',
  },
  typeorm: {
    dataSource: {
      default: {
src/modules/base/db.json
@@ -22,6 +22,20 @@
          "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>",
src/modules/basicdata/controller/app/options.ts
New file
@@ -0,0 +1,40 @@
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));
  }
}
src/modules/basicdata/service/options.ts
New file
@@ -0,0 +1,95 @@
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' },
    });
  }
}
src/modules/operation/controller/admin/wastesale.ts
@@ -2,7 +2,7 @@
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'
@@ -27,7 +27,7 @@
      '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: [
@@ -50,9 +50,9 @@
        type: 'leftJoin',
      },
      {
        entity: BaseSysUserEntity,
        entity: BasicdataSorterEntity,
        alias: 'e',
        condition: 'a.handlerId = e.userId',
        condition: 'a.handlerId = e.businessId',
        type: 'leftJoin',
      },
      {
src/modules/operation/controller/app/wastesale.ts
New file
@@ -0,0 +1,31 @@
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));
  }
}
src/modules/operation/entity/wastesale.ts
@@ -37,8 +37,8 @@
  totalPrice: number;
  @Index()
  @Column({ comment: '经手人ID', type: 'bigint' })
  handlerId: number;
  @Column({ comment: '经手人ID', length: 64 })
  handlerId: string;
  handlerName: string;
src/modules/operation/service/wastesale.ts
@@ -1,8 +1,12 @@
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';
/**
 * 可回收物收益服务
@@ -12,14 +16,97 @@
  @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);
  }
}
src/modules/order/controller/app/sorter.ts
New file
@@ -0,0 +1,34 @@
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));
  }
}
src/modules/order/service/info.ts
@@ -7,6 +7,7 @@
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';
@@ -14,6 +15,7 @@
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';
/**
@@ -29,6 +31,9 @@
  @InjectEntityModel(MarketCouponUserEntity)
  marketCouponUserEntity: Repository<MarketCouponUserEntity>;
  @InjectEntityModel(BasicdataSorterEntity)
  basicdataSorterEntity: Repository<BasicdataSorterEntity>;
  @Inject()
  orderGoodsService: OrderGoodsService;
@@ -418,6 +423,72 @@
  }
  /**
   * 分拣员查看校园商城订单(按学校过滤)
   */
  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
   */
src/modules/push/controller/app/sorter.ts
New file
@@ -0,0 +1,43 @@
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));
  }
}
src/modules/push/controller/app/sorterattendance.ts
New file
@@ -0,0 +1,31 @@
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));
  }
}
src/modules/push/entity/sorterhealth.ts
@@ -19,22 +19,26 @@
    @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;
}
src/modules/push/service/sorterapp.ts
New file
@@ -0,0 +1,124 @@
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);
  }
}
src/modules/push/service/sorterattendance.ts
@@ -1,14 +1,112 @@
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,
    };
  }
}
src/modules/report/controller/admin/dailysorter.ts
New file
@@ -0,0 +1,33 @@
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 {}
src/modules/report/controller/admin/screen.ts
New file
@@ -0,0 +1,17 @@
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));
  }
}
src/modules/report/entity/dailysorter.ts
New file
@@ -0,0 +1,191 @@
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;
}
src/modules/report/service/ExecutorSorterDaily.ts
New file
@@ -0,0 +1,84 @@
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',
                });
            }
        }
    }
}
src/modules/report/service/dailysorter.ts
New file
@@ -0,0 +1,14 @@
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>;
}
src/modules/report/service/screen.ts
New file
@@ -0,0 +1,869 @@
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,
    };
  }
}
src/modules/report/service/taskservice.ts
@@ -67,10 +67,12 @@
      { 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 },         // 新增
    ];
src/modules/report/service/taskservicesorter.ts
New file
@@ -0,0 +1,922 @@
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();
  }
}
src/modules/shop/controller/app/sorter.ts
New file
@@ -0,0 +1,86 @@
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)
    );
  }
}
src/modules/shop/entity/recycleappointment.ts
@@ -50,7 +50,7 @@
    @Column({ comment:"预计重量",default: 0 })
    expectedWeight:number;
    @Column({ comment: "状态", dict: ['待审核', '待接单', '已接单', '上门中', '已完成','已取消', '未知'], default: '未知' })
    @Column({ comment: "状态", dict: ['新增', '待审核', '待接单', '已确认', '已接单', '上门中', '已完成', '已取消', '未知'], default: '新增' })
    status: string;
    
    @Index()
src/modules/shop/entity/recycleappointmentlog.ts
@@ -16,6 +16,7 @@
            "创建预约",
            "审核通过",
            "分配人员",
            "确认",
            "接单",
            "到达",
            "开始回收",
src/modules/shop/entity/recycleorder.ts
@@ -10,7 +10,7 @@
    @Column({ comment: "订单编号" })
    orderNo: string;
    @Column({ comment: "订单类型", dict: ['现场', '预约', '大屏', '活动'], default: '现场' })
    @Column({ comment: "订单类型", dict: ['现场', '预约', '大屏', '活动', '打卡'], default: '现场' })
    orderType: string;
    @Index()
src/modules/shop/service/sorter.ts
New file
@@ -0,0 +1,531 @@
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}`;
  }
}
src/modules/user/service/login.ts
@@ -287,18 +287,24 @@
    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('账号或密码错误');
    }
@@ -346,7 +352,9 @@
   */
  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,