wangrong
2026-09-24 bf9400d4198094194439214b5e90036b2f040052
src/modules/report/data/screen-merge.ts
@@ -1,12 +1,5 @@
import type { ScreenData, TimeRange } from './screen';
import { demoScreen } from './screen-demo';
import {
   FieldTracker,
   KPI_GROUPS,
   groupHasLive,
   resolveDataMode,
   type ScreenDataMode
} from './screen-field-meta';
import { FieldTracker, resolveDataMode, type ScreenDataMode } from './screen-field-meta';
import { structureScreen } from './screen-structure';
export type { ScreenDataMode };
@@ -18,271 +11,120 @@
   liveFields: string[];
}
function sum(nums: number[]) {
   return nums.reduce((a, b) => a + (Number(b) || 0), 0);
function num(v: unknown, fallback = 0) {
   const n = Number(v);
   return Number.isFinite(n) ? n : fallback;
}
function hasPositive(nums: number[]) {
   return nums.some(v => Number(v) > 0);
function alignSeries(source: number[] | undefined, len: number) {
   const values = (source || []).map(v => num(v));
   if (!values.length) return Array.from({ length: len }, () => 0);
   if (values.length === len) return values;
   if (values.length > len) return values.slice(values.length - len);
   return [...Array.from({ length: len - values.length }, () => 0), ...values];
}
function hasRankData(list: any[]) {
   return list.length > 0 && list.some(e => Number(e?.people) > 0 || Number(e?.weight) > 0);
}
function pickKpis(
   live: Record<string, number> | undefined,
   demo: Record<string, number>,
   ctx: FieldTracker
) {
   const out = { ...demo };
   if (!live) {
      Object.values(KPI_GROUPS)
         .flat()
         .forEach(key => ctx.markDemo(`kpis.${key}`));
      return out;
   }
   for (const [group, keys] of Object.entries(KPI_GROUPS)) {
      if (groupHasLive(keys, live)) {
         keys.forEach(key => {
            const v = Number(live[key]);
            out[key] = Number.isFinite(v) ? v : demo[key];
            ctx.markLive(`kpis.${key}`);
         });
      } else {
         keys.forEach(key => ctx.markDemo(`kpis.${key}`));
      }
   }
   return out;
}
function pickArray<T>(
   field: string,
   live: T[] | undefined,
   demo: T[],
   ctx: FieldTracker,
   valid: (v: T[]) => boolean
) {
   if (live && valid(live)) {
      ctx.markLive(field);
      return live;
   }
   ctx.markDemo(field);
   return demo;
}
function pickTrend(
   field: string,
   live: { weight: number[]; value: number[]; carbon: number[] } | undefined,
   demo: ScreenData['trend'],
   axis: string[],
   ctx: FieldTracker
) {
   const valid =
      live &&
      live.weight?.length &&
      (sum(live.weight) > 0 || sum(live.value) > 0 || sum(live.carbon) > 0);
   if (valid) {
      ctx.markLive(field);
      return {
         weight: alignSeries(live!.weight, axis.length, live!.weight[live!.weight.length - 1] || 0),
         value: alignSeries(live!.value, axis.length, live!.value[live!.value.length - 1] || 0),
         carbon: alignSeries(live!.carbon, axis.length, live!.carbon[live!.carbon.length - 1] || 0)
      };
   }
   ctx.markDemo(field);
   return alignTrend(demo, axis.length);
}
function alignSeries(source: number[], len: number, fill = 0) {
   if (!source?.length) return Array.from({ length: len }, () => fill);
   if (source.length === len) return [...source];
   if (source.length > len) return source.slice(source.length - len);
   return [...Array.from({ length: len - source.length }, () => fill), ...source];
}
function alignTrend(trend: ScreenData['trend'], len: number) {
   return {
      weight: alignSeries(trend.weight, len),
      value: alignSeries(trend.value, len),
      carbon: alignSeries(trend.carbon, len)
   };
}
function pickWasteMix(
   live: ScreenData['wasteMix'] | undefined,
   demo: ScreenData['wasteMix'],
   ctx: FieldTracker
) {
   if (
      live?.length &&
      live.some(e => Number(e.weight) > 0 || Number(e.count) > 0 || Number(e.value) > 0)
   ) {
      ctx.markLive('wasteMix');
      return live.map((item, i) => ({
         ...demo[i],
         ...item,
         color: item.color || demo[i]?.color
      }));
   }
   ctx.markDemo('wasteMix');
   return demo;
}
function pickRecycleItems(
   live: ScreenData['recycleItems'] | undefined,
   demo: ScreenData['recycleItems'],
   ctx: FieldTracker
) {
   if (live?.length && live.some(e => Number(e.weight) > 0 || Number(e.value) > 0 || Number(e.carbon) > 0)) {
      ctx.markLive('recycleItems');
      return live.map((item, i) => ({
         ...demo[i],
         ...item,
         color: item.color || demo[i]?.color
      }));
   }
   ctx.markDemo('recycleItems');
   return demo;
}
function pickPointFlow(
   live: ScreenData['pointFlow'] | undefined,
   demo: ScreenData['pointFlow'],
   ctx: FieldTracker
) {
   const out = { ...demo };
   if (!live) {
      ctx.markDemo('pointFlow');
      (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key =>
         ctx.markDemo(`pointFlow.${key}`)
      );
      return out;
   }
   let hasLive = false;
   (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key => {
      if (Number(live[key]) > 0) {
         out[key] = live[key];
         ctx.markLive(`pointFlow.${key}`);
         hasLive = true;
      } else {
         ctx.markDemo(`pointFlow.${key}`);
      }
   });
   if (hasLive) ctx.markLive('pointFlow');
   else ctx.markDemo('pointFlow');
   return out;
}
function pickOrdersByType(
   live: ScreenData['ordersByType'] | undefined,
   demo: ScreenData['ordersByType'],
   ctx: FieldTracker
) {
   const keys = ['现场回收', '预约上门', '大屏投递', '活动回收'] as const;
   const liveValid = live?.axis?.length && keys.some(k => hasPositive(live[k] || []));
   if (liveValid) {
      ctx.markLive('ordersByType');
      return live!;
   }
   ctx.markDemo('ordersByType');
   return demo;
}
function pickAppointmentFlow(
   live: ScreenData['appointmentFlow'] | undefined,
   demo: ScreenData['appointmentFlow'],
   ctx: FieldTracker
) {
   if (live?.length && live.some(e => Number(e.value) > 0)) {
      ctx.markLive('appointmentFlow');
      return live;
   }
   ctx.markDemo('appointmentFlow');
   return demo;
}
function pickSorterStatus(
   live: ScreenData['sorterStatus'] | undefined,
   demo: ScreenData['sorterStatus'],
   ctx: FieldTracker
) {
   if (live?.length && sum(live.map(e => Number(e.value))) > 0) {
      ctx.markLive('sorterStatus');
      return live;
   }
   ctx.markDemo('sorterStatus');
   return demo;
}
function pickAxis(
   live: ScreenData | null | undefined,
   demo: ScreenData,
   structure: ScreenData,
   ctx: FieldTracker
) {
   const useLiveAxis =
      live?.axis?.length &&
      (sum(live.trend?.weight || []) > 0 ||
         sum(live.participateTrend || []) > 0 ||
         sum(live.deliverTrend || []) > 0 ||
         live.axis.length > 1);
   if (useLiveAxis) {
      ctx.markLive('axis');
      return live!.axis;
   }
   ctx.markDemo('axis');
   return demo.axis.length ? demo.axis : structure.axis;
}
/** 结构 + 演示 + 真实数据按字段/分组合并 */
/** 用接口结果覆盖零值结构。缺字段保持 0 或空列表,不再回落到演示数据。 */
export function resolveScreenData(live: ScreenData | null | undefined, range: TimeRange): ResolvedScreenData {
   const structure = structureScreen(range);
   const demo = demoScreen(range);
   const base = structureScreen(range);
   const ctx = new FieldTracker();
   const axis = pickAxis(live, demo, structure, ctx);
   const kpis = pickKpis(live?.kpis, demo.kpis, ctx);
   const wasteMix = pickWasteMix(live?.wasteMix, demo.wasteMix, ctx);
   const recycleItems = pickRecycleItems(live?.recycleItems, demo.recycleItems, ctx);
   const trend = pickTrend('trend', live?.trend, demo.trend, axis, ctx);
   let yearCarbon = demo.yearCarbon;
   if (live?.yearCarbon?.length && sum(live.yearCarbon) > 0) {
      yearCarbon = live.yearCarbon;
      ctx.markLive('yearCarbon');
   } else {
      ctx.markDemo('yearCarbon');
   if (!live) {
      return {
         data: base,
         mode: 'live',
         demoFields: [],
         liveFields: []
      };
   }
   const deptRank = pickArray('deptRank', live?.deptRank, demo.deptRank, ctx, hasRankData);
   const classRank = pickArray('classRank', live?.classRank, demo.classRank, ctx, hasRankData);
   const majorRank = pickArray('majorRank', live?.majorRank, demo.majorRank, ctx, hasRankData);
   const pointFlow = pickPointFlow(live?.pointFlow, demo.pointFlow, ctx);
   const axis = live.axis?.length ? live.axis : base.axis;
   ctx.markLive('axis');
   let participateTrend = alignSeries(demo.participateTrend, axis.length);
   if (live?.participateTrend?.length && sum(live.participateTrend) > 0) {
      participateTrend = alignSeries(live.participateTrend, axis.length);
      ctx.markLive('participateTrend');
   } else {
      ctx.markDemo('participateTrend');
   }
   const kpis = { ...base.kpis };
   Object.keys(kpis).forEach(key => {
      if (live.kpis && live.kpis[key] != null) {
         kpis[key] = num(live.kpis[key]);
      }
      ctx.markLive(`kpis.${key}`);
   });
   let deliverTrend = alignSeries(demo.deliverTrend, axis.length);
   if (live?.deliverTrend?.length && sum(live.deliverTrend) > 0) {
      deliverTrend = alignSeries(live.deliverTrend, axis.length);
      ctx.markLive('deliverTrend');
   } else {
      ctx.markDemo('deliverTrend');
   }
   const wasteMix = live.wasteMix?.length
      ? live.wasteMix.map((item, i) => ({
            ...base.wasteMix[i],
            ...item,
            weight: num(item.weight),
            value: num(item.value),
            count: num(item.count),
            color: item.color || base.wasteMix[i]?.color
         }))
      : base.wasteMix;
   ctx.markLive('wasteMix');
   const ordersByType = pickOrdersByType(live?.ordersByType, demo.ordersByType, ctx);
   const appointmentFlow = pickAppointmentFlow(live?.appointmentFlow, demo.appointmentFlow, ctx);
   const sorterStatus = pickSorterStatus(live?.sorterStatus, demo.sorterStatus, ctx);
   const vehicles = pickArray('vehicles', live?.vehicles, demo.vehicles, ctx, v => v.length > 0);
   const sites = pickArray('sites', live?.sites, demo.sites, ctx, v => v.length > 0);
   const recycleItems = live.recycleItems?.length
      ? live.recycleItems.map((item, i) => ({
            ...base.recycleItems[i],
            ...item,
            weight: num(item.weight),
            value: num(item.value),
            carbon: num(item.carbon),
            color: item.color || base.recycleItems[i]?.color
         }))
      : base.recycleItems;
   ctx.markLive('recycleItems');
   const trend = {
      weight: alignSeries(live.trend?.weight, axis.length),
      value: alignSeries(live.trend?.value, axis.length),
      carbon: alignSeries(live.trend?.carbon, axis.length)
   };
   ctx.markLive('trend');
   const yearCarbon = live.yearCarbon?.length
      ? alignSeries(live.yearCarbon, 12)
      : base.yearCarbon;
   ctx.markLive('yearCarbon');
   const deptRank = Array.isArray(live.deptRank) ? live.deptRank : [];
   const classRank = Array.isArray(live.classRank) ? live.classRank : [];
   const majorRank = Array.isArray(live.majorRank) ? live.majorRank : [];
   ctx.markLive('deptRank');
   ctx.markLive('classRank');
   ctx.markLive('majorRank');
   const pointFlow = {
      issued: num(live.pointFlow?.issued),
      transfer: num(live.pointFlow?.transfer),
      donate: num(live.pointFlow?.donate),
      mall: num(live.pointFlow?.mall),
      balance: num(live.pointFlow?.balance)
   };
   ctx.markLive('pointFlow');
   const participateTrend = alignSeries(live.participateTrend, axis.length);
   const deliverTrend = alignSeries(live.deliverTrend, axis.length);
   ctx.markLive('participateTrend');
   ctx.markLive('deliverTrend');
   const ordersByType = live.ordersByType?.axis?.length
      ? {
            axis: live.ordersByType.axis,
            现场回收: alignSeries(live.ordersByType.现场回收, live.ordersByType.axis.length),
            预约上门: alignSeries(live.ordersByType.预约上门, live.ordersByType.axis.length),
            大屏投递: alignSeries(live.ordersByType.大屏投递, live.ordersByType.axis.length),
            活动回收: alignSeries(live.ordersByType.活动回收, live.ordersByType.axis.length)
         }
      : base.ordersByType;
   ctx.markLive('ordersByType');
   const appointmentFlow = live.appointmentFlow?.length ? live.appointmentFlow : base.appointmentFlow;
   const sorterStatus = live.sorterStatus?.length ? live.sorterStatus : base.sorterStatus;
   const vehicles = Array.isArray(live.vehicles) ? live.vehicles : [];
   const sites = Array.isArray(live.sites) ? live.sites : [];
   ctx.markLive('appointmentFlow');
   ctx.markLive('sorterStatus');
   ctx.markLive('vehicles');
   ctx.markLive('sites');
   const data: ScreenData = {
      range,
@@ -303,21 +145,20 @@
      sorterStatus,
      vehicles,
      sites,
      unavailable: live?.unavailable || []
      unavailable: live.unavailable || []
   };
   return {
      data,
      mode: resolveDataMode(live, ctx),
      demoFields: ctx.listDemo(),
      demoFields: [],
      liveFields: ctx.listLive()
   };
}
/** 接口失败或无 live 数据时的初始展示 */
/** 接口尚未返回时先展示零值,避免先闪出演示数字 */
export function initialScreenData(range: TimeRange = 'month') {
   return resolveScreenData(null, range);
}
export { structureScreen, buildScreenAxis } from './screen-structure';
export { demoScreen } from './screen-demo';