wangrong
2026-09-24 98ce961272c596468a4cb4b2498a3c70048e4aed
src/modules/report/service/screen.ts
@@ -28,6 +28,30 @@
const ITEM_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#a855f7', '#06b6d4', '#ef4444', '#94a3b8'];
/** 日表列与垃圾种类编码。首页分类统计只累加其中状态为启用的编码。 */
const WASTE_FIELDS: {
  code: string;
  weight: string;
  carbon: string;
  time: string;
  sales?: string;
  group?: 'recyclable' | 'bulk' | 'elec' | 'fabric';
}[] = [
  { code: 'SW60', weight: 'weightSw60', carbon: 'carbonSw60', time: 'timeSw60' },
  { code: 'SW61', weight: 'weightSw61', carbon: 'carbonSw61', time: 'timeSw61' },
  { code: 'SW62', weight: 'weightSw62', carbon: 'carbonSw62', time: 'timeSw62', sales: 'estimatedSalesSw62', group: 'recyclable' },
  { code: 'SW63', weight: 'weightSw63', carbon: 'carbonSw63', time: 'timeSw63', group: 'bulk' },
  { code: 'SW64', weight: 'weightSw64', carbon: 'carbonSw64', time: 'timeSw64' },
  ...['001', '002', '003', '004', '005', '006', '007'].map(idx => ({
    code: `SW62${idx}`,
    weight: `weightSw62${idx}`,
    carbon: `carbonSw62${idx}`,
    time: `timeSw62${idx}`,
    sales: `estimatedSalesSw62${idx}`,
    group: (idx === '006' ? 'elec' : idx === '004' ? 'fabric' : undefined) as 'elec' | 'fabric' | undefined,
  })),
];
type OrdersByType = {
  axis: string[];
  '现场回收': number[];
@@ -119,15 +143,23 @@
      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 enabledWaste = await safe(
      '启用垃圾种类',
      () => this.enabledWaste(),
      { names: new Map<string, string>(), codes: new Set<string>() }
    );
    const wasteMix = this.buildWasteMix(period, enabledWaste);
    const recycleItems = this.buildRecycleItems(period, enabledWaste);
    const { axis, trend } = await safe(
      '回收趋势',
      () => this.buildTrend(range || 'month', dates),
      () => this.buildTrend(range || 'month', dates, enabledWaste.codes),
      { axis: [], trend: { weight: [], value: [], carbon: [] } }
    );
    const yearCarbon = await safe('年度碳减排', () => this.buildYearCarbon(), Array.from({ length: 12 }, () => 0));
    const yearCarbon = await safe(
      '年度碳减排',
      () => this.buildYearCarbon(enabledWaste.codes),
      Array.from({ length: 12 }, () => 0)
    );
    const ranks = await safe(
      '班级/专业/院系排行',
      () => this.buildOrgRanks(dates.startTime, dates.endTime, unavailable),
@@ -176,30 +208,30 @@
      sites: [],
    });
    const weightKg = n(period.weightTotal);
    const recyclableKg = n(period.weightSw62);
    const carbonKg = n(period.carbonTotal);
    const totalCarbonKg = n(totalRow.carbonTotal);
    const periodStat = this.statEnabled(period, enabledWaste.codes);
    const todayStat = this.statEnabled(todayRow, enabledWaste.codes);
    const totalStat = this.statEnabled(totalRow, enabledWaste.codes);
    const yearStat = this.statEnabled(yearRow, enabledWaste.codes);
    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),
        todayWeightKg: round(todayStat.weight, 1),
        periodWeightTon: round(periodStat.weight / 1000, 2),
        resourceRate: periodStat.weight > 0 ? round((periodStat.recyclable / periodStat.weight) * 100, 1) : 0,
        periodValue: round(periodStat.sales, 0),
        periodCarbonTon: round(periodStat.carbon / 1000, 2),
        totalWeightTon: round(totalStat.weight / 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),
        recyclableTon: round(periodStat.recyclable / 1000, 2),
        bulkTon: round(periodStat.bulk / 1000, 2),
        elecTon: round(periodStat.elec / 1000, 2),
        fabricTon: round(periodStat.fabric / 1000, 2),
        yearCarbonTon: round(yearStat.carbon / 1000, 1),
        monthCarbonTon: round(periodStat.carbon / 1000, 2),
        totalCarbonTon: round(totalStat.carbon / 1000, 1),
        greenActions: users.greenActions,
        newParticipants: users.newParticipants,
        participateRate: users.participateRate,
@@ -339,23 +371,57 @@
    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 async enabledWaste() {
    const list = await this.wasteTypeRepo.find({
      where: { status: 1 },
      select: ['code', 'name'],
    });
    const names = new Map<string, string>();
    const codes = new Set<string>();
    list.forEach(e => {
      const code = String(e.code).toUpperCase();
      codes.add(code);
      names.set(code, e.name);
    });
    return { names, codes };
  }
  private buildWasteMix(row: any, names: Map<string, string>) {
  /** 只累加启用种类对应的日表列,避免禁用种类进入重量、碳排和销售额。 */
  private statEnabled(row: any, codes: Set<string>) {
    const on = WASTE_FIELDS.filter(f => codes.has(f.code));
    const sum = (pick: (f: (typeof WASTE_FIELDS)[number]) => string | undefined) =>
      on.reduce((s, f) => s + n(row?.[pick(f)]), 0);
    const ofGroup = (group: 'recyclable' | 'bulk' | 'elec' | 'fabric') =>
      on.filter(f => f.group === group).reduce((s, f) => s + n(row?.[f.weight]), 0);
    return {
      weight: sum(f => f.weight),
      carbon: sum(f => f.carbon),
      sales: sum(f => f.sales),
      recyclable: ofGroup('recyclable'),
      bulk: ofGroup('bulk'),
      elec: ofGroup('elec'),
      fabric: ofGroup('fabric'),
    };
  }
  private enabledExpr(codes: Set<string>, pick: (f: (typeof WASTE_FIELDS)[number]) => string | undefined) {
    const cols = WASTE_FIELDS.filter(f => codes.has(f.code))
      .map(pick)
      .filter((col): col is string => !!col);
    if (!cols.length) return '0';
    return cols.map(col => `IFNULL(a.${col},0)`).join(' + ');
  }
  private buildWasteMix(row: any, waste: { names: Map<string, string>; codes: Set<string> }) {
    const items = [
      { code: 'SW62', key: 'Sw62' },
      { code: 'SW64', key: 'Sw64' },
      { code: 'SW63', key: 'Sw63' },
      { code: 'SW61', key: 'Sw61' },
      { code: 'SW60', key: 'Sw60' },
    ];
    ].filter(e => waste.codes.has(e.code));
    return items.map(e => ({
      name: names.get(e.code) || this.defaultWasteName(e.code),
      name: waste.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,
@@ -376,31 +442,26 @@
    );
  }
  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',
    ];
  private buildRecycleItems(row: any, waste: { names: Map<string, string>; codes: Set<string> }) {
    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],
      };
    });
    return ['001', '002', '003', '004', '005', '006', '007']
      .map((idx, i) => ({ idx, i, code: `SW62${idx}` }))
      .filter(e => waste.codes.has(e.code))
      .map(e => ({
        name: waste.names.get(e.code) || fallback[e.i],
        code: e.code,
        weight: round(n(row[`weightSw62${e.idx}`]), 1),
        value: round(n(row[`estimatedSalesSw62${e.idx}`]), 0),
        carbon: round(n(row[`carbonSw62${e.idx}`]), 1),
        color: ITEM_COLORS[e.i],
      }));
  }
  private async buildTrend(range: string, dates: { start: string; end: string; startTime: string; endTime: string }) {
  private async buildTrend(
    range: string,
    dates: { start: string; end: string; startTime: string; endTime: string },
    codes: Set<string>
  ) {
    const trendStart =
      range === 'today' ? moment().subtract(6, 'day').format('YYYY-MM-DD') : dates.start;
    const trendEnd = dates.end;
@@ -408,9 +469,9 @@
    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')
      .addSelect(`SUM(${this.enabledExpr(codes, f => f.weight)})`, 'weight')
      .addSelect(`SUM(${this.enabledExpr(codes, f => f.sales)})`, 'value')
      .addSelect(`SUM(${this.enabledExpr(codes, f => f.carbon)})`, 'carbon')
      .where('a.date BETWEEN :start AND :end', { start: trendStart, end: trendEnd })
      .groupBy('a.date')
      .orderBy('a.date', 'ASC');
@@ -463,13 +524,13 @@
    return { axis, trend: { weight, value, carbon } };
  }
  private async buildYearCarbon() {
  private async buildYearCarbon(codes: Set<string>) {
    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')
      .addSelect(`SUM(${this.enabledExpr(codes, f => f.carbon)})`, 'carbon')
      .where('a.date BETWEEN :start AND :end', { start, end })
      .groupBy('MONTH(a.date)');
    this.applyDept(qb, 'a');