周运行报告

parent 878a91c0
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"express-async-handler": "^1.1.4", "express-async-handler": "^1.1.4",
"express-history-api-fallback": "^2.2.1", "express-history-api-fallback": "^2.2.1",
"formidable": "^1.2.1", "formidable": "^1.2.1",
"html2pdf.js": "^0.14.0",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"log4js": "^6.6.1", "log4js": "^6.6.1",
"lru-cache": "^4.1.5", "lru-cache": "^4.1.5",
......
/**
* 周报告模块
* - 查询上周各馆(A/B/C馆)的设备监测数据
* - 提供周报告 JSON 数据接口
*/
import { TABLENAME } from "../config/dbEnum";
import { selectDataListByParam } from "../data/findData";
// ======================= 常量 =======================
const HALL_NAMES = ["A馆", "B馆", "C馆"];
const HALL_KEYS = ["total_a", "total_b", "total_c"];
const TABLE_TITLE_LIST = ["时间", "运行时长", "温度℃", "客流量", "能耗kWh"];
// ======================= 工具函数 =======================
/** 格式化日期为 'YYYY-MM-DD HH:mm:ss' */
function formatLocalTime(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** 并行查询三馆区域,按 groups 分组 */
async function queryHallRegions(): Promise<Map<string, number[]>> {
const results = await Promise.all(
HALL_NAMES.map(g => selectDataListByParam(TABLENAME.区域表, { groups: g }, ["id"]))
);
const hallRegionMap = new Map<string, number[]>();
for (let i = 0; i < HALL_NAMES.length; i++) {
const ids = (results[i].data || []).map((r: any) => r.id);
if (ids.length > 0) hallRegionMap.set(HALL_NAMES[i], ids);
}
return hallRegionMap;
}
// ======================= JSON 数据接口 =======================
/** 格式化日期为中文 "YYYY年M月D日" */
function formatChineseDate(date: Date): string {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}日`;
}
/** 格式化日期为 "YYYY-MM-DD" */
function formatDateStr(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 计算指定周的起止时间(周一~周日) */
function calcWeekRange(weekOffset: number = 0): { start: Date; end: Date; timer: string } {
const now = new Date();
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 8 : dayOfWeek + 6; // 回溯到上周一
const monday = new Date(now);
monday.setDate(now.getDate() - daysToMonday + weekOffset * 7);
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
const timer = `${formatChineseDate(monday)}${formatChineseDate(sunday)}`;
return { start: monday, end: sunday, timer };
}
/** 格式化数字,不足时返回 "——" */
function fmtNum(val: number | null): string {
if (val == null) return "——";
return String(Math.round(val * 10) / 10);
}
/** 格式化能耗,为 0 时返回 "——" */
function fmtEnergy(val: number): string {
if (val <= 0) return "——";
return Math.round(val).toLocaleString() + "kwh";
}
/** 按周七天生成日期数组 */
function genWeekDays(monday: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return d;
});
}
/** 获取某天 00:00 ~ 23:59 的时间字符串 */
function dayRange(day: Date): { from: string; to: string } {
const start = new Date(day);
start.setHours(0, 0, 0, 0);
const end = new Date(day);
end.setHours(23, 59, 59, 999);
return { from: formatLocalTime(start), to: formatLocalTime(end) };
}
/** 某馆的所有设备分组(用于批量查询) */
interface HallDeviceGroup {
acDevices: { device_id: string; device_name: string; region_name: string; region_key: number }[];
customerDevices: { device_id: string; device_name: string; region_key: number }[];
meterDevices: { device_id: string; device_name: string; region_key: number }[];
allDeviceIds: string[];
allDevices: { device_id: string; device_name: string; device_type: string; region_key: number }[];
}
/**
* 查询某馆下所有类型设备并分组
*/
async function queryHallDeviceGroup(regionIds: number[]): Promise<HallDeviceGroup> {
if (regionIds.length === 0) {
return { acDevices: [], customerDevices: [], meterDevices: [], allDeviceIds: [], allDevices: [] };
}
// 并行查询该馆设备和区域名称
const [allDevRes, regionRes] = await Promise.all([
selectDataListByParam(TABLENAME.设备表, { region_key: { "%in%": regionIds } }, ["device_id", "device_name", "device_type", "region_key"]),
selectDataListByParam(TABLENAME.区域表, { id: { "%in%": regionIds } }, ["id", "name"]),
]);
const nameMap = new Map<number, string>();
for (const r of (regionRes.data || [])) {
nameMap.set(r.id, r.name || "");
}
const allDevices = (allDevRes.data || []).map((d: any) => ({
device_id: d.device_id,
device_name: d.device_name || d.device_id,
device_type: d.device_type,
region_key: d.region_key,
}));
const acDevices = allDevices
.filter(d => d.device_type === "空调" || d.device_type === "新风")
.map(d => ({ ...d, region_name: nameMap.get(d.region_key) || "" }));
return {
acDevices,
customerDevices: allDevices.filter(d => d.device_type === "客流传感器"),
meterDevices: allDevices.filter(d => d.device_type === "电能监测"),
allDeviceIds: allDevices.map(d => d.device_id),
allDevices,
};
}
/**
* 从空调/新风设备数据中计算室温均值 + 运行时长
*/
function calcAcDeviceDaily(records: any[], dayEndTime: Date): { avgTemp: number; runHours: number; tempCount: number } {
const sorted = records.slice().sort(
(a, b) => new Date(a.device_time).getTime() - new Date(b.device_time).getTime(),
);
let tempSum = 0, tempCount = 0, runHours = 0;
for (let i = 0; i < sorted.length; i++) {
const d = sorted[i].data || {};
if (d.roomTemp != null && Number(d.roomTemp) !== 0) {
tempSum += Number(d.roomTemp);
tempCount++;
}
if (d.power === "on") {
const t1 = new Date(sorted[i].device_time).getTime();
const t2 = i + 1 < sorted.length
? new Date(sorted[i + 1].device_time).getTime()
: dayEndTime.getTime();
if (t2 > t1) runHours += (t2 - t1) / (1000 * 60 * 60);
}
}
const avgTemp = tempCount > 0 ? Math.round(tempSum / tempCount * 10) / 10 : 0;
return { avgTemp, runHours, tempCount };
}
/**
* 从客流传感器数据中计算人流总值
*/
function calcCustomerDeviceDaily(records: any[]): number {
let visitorSum = 0;
for (const rec of records) {
const d = rec.data || {};
if (d.current_total != null) visitorSum += Number(d.current_total);
}
return visitorSum;
}
/**
* 从电表数据中计算24时能耗(末次累计 - 首次累计)
*/
function calcMeterDeviceDaily(records: any[]): number {
let firstEnergy: number | null = null;
let lastEnergy: number | null = null;
const sorted = records.slice().sort(
(a, b) => new Date(a.device_time).getTime() - new Date(b.device_time).getTime(),
);
for (const rec of sorted) {
const d = rec.data || {};
if (d.energy != null) {
const val = Number(d.energy);
if (firstEnergy === null) firstEnergy = val;
lastEnergy = val;
}
}
return (lastEnergy !== null && firstEnergy !== null && lastEnergy > firstEnergy)
? lastEnergy - firstEnergy
: 0;
}
/**
* 查询周报告 JSON 数据
* @param weekOffset 0=上周(默认), -1=前一周, ...
*/
export async function getWeeklyReportData(weekOffset: number = 0): Promise<any> {
const { start: monday, end: sunday, timer } = calcWeekRange(weekOffset);
console.log(`[周报告JSON] 查询数据: ${timer}`);
// ===== 第一步:并行查询区域 + 各馆设备分组 =====
const hallRegionMap = await queryHallRegions();
const weekDays = genWeekDays(monday);
const groups = await Promise.all(
HALL_NAMES.map(h => queryHallDeviceGroup(hallRegionMap.get(h) || []))
);
const hallDeviceGroups = new Map<string, HallDeviceGroup>();
for (let i = 0; i < HALL_NAMES.length; i++) {
hallDeviceGroups.set(HALL_NAMES[i], groups[i]);
console.log(`[周报告JSON] ${HALL_NAMES[i]}: AC${groups[i].acDevices.length} 客流${groups[i].customerDevices.length} 电表${groups[i].meterDevices.length}`);
}
// ===== 预查询:一次性拉取所有设备未解决故障 =====
const allDeviceIdsUnion = groups.reduce<string[]>((arr, g) => arr.concat(g.allDeviceIds), []);
const faultSet = new Set<string>();
if (allDeviceIdsUnion.length > 0) {
const faultRes = await selectDataListByParam(TABLENAME.设备故障表, {
device_id: { "%in%": allDeviceIdsUnion },
status: { "%ne%": 2 },
occurred_time: { "%lte%": formatLocalTime(sunday) },
}, ["device_id"]);
for (const f of (faultRes.data || [])) faultSet.add(f.device_id);
}
// ===== 第二步:逐馆逐天查询 + 聚合汇总 =====
const runDatas: any = {};
const dataTotal: any = {};
let overallEnergy = 0, overallVisitor = 0, overallTempSum = 0, overallTempCount = 0;
for (let idx = 0; idx < HALL_NAMES.length; idx++) {
const hallName = HALL_NAMES[idx];
const hallKey = HALL_KEYS[idx];
const group = hallDeviceGroups.get(hallName)!;
const tableList: string[][] = [];
let hallTotalEnergy = 0, hallTotalVisitor = 0;
let hallTempSum = 0, hallTempCount = 0, hallTotalRunHours = 0;
for (const day of weekDays) {
if (group.allDeviceIds.length === 0) break;
const { from, to } = dayRange(day);
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
const dayDataRes = await selectDataListByParam(TABLENAME.设备数据表, {
device_id: { "%in%": group.allDeviceIds },
device_time: { "%gte%": from, "%lte%": to },
"%orderAsc%": "device_time",
}, ["device_id", "data", "device_time"]);
// 按设备 ID 分组
const deviceDataMap = new Map<string, any[]>();
for (const rec of (dayDataRes.data || [])) {
const did = rec.device_id;
if (!deviceDataMap.has(did)) deviceDataMap.set(did, []);
deviceDataMap.get(did)!.push(rec);
}
let dayRunHours = 0, dayTempSum = 0, dayTempCount = 0;
let dayEnergy = 0, dayVisitor = 0;
for (const dev of group.acDevices) {
const records = deviceDataMap.get(dev.device_id) || [];
const { avgTemp, runHours, tempCount } = calcAcDeviceDaily(records, dayEnd);
dayRunHours += runHours;
if (tempCount > 0) { dayTempSum += avgTemp * tempCount; dayTempCount += tempCount; }
}
for (const dev of group.customerDevices) {
dayVisitor += calcCustomerDeviceDaily(deviceDataMap.get(dev.device_id) || []);
}
for (const dev of group.meterDevices) {
dayEnergy += calcMeterDeviceDaily(deviceDataMap.get(dev.device_id) || []);
}
// 累加到周汇总
hallTotalRunHours += dayRunHours;
if (dayTempCount > 0) { hallTempSum += dayTempSum; hallTempCount += dayTempCount; }
hallTotalEnergy += dayEnergy;
hallTotalVisitor += dayVisitor;
tableList.push([
formatDateStr(day),
dayRunHours > 0 ? fmtNum(dayRunHours) + "h" : "——",
dayTempCount > 0 ? fmtNum(dayTempSum / dayTempCount) : "——",
dayVisitor > 0 ? String(dayVisitor) : "——",
dayEnergy > 0 ? String(Math.round(dayEnergy * 100) / 100) : "——",
]);
}
// ===== 第四步:汇总各馆 =====
const hallAvgTemp = hallTempCount > 0 ? hallTempSum / hallTempCount : null;
const hallTempStr = hallAvgTemp != null ? fmtNum(hallAvgTemp) + "°C" : "——";
const hallEnergyStr = fmtEnergy(hallTotalEnergy);
const kllStr = String(hallTotalVisitor || "——");
dataTotal[hallKey] = { nh: hallEnergyStr, kll: kllStr, jw: hallTempStr };
runDatas[hallName] = {
rjnh: hallTotalEnergy > 0 ? fmtNum(hallTotalEnergy / 7) + " kWh" : "——",
tableData: [{ titleList: TABLE_TITLE_LIST, tableList }],
totalData: [
"周累计",
hallTotalRunHours > 0 ? fmtNum(hallTotalRunHours) + "h" : "——",
hallTempStr,
kllStr,
hallEnergyStr,
],
};
overallEnergy += hallTotalEnergy;
overallVisitor += hallTotalVisitor;
if (hallTempCount > 0) {
overallTempSum += hallTempSum;
overallTempCount += hallTempCount;
}
if (hallTotalRunHours > 0) {
console.log(`[周报告JSON] ${hallName} 空调周运行时长: ${Math.round(hallTotalRunHours)}h`);
}
}
// ===== 第五步:整体汇总 + 评价 =====
dataTotal.ztyx = {
nh: fmtEnergy(overallEnergy),
kll: String(overallVisitor || "——"),
jw: overallTempCount > 0
? fmtNum(overallTempSum / overallTempCount) + "°C"
: "——",
};
const evalMap: any = {};
for (let i = 0; i < HALL_NAMES.length; i++) {
const group = hallDeviceGroups.get(HALL_NAMES[i])!;
const faultCount = group.allDevices.filter(d => faultSet.has(d.device_id)).length;
evalMap[HALL_NAMES[i]] = faultCount === 0 ? "优秀" : faultCount <= 2 ? "合格" : "待改善";
}
dataTotal.ztpj = evalMap;
return { timer, dataTotal, runDatas };
}
...@@ -946,36 +946,41 @@ export async function controlAcByEnvironment(deviceId: string, data: any) { ...@@ -946,36 +946,41 @@ export async function controlAcByEnvironment(deviceId: string, data: any) {
} }
} }
// ===== 新风联动 ===== // ===== 新风联动(后执行,通过送风模式覆盖空调温控) =====
if (co2 != null) { if (co2 != null) {
const fanParams = calcFanParams(co2); const fanParams = calcFanParams(co2);
if (fanParams) { if (fanParams) {
let fanDevices = await selectDataListByParam(TABLENAME.设备表, { let fanDevices = await selectDataListByParam(TABLENAME.设备表, {
region_key: regionKey, region_key: regionKey,
device_type: "新风" device_type: { "%in%": ["新风", "空调"] }
}, ["id", "device_name", "device_id", "device_ad"]); }, ["id", "device_name", "device_id", "device_ad", "device_type"]);
if (fanDevices.data.length > 0) { if (fanDevices.data.length > 0) {
// 去重:若本次新风动作与上次相同,跳过调用 // CO2联动:高→送风模式(3)+高风(1),低→不传workMode(不切换模式)+低风(4)
const fanWorkMode: number | undefined = fanParams.action === 'high' ? 3 : undefined;
const fanSpeedVal = fanParams.action === 'high' ? 1 : 4;
// 去重
const fanCacheKey = `fan_${regionKey}`; const fanCacheKey = `fan_${regionKey}`;
const lastFanState = envControlCache.get(fanCacheKey); const lastFanState = envControlCache.get(fanCacheKey);
if (lastFanState && lastFanState.co2Action === fanParams.action) { if (lastFanState && lastFanState.co2Action === fanParams.action && lastFanState.workMode === fanWorkMode && lastFanState.fanSpeed === fanSpeedVal) {
console.log(`新风联动跳过(控制参数未变): CO2 ${co2}ppm, 动作 ${fanParams.action}, 区域 ${regionKey}`); console.log(`新风联动跳过(参数未变): CO2 ${co2}ppm, ${fanParams.action === 'high' ? '送风高风' : '低风待机'}, 区域 ${regionKey}`);
} else { } else {
let fanControlRes = await controlFanRunningApi(fanDevices, fanParams); let fanControlRes = await controlAcRunningApi(fanDevices, fanWorkMode, undefined, fanSpeedVal, 1);
// 更新缓存 // 更新缓存(含 workMode/fanSpeed 用于去重)
envControlCache.set(fanCacheKey, { co2Action: fanParams.action }); envControlCache.set(fanCacheKey, { co2Action: fanParams.action, workMode: fanWorkMode, fanSpeed: fanSpeedVal });
const fanLogs = fanDevices.data.map((fan: any) => ({ const fanLogs = fanDevices.data.map((fan: any) => ({
device_id: fan.device_id, device_id: fan.device_id,
device_type: '新风', device_type: fan.device_type || '新风',
operation_content: { operation_content: {
option: '自动联动控制', option: '自动联动控制',
device_id: deviceId, device_id: deviceId,
time: operationTime, time: operationTime,
roomCo2: co2, roomCo2: co2,
action: fanParams.action === 'high' ? '风量调高' : '风量调低待机', mode: fanParams.action === 'high' ? '送风' : '低风待机',
action: fanParams.action === 'high' ? '送风高风' : '低风不切换模式',
success: fanControlRes.success, success: fanControlRes.success,
message: fanControlRes.message || (fanControlRes.success ? '控制成功' : '控制失败'), message: fanControlRes.message || (fanControlRes.success ? '控制成功' : '控制失败'),
}, },
...@@ -986,7 +991,7 @@ export async function controlAcByEnvironment(deviceId: string, data: any) { ...@@ -986,7 +991,7 @@ export async function controlAcByEnvironment(deviceId: string, data: any) {
await addData(TABLENAME.设备日志表, fanLogs); await addData(TABLENAME.设备日志表, fanLogs);
if (fanControlRes.success) { if (fanControlRes.success) {
console.log(`新风联动成功: IAQ CO2 ${co2}ppm → ${fanParams.action === 'high' ? '风量调高' : '风量调低待机'}, 设备 ${deviceId}`); console.log(`新风联动成功: CO2 ${co2}ppm → ${fanParams.action === 'high' ? '送风高风' : '低风待机'}, 设备 ${deviceId}`);
} else { } else {
console.error(`新风联动失败: ${fanControlRes.message}`); console.error(`新风联动失败: ${fanControlRes.message}`);
} }
...@@ -1009,11 +1014,14 @@ async function controlAcRunningApi(acDevices?: any, workMode?: number, tempSet?: ...@@ -1009,11 +1014,14 @@ async function controlAcRunningApi(acDevices?: any, workMode?: number, tempSet?:
let params: any = { let params: any = {
"indoorUnitAddressFull": deviceList, "indoorUnitAddressFull": deviceList,
"workMode": workMode,
"onOff": onOff ?? 1, // 联动时按温差动态判断,默认开机兜底 "onOff": onOff ?? 1, // 联动时按温差动态判断,默认开机兜底
"fanSpeed": fanSpeed ?? 2, // 联动时按温差动态计算,默认中风兜底 "fanSpeed": fanSpeed ?? 2, // 联动时按温差动态计算,默认中风兜底
"openApiAction": "control", "openApiAction": "control",
}; };
// workMode 为 undefined 时不传,飞奕不切换模式
if (workMode != null) {
params.workMode = workMode;
}
// 如果传入了设定温度,一并下发 // 如果传入了设定温度,一并下发
if (tempSet != null) { if (tempSet != null) {
params.tempSet = tempSet; params.tempSet = tempSet;
...@@ -1039,39 +1047,8 @@ async function controlAcRunningApi(acDevices?: any, workMode?: number, tempSet?: ...@@ -1039,39 +1047,8 @@ async function controlAcRunningApi(acDevices?: any, workMode?: number, tempSet?:
return controlResults; return controlResults;
} }
// 辅助:接入新风控制API,根据CO2浓度控制新风风量
async function controlFanRunningApi(fanDevices?: any, fanParams?: { action: 'high' | 'low' | 'standby' }) {
let deviceList: any = [];
fanDevices.data.forEach((fan: any) => {
deviceList.push(fan.device_id);
});
let controlResults: any = { "success": false, "message": "失败" };
if (deviceList.length === 0) return controlResults;
// fanSpeed: 1高风 2中风 4低风;standby模式关停
const fanSpeed = fanParams?.action === 'high' ? 1 : 4;
const onOff = fanParams?.action === 'standby' ? 0 : 1;
let params: any = {
"indoorUnitAddressFull": deviceList,
"onOff": onOff,
"fanSpeed": fanSpeed,
"openApiAction": "control",
};
let res = await controlIndoorUnit(params);
if (res.success) {
controlResults.success = true;
controlResults.devices = deviceList;
controlResults.message = '控制成功';
} else {
controlResults.success = false;
controlResults.devices = deviceList;
controlResults.message = res.data || '控制新风设备失败';
}
return controlResults;
}
/** /**
* 数据集成 - 启动定时任务 * 数据集成 - 启动定时任务
......
...@@ -95,8 +95,6 @@ async function scheduledTask(): Promise<void> { ...@@ -95,8 +95,6 @@ async function scheduledTask(): Promise<void> {
// ====== 在此处编写定时任务的具体逻辑 ====== // ====== 在此处编写定时任务的具体逻辑 ======
console.log(`[定时任务] 开始执行 - ${new Date().toLocaleString()}`); console.log(`[定时任务] 开始执行 - ${new Date().toLocaleString()}`);
// TODO: 替换为实际的业务逻辑
// 例如:数据备份、缓存清理、定时同步等
await region(); await region();
await device(); await device();
await deviceData(); await deviceData();
......
/**
* 周报告 API 路由
*/
import asyncHandler from "express-async-handler";
import * as reportBiz from "../biz/report";
export function setRouter(httpServer) {
/** 获取周报告JSON数据 */
httpServer.get("/api/qdm/report/weekly/data", asyncHandler(getReportData));
}
/**
* GET /api/qdm/report/weekly/data?weekOffset=0
* 返回周报告 JSON 数据供前端页面渲染
*/
async function getReportData(req, res) {
const weekOffset = Number(req.query?.weekOffset) || 0;
const data = await reportBiz.getWeeklyReportData(weekOffset);
res.success(data);
}
...@@ -5,11 +5,13 @@ ...@@ -5,11 +5,13 @@
import * as usersRouter from './users'; import * as usersRouter from './users';
import * as deviceRouter from './device'; import * as deviceRouter from './device';
import * as feiyiClientRouter from './feiyiClient'; import * as feiyiClientRouter from './feiyiClient';
import * as reportRouter from './report';
export function setRouter(httpServer) { export function setRouter(httpServer) {
usersRouter.setRouter(httpServer); usersRouter.setRouter(httpServer);
deviceRouter.setRouter(httpServer); deviceRouter.setRouter(httpServer);
feiyiClientRouter.setRouter(httpServer); feiyiClientRouter.setRouter(httpServer);
reportRouter.setRouter(httpServer);
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment