| | |
| | | |
| | | const ITEM_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#a855f7', '#06b6d4', '#ef4444', '#94a3b8']; |
| | | |
| | | /** 日表列与垃圾种类编码。首页分类统计只累加其中状态为启用的编码。 */ |
| | | /** 日表列与垃圾种类编码,首页 KPI/趋势口径以本表为准,不读种类表启用状态。 */ |
| | | const WASTE_FIELDS: { |
| | | code: string; |
| | | weight: string; |
| | |
| | | group: (idx === '006' ? 'elec' : idx === '004' ? 'fabric' : undefined) as 'elec' | 'fabric' | undefined, |
| | | })), |
| | | ]; |
| | | |
| | | /** 五大类;子类已滚入 SW62,总量不再把 SW62001–007 加一遍。 */ |
| | | const MAIN_WASTE_FIELDS = WASTE_FIELDS.filter(f => /^SW6[0-4]$/.test(f.code)); |
| | | |
| | | type OrdersByType = { |
| | | axis: string[]; |
| | |
| | | safe('年度站点日汇总', () => this.sumSite(yearStart, yearEnd), emptySum), |
| | | ]); |
| | | |
| | | const enabledWaste = await safe( |
| | | '启用垃圾种类', |
| | | () => this.enabledWaste(), |
| | | { names: new Map<string, string>(), codes: new Set<string>() } |
| | | const wasteNames = await safe( |
| | | '垃圾种类名称', |
| | | () => this.wasteNames(), |
| | | new Map<string, string>() |
| | | ); |
| | | const wasteMix = this.buildWasteMix(period, enabledWaste); |
| | | const recycleItems = this.buildRecycleItems(period, enabledWaste); |
| | | const wasteMix = this.buildWasteMix(period, wasteNames); |
| | | const recycleItems = this.buildRecycleItems(period, wasteNames); |
| | | const { axis, trend } = await safe( |
| | | '回收趋势', |
| | | () => this.buildTrend(range || 'month', dates, enabledWaste.codes), |
| | | () => this.buildTrend(range || 'month', dates), |
| | | { axis: [], trend: { weight: [], value: [], carbon: [] } } |
| | | ); |
| | | const yearCarbon = await safe( |
| | | '年度碳减排', |
| | | () => this.buildYearCarbon(enabledWaste.codes), |
| | | () => this.buildYearCarbon(), |
| | | Array.from({ length: 12 }, () => 0) |
| | | ); |
| | | const ranks = await safe( |
| | |
| | | sites: [], |
| | | }); |
| | | |
| | | 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); |
| | | const periodStat = this.statKpi(period); |
| | | const todayStat = this.statKpi(todayRow); |
| | | const totalStat = this.statKpi(totalRow); |
| | | const yearStat = this.statKpi(yearRow); |
| | | |
| | | return { |
| | | range: range || 'month', |
| | |
| | | .addSelect('SUM(a.timeSw63)', 'timeSw63') |
| | | .addSelect('SUM(a.timeSw64)', 'timeSw64') |
| | | .addSelect('SUM(a.carbonTotal)', 'carbonTotal') |
| | | .addSelect('SUM(a.carbonSw60)', 'carbonSw60') |
| | | .addSelect('SUM(a.carbonSw61)', 'carbonSw61') |
| | | .addSelect('SUM(a.carbonSw62)', 'carbonSw62') |
| | | .addSelect('SUM(a.carbonSw63)', 'carbonSw63') |
| | | .addSelect('SUM(a.carbonSw64)', 'carbonSw64') |
| | | .addSelect('SUM(a.estimatedSalesSw62)', 'estimatedSalesSw62'); |
| | | |
| | | for (const i of ['001', '002', '003', '004', '005', '006', '007']) { |
| | |
| | | return (await qb.getRawOne()) || {}; |
| | | } |
| | | |
| | | /** 名称取全部种类;codes 仍只含启用项,供 KPI/趋势沿用原口径。分类图不再用 codes 过滤。 */ |
| | | private async enabledWaste() { |
| | | /** 种类名称仅作展示,不参与 KPI 过滤。 */ |
| | | private async wasteNames() { |
| | | const list = await this.wasteTypeRepo.find({ |
| | | select: ['code', 'name', 'status'], |
| | | select: ['code', 'name'], |
| | | }); |
| | | const names = new Map<string, string>(); |
| | | const codes = new Set<string>(); |
| | | list.forEach(e => { |
| | | const code = String(e.code).toUpperCase(); |
| | | names.set(code, e.name); |
| | | if (Number(e.status) === 1) { |
| | | codes.add(code); |
| | | } |
| | | names.set(String(e.code).toUpperCase(), e.name); |
| | | }); |
| | | return { names, codes }; |
| | | return names; |
| | | } |
| | | |
| | | /** 只累加启用种类对应的日表列,避免禁用种类进入重量、碳排和销售额。 */ |
| | | 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); |
| | | /** 按 WASTE_FIELDS 汇总;总量用五大类,分组指标用 group 字段。 */ |
| | | private statKpi(row: any) { |
| | | const sum = ( |
| | | fields: typeof WASTE_FIELDS, |
| | | pick: (f: (typeof WASTE_FIELDS)[number]) => string | undefined |
| | | ) => fields.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); |
| | | WASTE_FIELDS.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), |
| | | weight: sum(MAIN_WASTE_FIELDS, f => f.weight), |
| | | carbon: sum(MAIN_WASTE_FIELDS, f => f.carbon), |
| | | sales: sum(MAIN_WASTE_FIELDS, f => f.sales), |
| | | recyclable: ofGroup('recyclable'), |
| | | bulk: ofGroup('bulk'), |
| | | elec: ofGroup('elec'), |
| | |
| | | }; |
| | | } |
| | | |
| | | 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); |
| | | private kpiExpr(pick: (f: (typeof WASTE_FIELDS)[number]) => string | undefined) { |
| | | const cols = MAIN_WASTE_FIELDS.map(pick).filter((col): col is string => !!col); |
| | | if (!cols.length) return '0'; |
| | | return cols.map(col => `IFNULL(a.${col},0)`).join(' + '); |
| | | } |
| | | |
| | | /** 首页分类全量返回,不按种类启用状态裁剪;0 值由前端图表隐藏。 */ |
| | | private buildWasteMix(row: any, waste: { names: Map<string, string>; codes: Set<string> }) { |
| | | private buildWasteMix(row: any, names: Map<string, string>) { |
| | | const items = [ |
| | | { code: 'SW62', key: 'Sw62' }, |
| | | { code: 'SW64', key: 'Sw64' }, |
| | |
| | | { code: 'SW60', key: 'Sw60' }, |
| | | ]; |
| | | return items.map(e => ({ |
| | | name: waste.names.get(e.code) || this.defaultWasteName(e.code), |
| | | 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, |
| | |
| | | } |
| | | |
| | | /** 可回收 7 子类全量返回;碳值保留符号(减排可为负),图表侧取绝对值展示。 */ |
| | | private buildRecycleItems(row: any, waste: { names: Map<string, string>; codes: Set<string> }) { |
| | | private buildRecycleItems(row: any, names: Map<string, string>) { |
| | | const fallback = ['废纸', '塑料', '金属', '织物', '玻璃', '电子产品', '其他可回收']; |
| | | return ['001', '002', '003', '004', '005', '006', '007'] |
| | | .map((idx, i) => ({ idx, i, code: `SW62${idx}` })) |
| | | .map(e => ({ |
| | | name: waste.names.get(e.code) || fallback[e.i], |
| | | name: 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), |
| | |
| | | |
| | | private async buildTrend( |
| | | range: string, |
| | | dates: { start: string; end: string; startTime: string; endTime: string }, |
| | | codes: Set<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 qb = this.dailySite |
| | | .createQueryBuilder('a') |
| | | .select('a.date', 'date') |
| | | .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') |
| | | .addSelect(`SUM(${this.kpiExpr(f => f.weight)})`, 'weight') |
| | | .addSelect(`SUM(${this.kpiExpr(f => f.sales)})`, 'value') |
| | | .addSelect(`SUM(${this.kpiExpr(f => f.carbon)})`, 'carbon') |
| | | .where('a.date BETWEEN :start AND :end', { start: trendStart, end: trendEnd }) |
| | | .groupBy('a.date') |
| | | .orderBy('a.date', 'ASC'); |
| | |
| | | return { axis, trend: { weight, value, carbon } }; |
| | | } |
| | | |
| | | private async buildYearCarbon(codes: Set<string>) { |
| | | 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(${this.enabledExpr(codes, f => f.carbon)})`, 'carbon') |
| | | .addSelect(`SUM(${this.kpiExpr(f => f.carbon)})`, 'carbon') |
| | | .where('a.date BETWEEN :start AND :end', { start, end }) |
| | | .groupBy('MONTH(a.date)'); |
| | | this.applyDept(qb, 'a'); |