接口开发

parent 92e486c8
...@@ -16,4 +16,14 @@ ...@@ -16,4 +16,14 @@
<mysqlPwd>root</mysqlPwd> <mysqlPwd>root</mysqlPwd>
<dataBase>dst_technology_platform</dataBase> --> <dataBase>dst_technology_platform</dataBase> -->
</mysqldb> </mysqldb>
<feiyi>
<feiyiAppKey>0</feiyiAppKey>
<feiyiSecretKey>0</feiyiSecretKey>
<feiyiBaseUrl>https://m.achelp.cn/open</feiyiBaseUrl>
</feiyi>
<diqin>
<diqinAppKey>178428005410049</diqinAppKey>
<diqinSecretKey>pGAII3TfpdrJ4zjrPncERwtt</diqinSecretKey>
<diqinBaseUrl>https://airiccc.com</diqinBaseUrl>
</diqin>
</config> </config>
import {
getRegionTree,
getIndoorUnitList,
getElectricMaterList,
getIndoorUnitStateList,
getMaterStateList,
getIndoorUnitAlarmErrorHis,
getFaultCodeInfo,
getFaultTypeName,
} from "./feiyiClient";
import {
getLocations,
getLocationDetail,
getDataSources,
getDataSourceDetail,
} from "./diqinClient";
import { mysqlModelMap } from "../model/sqlModelBind";
import Sequelize from "sequelize";
import moment from "moment";
import XLSX from "xlsx";
import path from "path";
const Op = Sequelize.Op;
/** 飞奕品牌编码,默认 "1"=日立 */
const FEIYI_BRAND_CODE = "1";
// ==================== 迪勤:设备类型映射配置 ====================
/**
* 迪勤 data_source_model_name → 本系统 device_type 映射表
* 初始仅 IEQ → IEQ传感器
* ORI PRO / ORI API 跳过不处理
*/
const DIQIN_DEVICE_TYPE_MAP: Record<string, string> = {
'IEQ': 'IEQ传感器',
};
// ==================== 工具函数 ====================
/**
* 获取今天的日期字符串,格式 YYYYMMDD(使用本地时区,避免时区问题)
*/
function getTodayDateStr(): string {
return moment().format('YYYYMMDD');
}
/**
* 从内机四段地址(如 "2-1-0-1")提取外机地址("2-1-0")
*/
function getOuterAddress(indoorUnitAddressFull: string): string | null {
if (!indoorUnitAddressFull) return null;
const parts = indoorUnitAddressFull.split('-');
if (parts.length >= 3) {
return parts.slice(0, 3).join('-');
}
return null;
}
/**
* 秒级时间比较:itemTime > maxTime(精度对齐,避免 MySQL DATETIME 秒级与接口毫秒级不一致导致重复插入)
* @returns true 表示 itemTime 比 maxTime 更新,应该保留
*/
function isNewerThan(itemTime: Date | null, maxTime: Date | null | undefined): boolean {
if (maxTime === undefined) return true; // 新设备,无历史数据
if (maxTime === null || !itemTime) return true;
const itemSec = Math.floor(itemTime.getTime() / 1000);
const maxSec = Math.floor(maxTime.getTime() / 1000);
return itemSec > maxSec;
}
/**
* 批量查询设备在各表中的最大 device_time 并构建 Map
*/
async function buildMaxTimeMap(
deviceDataModel: any,
deviceIds: string[],
): Promise<Map<string, Date | null>> {
const map = new Map<string, Date | null>();
if (deviceIds.length === 0) return map;
const results = await deviceDataModel.findAll({
attributes: [
'device_id',
[deviceDataModel.sequelize.fn('MAX', deviceDataModel.sequelize.col('device_time')), 'max_time'],
],
where: { device_id: deviceIds },
group: ['device_id'],
raw: true,
});
for (const r of results as any[]) {
map.set(r.device_id, r.max_time ? new Date(r.max_time) : null);
}
return map;
}
/**
* 从 Excel 文件读取电表设备编号 → 外机地址 的映射表
*/
function loadMeterOuterAddrMap(): Map<string, string> {
const map = new Map<string, string>();
try {
const filePath = path.resolve(__dirname, '../../res/电表对应区域.xlsx');
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows: any[] = XLSX.utils.sheet_to_json(sheet);
for (const row of rows) {
const meterCode = row['电表设备编号'];
const outerAddr = row['空调外机地址'];
if (meterCode && outerAddr) {
map.set(String(meterCode), String(outerAddr));
}
}
console.log(`[工具] 电表对应区域.xlsx 读取完成,共 ${map.size} 条映射`);
} catch (err) {
console.error('[工具] 读取电表对应区域.xlsx 失败:', err);
}
return map;
}
// ==================== 分页拉取辅助函数 ====================
/**
* 分页拉取所有数据(通用)
* @param fetchFn 分页查询函数,入参 { page, limit, ...extraParams },返回 { page, limit, total, rows }
* @param extraParams 额外的查询参数,会透传每次调用
* @param limit 每页条数,默认 100
*/
async function fetchAllPages(
fetchFn: (page: number, limit: number, extraParams: Record<string, any>) => Promise<any>,
extraParams: Record<string, any> = {},
limit: number = 100,
): Promise<any[]> {
const allRows: any[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const result = await fetchFn(page, limit, extraParams);
if (!result || !result.rows || result.rows.length === 0) {
hasMore = false;
break;
}
allRows.push(...result.rows);
// 判断是否还有下一页
if (page * limit >= result.total) {
hasMore = false;
} else {
page++;
}
}
return allRows;
}
// ==================== 区域集成 ====================
/**
* 递归遍历建筑物树,提取所有房间节点(buildingType === 3)
* 同时追溯父节点获取楼层和楼栋信息
*/
function extractRoomsFromTree(
tree: any[],
parentFloor: { floorId: string; floorName: string } | null = null,
parentBuilding: { buildingId: string; buildingName: string } | null = null,
): Array<{
roomId: string;
roomName: string;
floorId: string;
floorName: string;
buildingId: string;
buildingName: string;
}> {
const rooms: any[] = [];
for (const node of tree) {
const buildingType = node.buildingType;
const buildingId = node.buildingId;
const buildingName = node.buildingName;
const children = node.children || [];
if (buildingType === 1) {
// 楼栋节点
const building = { buildingId, buildingName };
rooms.push(...extractRoomsFromTree(children, null, building));
} else if (buildingType === 2) {
// 楼层节点
const floor = { floorId: buildingId, floorName: buildingName };
rooms.push(...extractRoomsFromTree(children, floor, parentBuilding));
} else if (buildingType === 3) {
// 房间节点
rooms.push({
roomId: buildingId,
roomName: buildingName,
floorId: parentFloor?.floorId || '',
floorName: parentFloor?.floorName || '',
buildingId: parentBuilding?.buildingId || '',
buildingName: parentBuilding?.buildingName || '',
});
}
}
return rooms;
}
/**
* 数据集成-区域集成
* 按最小节点(房间)平铺集成,一条数据对应一个房间
*/
export async function region() {
console.log('[区域集成] 开始执行...');
try {
const regionModel = mysqlModelMap['region'];
if (!regionModel) {
console.error('[区域集成] region 表模型未初始化,跳过');
return;
}
// 1. 获取建筑物树结构
const treeData = await getRegionTree();
if (!treeData || !Array.isArray(treeData)) {
console.warn('[区域集成] 未获取到建筑物树数据');
return;
}
// 2. 提取所有房间节点
const rooms = extractRoomsFromTree(treeData);
console.log(`[区域集成] 共提取到 ${rooms.length} 个房间节点`);
// 3. 逐房间检查并插入
let insertCount = 0;
let skipCount = 0;
for (const room of rooms) {
// 校验是否已存在(通过 room_id)
const existing = await regionModel.findOne({ where: { room_id: room.roomId } });
if (existing) {
skipCount++;
continue;
}
// 插入新区域记录
await regionModel.create({
room_id: room.roomId,
name: room.roomName,
floor_id: room.floorId,
type: room.floorName, // type 对应楼层名称
building_id: room.buildingId,
groups: room.buildingName, // groups 对应楼栋名称
sort_order: 0,
});
insertCount++;
}
console.log(`[区域集成] 完成,新增 ${insertCount} 条,跳过 ${skipCount} 条已存在`);
// 2. 一次性查询所有 region,后续两步共用内存数据,避免 DB 读写时序问题
const allRegions = await regionModel.findAll({ raw: true }) as any[];
// 3. 同步空调内机 → 反写 region.address
const updatedRegions = await syncRegionAddressByAcDevices(regionModel, allRegions);
// 4. 同步电表设备 region_key(复用上一步已更新 address 的内存数据)
await syncMeterDeviceRegionKey(updatedRegions);
// 5. 同步飞奕设备 → region_device_rel 关联表
await feiyiRegionDeviceRel();
} catch (err) {
console.error('[区域集成] 执行异常:', err);
}
}
// ==================== 区域-设备关联同步 ====================
/**
* 通过空调内机信息反写 region 表的 address 字段(外机地址)
* 逻辑:拉取所有空调内机 → 通过 roomId 匹配 region → 用内机地址前三位反写 region.address
*/
async function syncRegionAddressByAcDevices(regionModel: any, allRegions: any[]) {
console.log('[同步region.address] 开始...');
try {
// 拉取所有空调内机
const rows = await fetchAllPages((page, limit) => getIndoorUnitList({ page, limit }));
console.log(`[同步region.address] 共获取 ${rows.length} 条空调内机`);
// 构建 roomId → region 映射(使用外部传入的 allRegions)
const roomIdToRegion = new Map<string, any>();
for (const r of allRegions) {
if (r.room_id) {
roomIdToRegion.set(r.room_id, r);
}
}
let updateCount = 0;
for (const item of rows) {
if (!item.roomId || !item.indoorUnitAddressFull) continue;
const region = roomIdToRegion.get(item.roomId);
if (!region) continue;
// 用内机四段地址前三位作为外机地址
const outerAddr = getOuterAddress(item.indoorUnitAddressFull);
if (!outerAddr) continue;
// address 为空或值不同时才更新
if (region.address === outerAddr) continue;
await regionModel.update(
{ address: outerAddr },
{ where: { id: region.id } },
);
// 同步更新内存中的值,后续 syncMeterDeviceRegionKey 可直接使用
region.address = outerAddr;
updateCount++;
}
console.log(`[同步region.address] 完成,更新 ${updateCount} 条`);
return allRegions; // 返回已更新 address 的内存数据
} catch (err) {
console.error('[同步region.address] 执行异常:', err);
return allRegions; // 异常时仍返回原始数据,不阻塞后续流程
}
}
/**
* 通过 Excel 映射 + region.address 同步电表设备的 region_key
* 逻辑:Excel(电表设备编号→外机地址) → region.address → region.id → 更新 device.region_key
*/
async function syncMeterDeviceRegionKey(allRegions: any[]) {
console.log('[同步电表region_key] 开始...');
try {
const deviceModel = mysqlModelMap['device'];
if (!deviceModel) {
console.error('[同步电表region_key] device 表模型未初始化,跳过');
return;
}
// 1. 读取 Excel 映射:电表设备编号 → 外机地址
const meterOuterMap = loadMeterOuterAddrMap();
if (meterOuterMap.size === 0) {
console.warn('[同步电表region_key] Excel 映射为空,跳过');
return;
}
// 2. 使用外部传入的 allRegions 构建 address → regionKey 映射(复用已更新 address 的内存数据)
const addrToRegionKey = new Map<string, string>();
for (const r of allRegions) {
if (r.address && r.region_key) {
addrToRegionKey.set(r.address, r.region_key);
}
}
console.log(`[同步电表region_key] region.address 映射共 ${addrToRegionKey.size} 条`);
// 3. 查询所有电表设备
const meterDevices = await deviceModel.findAll({
where: { device_type: '电能监测' },
raw: true,
});
console.log(`[同步电表region_key] 共 ${meterDevices.length} 个电表设备`);
// 4. 逐一匹配更新
let updateCount = 0;
let missCount = 0;
for (const device of meterDevices as any[]) {
const outerAddr = meterOuterMap.get(device.device_id);
if (!outerAddr) {
missCount++;
continue;
}
const regionKey = addrToRegionKey.get(outerAddr);
if (!regionKey) {
missCount++;
continue;
}
// region_key 已正确则跳过
if (device.region_key === regionKey) continue;
await deviceModel.update(
{ region_key: regionKey },
{ where: { id: device.id } },
);
updateCount++;
}
console.log(`[同步电表region_key] 完成,更新 ${updateCount} 条,未匹配 ${missCount} 条`);
} catch (err) {
console.error('[同步电表region_key] 执行异常:', err);
}
}
// ==================== 设备集成 ====================
/** 空调控制参数模板(按设备数据结构.md) */
const AC_CONTROL_PARAMS = {
power: ["on", "off"],
mode: ["制冷", "制热", "送风", "除湿"],
fanSpeed: ["自动", "弱", "强", "超强"],
setTemp: "16~30",
roomTemp: "-20~50",
maxTemp: "20~40",
minTemp: "-10~20",
autoControl: ["生效", "解除"],
};
/** 电表控制参数模板(按设备数据结构.md) */
const METER_CONTROL_PARAMS = {
current: "0~9999",
voltage: "0~500",
power: "0~99999",
energy: "0~999999",
switch: ["closed", "open"],
};
/**
* 数据集成-设备集成
* 包含:空调设备集成 + 电表设备集成
*/
export async function device() {
console.log('[设备集成] 开始执行...');
try {
await integrateAcDevices();
await integrateMeterDevices();
console.log('[设备集成] 完成');
} catch (err) {
console.error('[设备集成] 执行异常:', err);
}
}
/**
* 空调设备集成
* device_id = indoorUnitAddressFull
* device_ad = indoorUnitId
*/
async function integrateAcDevices() {
console.log('[空调设备集成] 开始...');
const deviceModel = mysqlModelMap['device'];
if (!deviceModel) {
console.error('[空调设备集成] device 表模型未初始化,跳过');
return;
}
// 查询未处理的故障设备信息
const faultDevices = await deviceModel.findAll({
where: { device_type: '空调', device_state: 2 },
raw: true,
});
console.log(`[空调设备集成] 共 ${faultDevices.length} 个故障设备`);
let deviceNomalIds = [];
// 分页拉取所有内机数据
const rows = await fetchAllPages((page, limit) => getIndoorUnitList({ page, limit }));
console.log(`[空调设备集成] 共获取 ${rows.length} 条内机数据`);
let insertCount = 0;
let skipCount = 0;
for (const item of rows) {
const deviceId = item.indoorUnitAddressFull; // device_id 用四段地址
if (!deviceId) {
skipCount++;
continue;
}
// 校验是否已存在
const existing = await deviceModel.findOne({ where: { device_id: deviceId } });
if (existing) {
skipCount++;
// 更新设备状态
const newState = item.state ?? 0;
if (existing.device_state !== newState) {
await deviceModel.update(
{ device_state: newState, updated_at: new Date() },
{ where: { id: existing.id } },
);
}
// 校验故障设备是否已恢复
if (faultDevices.find(d => d.device_id === deviceId) && newState === 0) {
console.log(`[空调设备集成] 故障设备 ${deviceId} 已恢复`);
deviceNomalIds.push(deviceId);
}
continue;
}
let deviceType = '空调';
let deviceName = '空调内机';
// 查找对应的 region_key
let regionKey: string = '';
if (item.roomId) {
const regionModel = mysqlModelMap['region'];
if (regionModel) {
const region = await regionModel.findOne({ where: { room_id: item.roomId } });
if (region) {
regionKey = region.region_key;
// 如果区域地址包含新风,则空调视为新风机
if (region.name && region.name.includes('新风')) {
deviceType = '新风';
deviceName = '新风机';
}
}
}
}
// 插入新设备
await deviceModel.create({
device_id: deviceId,
device_ad: item.indoorUnitId || '',
region_key: regionKey,
device_type: deviceType,
device_name: `${deviceName}-${deviceId}`,
control_params: AC_CONTROL_PARAMS,
device_state: item.state ?? 0
});
insertCount++;
}
// 根据deviceNomalIds更新故障信息为正常
if (deviceNomalIds.length > 0) {
const faultModel = mysqlModelMap['device_fault'];
if (faultModel) {
const now = new Date();
await faultModel.update(
{ status: 2, resolved_time: now, updated_at: now },
{ where: { device_id: { [Op.in]: deviceNomalIds }, status: { [Op.ne]: 2 } } },
);
console.log(`[空调设备集成] 批量解决 ${deviceNomalIds.length} 台设备故障`);
}
}
console.log(`[空调设备集成] 完成,新增 ${insertCount} 条,跳过 ${skipCount} 条已存在`);
}
/**
* 电表设备集成
* device_id = gatewayCode
* device_ad = meterId
*/
async function integrateMeterDevices() {
console.log('[电表设备集成] 开始...');
const deviceModel = mysqlModelMap['device'];
if (!deviceModel) {
console.error('[电表设备集成] device 表模型未初始化,跳过');
return;
}
// 分页拉取所有电表数据
const rows = await fetchAllPages((page, limit) => getElectricMaterList({ page, limit }));
console.log(`[电表设备集成] 共获取 ${rows.length} 条电表数据`);
let insertCount = 0;
let skipCount = 0;
for (const item of rows) {
const deviceId = item.gatewayCode; // device_id 用 gatewayCode
if (!deviceId) {
skipCount++;
continue;
}
// 校验是否已存在
const existing = await deviceModel.findOne({ where: { device_id: deviceId } });
if (existing) {
skipCount++;
continue;
}
// 插入新设备
await deviceModel.create({
device_id: deviceId,
device_ad: item.meterId || '',
region_key: '', // 电表暂无区域关联,后续由 syncMeterDeviceRegionKey 回填
device_type: '电能监测',
device_name: `电表-${item.meterComId || item.meterId || ''}`,
control_params: METER_CONTROL_PARAMS,
});
insertCount++;
}
console.log(`[电表设备集成] 完成,新增 ${insertCount} 条,跳过 ${skipCount} 条已存在`);
}
// ==================== 设备数据集成 ====================
/** 工作模式映射:1=制冷 2=制热 3=送风 4=除湿 */
const WORK_MODE_MAP: Record<number, string> = {
1: '制冷',
2: '制热',
3: '送风',
4: '除湿',
};
/** 风速映射:1=高风 2=中风 4=低风 */
const FAN_SPEED_MAP: Record<number, string> = {
1: '超强',
2: '强',
4: '弱',
};
/**
* 数据集成-空调内机、电表设备数据集成
*/
export async function deviceData() {
console.log('[设备数据集成] 开始执行...');
try {
await integrateAcDeviceData();
await integrateMeterDeviceData();
console.log('[设备数据集成] 完成');
} catch (err) {
console.error('[设备数据集成] 执行异常:', err);
}
}
/**
* 空调设备数据集成(开关机记录)
* 用 indoorUnitAddressFull 匹配 device 表的 device_id
*/
async function integrateAcDeviceData() {
console.log('[空调设备数据集成] 开始...');
const deviceModel = mysqlModelMap['device'];
const deviceDataModel = mysqlModelMap['device_data'];
if (!deviceModel || !deviceDataModel) {
console.error('[空调设备数据集成] device 或 device_data 表模型未初始化,跳过');
return;
}
// 1. 分页拉取当天内机开关机记录(使用 occurrenceTime 过滤当天数据)
const todayDate = getTodayDateStr();
const rows = await fetchAllPages(
(page, limit, extraParams) => getIndoorUnitStateList({ page, limit, occurrenceTime: extraParams.occurrenceTime }),
{ occurrenceTime: todayDate },
);
console.log(`[空调设备数据集成] 共获取 ${rows.length} 条开关机记录`);
if (rows.length === 0) {
console.log('[空调设备数据集成] 当天无开关机记录,跳过');
return;
}
// 2. 提取所有涉及的 device_id(去重)
const deviceIdSet = new Set<string>();
for (const item of rows) {
if (item.indoorUnitAddressFull) {
deviceIdSet.add(item.indoorUnitAddressFull);
}
}
const deviceIds = Array.from(deviceIdSet);
// 3. 一次 SQL GROUP BY 查询最大 device_time
const maxTimeMap = await buildMaxTimeMap(deviceDataModel, deviceIds);
// 4. 内存过滤:保留 device_time > 该设备最大时间的记录
const toInsert: any[] = [];
let skipCount = 0;
for (const item of rows) {
const deviceId = item.indoorUnitAddressFull;
if (!deviceId || !deviceIdSet.has(deviceId)) {
skipCount++;
continue;
}
const itemTime = item.createTime ? new Date(item.createTime) : null;
if (!isNewerThan(itemTime, maxTimeMap.get(deviceId))) {
skipCount++;
continue;
}
// 构造数据(按设备数据结构.md 空调设备数据格式)
const data = {
power: item.onOff === 1 ? 'on' : 'off',
mode: WORK_MODE_MAP[item.workMode] || '未知',
fanSpeed: FAN_SPEED_MAP[item.fanSpeed] || '自动',
setTemp: item.tempSet || 0,
roomTemp: item.roomTemp || 0,
maxTemp: item.tempSetHi || 28,
minTemp: item.tempSetLo || 16,
autoControl: '生效',
};
toInsert.push({
device_id: deviceId,
data: data,
received_time: new Date(),
device_time: itemTime,
});
}
// TODO: 考虑是否需要在插入前对数据按 device_time 排序,避免数据库存储顺序混乱
// 5. 批量插入
if (toInsert.length > 0) {
await deviceDataModel.bulkCreate(toInsert);
}
console.log(`[空调设备数据集成] 完成,新增 ${toInsert.length} 条,跳过 ${skipCount} 条`);
}
/**
* 电表设备数据集成(抄表记录)
* 用 gatewayCode 匹配 device 表的 device_id
*/
async function integrateMeterDeviceData() {
console.log('[电表设备数据集成] 开始...');
const deviceModel = mysqlModelMap['device'];
const deviceDataModel = mysqlModelMap['device_data'];
if (!deviceModel || !deviceDataModel) {
console.error('[电表设备数据集成] device 或 device_data 表模型未初始化,跳过');
return;
}
// 1. 分页拉取当天抄表记录(使用 occurrenceTime 过滤当天数据)
const todayDate = getTodayDateStr();
const rows = await fetchAllPages(
(page, limit, extraParams) => getMaterStateList({ page, limit, occurrenceTime: extraParams.occurrenceTime }),
{ occurrenceTime: todayDate },
);
console.log(`[电表设备数据集成] 共获取 ${rows.length} 条抄表记录`);
if (rows.length === 0) {
console.log('[电表设备数据集成] 当天无抄表记录,跳过');
return;
}
// 2. 提取所有涉及的 device_id(去重)
const deviceIdSet = new Set<string>();
for (const item of rows) {
if (item.gatewayCode) {
deviceIdSet.add(item.gatewayCode);
}
}
const deviceIds = Array.from(deviceIdSet);
// 3. 一次 SQL GROUP BY 查询最大 device_time
const maxTimeMap = await buildMaxTimeMap(deviceDataModel, deviceIds);
// 4. 内存过滤:保留 device_time > 该设备最大时间的记录
const toInsert: any[] = [];
let skipCount = 0;
for (const item of rows) {
const deviceId = item.gatewayCode;
if (!deviceId) {
skipCount++;
continue;
}
const itemTime = item.deviceTime ? new Date(item.deviceTime) : null;
if (!isNewerThan(itemTime, maxTimeMap.get(deviceId))) {
skipCount++;
continue;
}
// 构造数据(按设备数据结构.md 电表设备数据格式)
const data = {
current: item.powerConsumptionAfterTransformationRatio || 0,
voltage: 0,
power: item.ammeterReadingAfterTransformationRatio || 0,
energy: item.ammeterReadingAfterTransformationRatio || 0,
switch: 'open',
};
toInsert.push({
device_id: deviceId,
data: data,
received_time: new Date(),
device_time: itemTime,
});
}
// TODO: 考虑是否需要在插入前对数据按 device_time 排序,避免数据库存储顺序混乱
// 5. 批量插入
if (toInsert.length > 0) {
await deviceDataModel.bulkCreate(toInsert);
}
console.log(`[电表设备数据集成] 完成,新增 ${toInsert.length} 条,跳过 ${skipCount} 条`);
}
// ==================== 暂无对应接口的集成函数(保留空实现) ====================
/**
* 数据集成-九合一环境设备数据集成
*/
export async function airDeviceData() {
// 暂无对应接口 -> MQTT
}
/**
* 数据集成-客流监测设备数据集成
*/
export async function customerDeviceData() {
// 暂无对应接口 -> MQTT
}
// ==================== 预警工单集成(飞奕故障记录) ====================
/**
* 数据集成-预警工单数据集成
* 从飞奕云平台拉取最近 100 条空调内机故障记录,按 indoorUnitAlarmId 去重后写入 device_fault 表
*/
export async function alertWorkData() {
console.log('[预警工单集成] 开始...');
const deviceModel = mysqlModelMap['device'];
const faultModel = mysqlModelMap['device_fault'];
if (!deviceModel || !faultModel) {
console.error('[预警工单集成] device 或 device_fault 模型未初始化,跳过');
return;
}
try {
// 1. 拉取最近 100 条故障记录(不再限制当天)
const result = await getIndoorUnitAlarmErrorHis({ page: 1, limit: 100 });
const rows: any[] = result?.rows || [];
console.log(`[预警工单集成] 获取 ${rows.length} 条故障记录`);
if (rows.length === 0) {
console.log('[预警工单集成] 无故障记录,跳过');
return;
}
// 2. 收集所有 indoorUnitAlarmId,批量查已有故障记录(去重)
const alarmIds = rows.map((r: any) => r.indoorUnitAlarmId).filter(Boolean);
if (alarmIds.length === 0) {
console.log('[预警工单集成] 所有记录缺少 indoorUnitAlarmId,跳过');
return;
}
const existingFaults = await faultModel.findAll({
where: { fault_origin_id: { [Op.in]: alarmIds } },
attributes: ['fault_origin_id'],
raw: true,
});
const existingIds = new Set((existingFaults as any[]).map((f: any) => f.fault_origin_id));
// 3. 收集 indoorUnitAddressFull,批量查设备确认已注册
const addrList = rows.map((r: any) => r.indoorUnitAddressFull).filter(Boolean);
const devices = await deviceModel.findAll({
where: { device_id: addrList },
attributes: ['device_id'],
raw: true,
});
const registeredDeviceIds = new Set((devices as any[]).map((d: any) => d.device_id));
// 4. 收集所有故障码,批量查询故障详情(缓存去重)
const alarmCodes = [...new Set(rows.map((r: any) => r.alarmCode).filter(Boolean))] as string[];
const faultInfoMap = new Map<string, { errorInfo: string; scheme: string | null }>();
if (alarmCodes.length > 0) {
console.log(`[预警工单集成] 查询 ${alarmCodes.length} 个不同故障码的详情...`);
const infoResults = await Promise.all(
alarmCodes.map(async (code) => {
const info = await getFaultCodeInfo(FEIYI_BRAND_CODE, code);
return { code, info };
})
);
for (const { code, info } of infoResults) {
faultInfoMap.set(code, info);
}
}
// 5. 过滤 + 组装待插入数据
const toInsert: any[] = [];
let skipCount = 0;
for (const r of rows as any[]) {
// 去重:已存在的记录跳过
if (!r.indoorUnitAlarmId || existingIds.has(r.indoorUnitAlarmId)) {
skipCount++;
continue;
}
// 设备未注册跳过
const deviceId = r.indoorUnitAddressFull;
if (!deviceId || !registeredDeviceIds.has(deviceId)) {
skipCount++;
continue;
}
// 获取故障详情
const faultInfo = faultInfoMap.get(r.alarmCode);
const faultFound = faultInfo
&& faultInfo.errorInfo !== r.alarmCode
&& faultInfo.errorInfo !== '查询失败';
const errorMsg = faultFound ? faultInfo.errorInfo : '未找到主内机';
toInsert.push({
device_id: deviceId,
fault_type: getFaultTypeName(r.alarmType),
fault_code: r.alarmCode || '',
fault_description: errorMsg,
fault_origin_id: r.indoorUnitAlarmId,
occurred_time: r.deviceTime ? new Date(r.deviceTime) : new Date(),
level: 2,
status: 0,
});
}
// 6. 批量写入
if (toInsert.length > 0) {
await faultModel.bulkCreate(toInsert);
}
console.log(`[预警工单集成] 完成,新增 ${toInsert.length} 条,跳过 ${skipCount} 条(去重/未注册)`);
} catch (err) {
console.error('[预警工单集成] 执行异常:', err);
}
}
// ==================== 飞奕:区域设备关联同步 ====================
/**
* 数据集成-飞奕区域设备关联同步
* 从 device 表中读取飞奕设备已绑定的 region_key,同步写入 region_device_rel 表
* 涵盖空调设备和电表设备
* 同步策略:先删后插,删除本批次涉及设备的全部旧关联,再插入新关联
*/
export async function feiyiRegionDeviceRel() {
console.log('[飞奕区域设备关联] 开始执行...');
try {
const relModel = mysqlModelMap['region_device_rel'];
const deviceModel = mysqlModelMap['device'];
if (!relModel || !deviceModel) {
console.error('[飞奕区域设备关联] region_device_rel 或 device 表模型未初始化,跳过');
return;
}
// 1. 查询所有已绑定 region_key 的飞奕设备(空调 + 电表)
const devices = await deviceModel.findAll({
where: {
device_type: ['空调', '新风', '电能监测'],
region_key: { [Op.ne]: null },
},
attributes: ['device_id', 'region_key', 'device_type'],
raw: true,
}) as any[];
// 过滤掉 region_key 为空或为 "0" 的记录(兼容历史数据)
const validDevices = devices.filter(
(d: any) => d.region_key && String(d.region_key).trim() !== '' && String(d.region_key) !== '0'
);
console.log(`[飞奕区域设备关联] 共 ${devices.length} 个飞奕设备,有效关联 ${validDevices.length} 个`);
if (validDevices.length === 0) {
console.log('[飞奕区域设备关联] 无有效关联设备,跳过');
return;
}
// 2. 收集涉及的所有 device_id
const targetDeviceIds = validDevices.map((d: any) => d.device_id);
// 3. 先删:清除这些设备的全部旧关联
const deleted = await relModel.destroy({
where: { device_id: { [Op.in]: targetDeviceIds } },
});
console.log(`[飞奕区域设备关联] 清除旧关联 ${deleted} 条`);
// 4. 后插:批量写入新关联
const newRels = validDevices.map((d: any) => ({
region_key: String(d.region_key),
device_id: d.device_id,
relation_type: 'region_to_device',
}));
await relModel.bulkCreate(newRels);
console.log(`[飞奕区域设备关联] 完成,新关联 ${newRels.length} 条`);
} catch (err) {
console.error('[飞奕区域设备关联] 执行异常:', err);
}
}
// ==================== 迪勤:数据集成 ====================
/**
* 迪勤 reading 数组 → 设备数据 JSON 对象
* 将迪勤的 [{ indicator, value }] 格式转为 { key: value } 的平面对象
* 特殊映射:light → lightLevel(与系统现有字段名对齐)
*/
function parseDiqinReading(reading: Array<{ indicator: string; value: number }> | undefined): Record<string, number> {
if (!reading || !Array.isArray(reading)) return {};
const result: Record<string, number> = {};
for (const item of reading) {
const key = item.indicator === 'light' ? 'lightLevel' : item.indicator;
result[key] = item.value;
}
return result;
}
/**
* 数据集成-迪勤区域集成
* 将迪勤平台的场所(location)同步到 region 表
* region_key = location.id,无父节点
*/
export async function diqinRegion() {
console.log('[迪勤区域集成] 开始执行...');
try {
const regionModel = mysqlModelMap['region'];
if (!regionModel) {
console.error('[迪勤区域集成] region 表模型未初始化,跳过');
return;
}
// 1. 获取所有场所
const locations = await getLocations();
if (!locations || !Array.isArray(locations)) {
console.warn('[迪勤区域集成] 未获取到场所数据');
return;
}
console.log(`[迪勤区域集成] 共获取 ${locations.length} 个场所`);
// 2. 逐一 upsert
let insertCount = 0;
let updateCount = 0;
for (const loc of locations) {
const regionKey = loc.id;
if (!regionKey) continue;
const existing = await regionModel.findOne({ where: { region_key: regionKey } });
if (existing) {
// 更新名称(如有变化)
if (existing.region_name !== loc.name) {
await regionModel.update(
{ region_name: loc.name, updated_at: new Date() },
{ where: { id: existing.id } },
);
updateCount++;
}
} else {
await regionModel.create({
region_key: regionKey,
region_name: loc.name,
parent_id: null,
parent_key: null,
region_type: null,
sort_order: 0,
});
insertCount++;
}
}
console.log(`[迪勤区域集成] 完成,新增 ${insertCount} 条,更新 ${updateCount} 条`);
} catch (err) {
console.error('[迪勤区域集成] 执行异常:', err);
}
}
/**
* 数据集成-迪勤设备集成
* 将迪勤平台的设备(data_source)同步到 device 表
* 仅处理 DIQIN_DEVICE_TYPE_MAP 中配置的型号(目前仅 IEQ)
* ORI PRO / ORI API 跳过
*/
export async function diqinDevice() {
console.log('[迪勤设备集成] 开始执行...');
try {
const deviceModel = mysqlModelMap['device'];
if (!deviceModel) {
console.error('[迪勤设备集成] device 表模型未初始化,跳过');
return;
}
// 1. 获取所有设备
const dataSources = await getDataSources();
if (!dataSources || !Array.isArray(dataSources)) {
console.warn('[迪勤设备集成] 未获取到设备数据');
return;
}
console.log(`[迪勤设备集成] 共获取 ${dataSources.length} 个设备`);
// 2. 过滤:仅处理已配置映射的型号
const validSources = dataSources.filter(
(ds: any) => DIQIN_DEVICE_TYPE_MAP[ds.data_source_model_name]
);
const skippedCount = dataSources.length - validSources.length;
if (skippedCount > 0) {
console.log(`[迪勤设备集成] 跳过 ${skippedCount} 个非目标型号设备(ORI PRO / ORI API 等)`);
}
// 3. 逐一 upsert
let insertCount = 0;
let updateCount = 0;
for (const ds of validSources) {
const deviceId = ds.id;
if (!deviceId) continue;
const deviceType = DIQIN_DEVICE_TYPE_MAP[ds.data_source_model_name];
const deviceName = ds.name || ds.identifier || deviceId;
const existing = await deviceModel.findOne({ where: { device_id: deviceId } });
if (existing) {
// 更新名称和类型(如有变化)
let needUpdate = false;
if (existing.device_name !== deviceName) { existing.device_name = deviceName; needUpdate = true; }
if (existing.device_type !== deviceType) { existing.device_type = deviceType; needUpdate = true; }
if (needUpdate) {
await deviceModel.update(
{ device_name: deviceName, device_type: deviceType, updated_at: new Date() },
{ where: { id: existing.id } },
);
updateCount++;
}
} else {
await deviceModel.create({
device_id: deviceId,
region_key: null,
device_type: deviceType,
device_name: deviceName,
device_state: 1, // 默认在线
});
insertCount++;
}
}
console.log(`[迪勤设备集成] 完成,新增 ${insertCount} 条,更新 ${updateCount} 条`);
} catch (err) {
console.error('[迪勤设备集成] 执行异常:', err);
}
}
/**
* 数据集成-迪勤区域设备关联同步
* 匹配逻辑:station.name 包含 data_source.name → 建立 region_device_rel 关联
* 同步策略:先删后插,删除本批次设备的全部旧关联,再插入新关联
*/
export async function diqinRegionDeviceRel() {
console.log('[迪勤区域设备关联] 开始执行...');
try {
const relModel = mysqlModelMap['region_device_rel'];
if (!relModel) {
console.error('[迪勤区域设备关联] region_device_rel 表模型未初始化,跳过');
return;
}
// 1. 获取所有 IEQ 设备
const dataSources = await getDataSources();
if (!dataSources || !Array.isArray(dataSources)) return;
const ieqSources = dataSources.filter(
(ds: any) => DIQIN_DEVICE_TYPE_MAP[ds.data_source_model_name]
);
if (ieqSources.length === 0) {
console.log('[迪勤区域设备关联] 无 IEQ 设备,跳过');
return;
}
console.log(`[迪勤区域设备关联] IEQ 设备共 ${ieqSources.length} 个`);
// 2. 获取所有场所及其监测点
// 构建 stationName → locationId 映射
const locations = await getLocations();
if (!locations || !Array.isArray(locations)) return;
const stationLocMap = new Map<string, string>(); // stationName → locationId
for (const loc of locations) {
try {
const detail = await getLocationDetail(loc.id);
const stations = detail?.stations || [];
for (const st of stations) {
if (st.name) {
stationLocMap.set(st.name, loc.id);
}
}
} catch (err) {
console.warn(`[迪勤区域设备关联] 获取场所 ${loc.name} 明细失败:`, err.message);
}
}
console.log(`[迪勤区域设备关联] stationLocMap 共 ${stationLocMap.size} 条`);
// 3. 匹配:station.name 包含 data_source.name → 建立关联
const newRels: Array<{ region_key: string; device_id: string }> = [];
const ieqDeviceIds: string[] = [];
for (const ds of ieqSources) {
const dsName = ds.name || ds.identifier;
if (!dsName) continue;
ieqDeviceIds.push(ds.id);
// 遍历所有 station,找包含 device name 的
for (const [stationName, locationId] of stationLocMap) {
if (stationName.includes(dsName)) {
newRels.push({ region_key: locationId, device_id: ds.id });
break; // 一个设备只匹配第一个命中的 station
}
}
}
console.log(`[迪勤区域设备关联] 匹配到 ${newRels.length} 条关联`);
// 4. 先删后插:删除这批 IEQ 设备的全部旧关联
if (ieqDeviceIds.length > 0) {
const deleted = await relModel.destroy({
where: { device_id: { [Op.in]: ieqDeviceIds } },
});
console.log(`[迪勤区域设备关联] 清除旧关联 ${deleted} 条`);
}
// 5. 批量插入新关联
if (newRels.length > 0) {
await relModel.bulkCreate(
newRels.map(r => ({
region_key: r.region_key,
device_id: r.device_id,
relation_type: 'region_to_device',
})),
);
}
console.log(`[迪勤区域设备关联] 完成,新关联 ${newRels.length} 条`);
} catch (err) {
console.error('[迪勤区域设备关联] 执行异常:', err);
}
}
/**
* 数据集成-迪勤设备数据同步
* 对每个 IEQ 设备,取最新一条 reading 数据写入 device_data 表
* 使用每个设备自身的 reading_time 作为 device_time
* 增量策略:只插入比该设备已有最新 record 更新的数据
*/
export async function diqinDeviceData() {
console.log('[迪勤设备数据集成] 开始执行...');
try {
const deviceDataModel = mysqlModelMap['device_data'];
if (!deviceDataModel) {
console.error('[迪勤设备数据集成] device_data 表模型未初始化,跳过');
return;
}
// 1. 获取所有 IEQ 设备
const dataSources = await getDataSources();
if (!dataSources || !Array.isArray(dataSources)) return;
const ieqSources = dataSources.filter(
(ds: any) => DIQIN_DEVICE_TYPE_MAP[ds.data_source_model_name]
);
if (ieqSources.length === 0) {
console.log('[迪勤设备数据集成] 无 IEQ 设备,跳过');
return;
}
// 2. 批量查询这些设备在 device_data 表中的最大 device_time
const ieqDeviceIds = ieqSources.map((ds: any) => ds.id);
const maxTimeMap = await buildMaxTimeMap(deviceDataModel, ieqDeviceIds);
// 3. 逐个设备获取最新明细并判断增量
const toInsert: any[] = [];
let skipCount = 0;
for (const ds of ieqSources) {
const deviceId = ds.id;
if (!deviceId) continue;
let detail: any;
try {
detail = await getDataSourceDetail(deviceId);
} catch (err) {
console.warn(`[迪勤设备数据集成] 获取设备 ${deviceId} 明细失败:`, err.message);
skipCount++;
continue;
}
if (!detail || !detail.reading || detail.reading.length === 0) {
skipCount++;
continue;
}
// reading_time 格式:"2026-07-20T14:05:32.000+08:00"
const readingTime = detail.reading_time ? new Date(detail.reading_time) : new Date();
if (!isNewerThan(readingTime, maxTimeMap.get(deviceId))) {
skipCount++;
continue;
}
const data = parseDiqinReading(detail.reading);
toInsert.push({
device_id: deviceId,
device_data: data,
device_time: readingTime,
created_at: new Date(),
updated_at: new Date(),
});
}
// 4. 批量插入
if (toInsert.length > 0) {
await deviceDataModel.bulkCreate(toInsert);
}
console.log(`[迪勤设备数据集成] 完成,新增 ${toInsert.length} 条,跳过 ${skipCount} 条`);
} catch (err) {
console.error('[迪勤设备数据集成] 执行异常:', err);
}
}
/**
* 数据集成-迪勤设备 region_key 回填
* 通过 region_device_rel 关联表,将匹配到的 region_key 回填到 device 表作为冗余字段
*/
export async function diqinDeviceRegionKey() {
console.log('[迪勤设备region_key回填] 开始...');
try {
const deviceModel = mysqlModelMap['device'];
const relModel = mysqlModelMap['region_device_rel'];
if (!deviceModel || !relModel) {
console.error('[迪勤设备region_key回填] device 或 region_device_rel 表模型未初始化,跳过');
return;
}
// 1. 查询所有迪勤 IEQ 设备
const ieqDevices = await deviceModel.findAll({
where: { device_type: 'IEQ传感器' },
attributes: ['id', 'device_id', 'region_key'],
raw: true,
}) as any[];
if (ieqDevices.length === 0) {
console.log('[迪勤设备region_key回填] 无 IEQ 设备,跳过');
return;
}
console.log(`[迪勤设备region_key回填] IEQ 设备共 ${ieqDevices.length} 个`);
// 2. 批量查询这些设备在 region_device_rel 中的关联
const ieqDeviceIds = ieqDevices.map((d: any) => d.device_id);
const rels = await relModel.findAll({
where: { device_id: { [Op.in]: ieqDeviceIds } },
attributes: ['device_id', 'region_key'],
raw: true,
}) as any[];
// 构建 deviceId → region_key 映射(一个设备可能关联多个区域,取第一个)
const deviceRegionMap = new Map<string, string>();
for (const rel of rels) {
if (!deviceRegionMap.has(rel.device_id) && rel.region_key) {
deviceRegionMap.set(rel.device_id, String(rel.region_key));
}
}
console.log(`[迪勤设备region_key回填] region_device_rel 中匹配到 ${deviceRegionMap.size} 个设备`);
// 3. 比对并更新 device.region_key
let updateCount = 0;
for (const device of ieqDevices) {
const targetKey = deviceRegionMap.get(device.device_id) || '';
if (device.region_key === targetKey) continue;
await deviceModel.update(
{ region_key: targetKey || null, updated_at: new Date() },
{ where: { id: device.id } },
);
updateCount++;
}
console.log(`[迪勤设备region_key回填] 完成,更新 ${updateCount} 条`);
} catch (err) {
console.error('[迪勤设备region_key回填] 执行异常:', err);
}
}
/**
* 迪勤数据全量同步入口
* 按顺序执行:区域 → 设备 → 区域设备关联 → 设备region_key回填 → 设备数据
*/
export async function diqinSyncAll() {
console.log('[迪勤全量同步] 开始...');
await diqinRegion();
await diqinDevice();
await diqinRegionDeviceRel();
await diqinDeviceRegionKey();
await diqinDeviceData();
console.log('[迪勤全量同步] 完成');
}
// ==================== 故障状态自动更新 ====================
/**
* 故障状态自动更新
* 故障驱动模式:拉取所有未处理/处理中的故障,其设备一次拉回故障时间之后的全部开机记录,
* 在内存中比对,状态匹配的批量更新为"已解决"。
* 始终发 2 条 SQL,与故障数无关;不设时间兜底,服务宕机重启后也能补全。
*/
export async function processFaultStatus() {
console.log('[故障状态更新] 开始...');
const faultModel = mysqlModelMap['device_fault'];
const deviceDataModel = mysqlModelMap['device_data'];
if (!faultModel || !deviceDataModel) {
console.error('[故障状态更新] device_fault 或 device_data 模型未初始化,跳过');
return;
}
try {
// ① 一次查出所有未处理/处理中的故障
const activeFaults = await faultModel.findAll({
where: { status: { [Op.in]: [0, 1] } },
attributes: ['id', 'device_id', 'occurred_time'],
raw: true,
});
console.log(`[故障状态更新] 共 ${activeFaults.length} 条活跃故障`);
if (activeFaults.length === 0) {
console.log('[故障状态更新] 无活跃故障,跳过');
return;
}
// ② 去重 device_id + 建立每个设备的最早故障时间映射
// deviceFaultTimeMap: 每个设备最早的活跃故障发生时间,用于内存中精确过滤
// globalMinOccurredTime: 全局最早故障时间,仅作为 SQL 查询范围下限
const deviceSet = new Set<string>();
let globalMinOccurredTime: Date | null = null;
const deviceFaultTimeMap = new Map<string, Date>();
for (const f of activeFaults as any[]) {
deviceSet.add(f.device_id);
const t = f.occurred_time ? new Date(f.occurred_time) : null;
if (!t) continue;
// 全局最早时间(SQL 查询范围)
if (!globalMinOccurredTime || t < globalMinOccurredTime) {
globalMinOccurredTime = t;
}
// 每个设备的最早故障时间(内存过滤用)
const existing = deviceFaultTimeMap.get(f.device_id);
if (!existing || t < existing) {
deviceFaultTimeMap.set(f.device_id, t);
}
}
const deviceIds = [...deviceSet];
// ③ 一次拉回所有相关设备的运行记录(全局最早故障时间之后)
// 不做时间兜底,宕机重启后也能覆盖全部历史
const deviceDataRecords = await deviceDataModel.findAll({
where: {
device_id: { [Op.in]: deviceIds },
...(globalMinOccurredTime ? { device_time: { [Op.gte]: globalMinOccurredTime } } : {}),
},
attributes: ['device_id', 'data', 'device_time'],
order: [['device_id', 'ASC'], ['device_time', 'ASC']],
raw: true,
});
console.log(`[故障状态更新] 共 ${deviceDataRecords.length} 条相关设备数据记录`);
// ④ 内存过滤:只保留各设备最早故障时间之后的开机记录(power='on')
// 关键修复:不以全局最早时间过滤,而是用每个设备自己的最早故障时间,
// 避免将故障发生前的开机记录纳入比对,导致永远无法判定"已解决"
const powerOnTimesMap = new Map<string, Date[]>();
for (const r of deviceDataRecords as any[]) {
let power: string | undefined;
const rawData = r.data;
if (typeof rawData === 'string') {
try { power = JSON.parse(rawData).power; } catch { continue; }
} else if (rawData && typeof rawData === 'object') {
power = rawData.power;
}
if (power !== 'on') continue;
const dt = r.device_time ? new Date(r.device_time) : null;
if (!dt) continue;
// 只保留该设备最早故障时间之后的记录
const deviceFaultTime = deviceFaultTimeMap.get(r.device_id);
if (deviceFaultTime && dt <= deviceFaultTime) continue;
if (!powerOnTimesMap.has(r.device_id)) {
powerOnTimesMap.set(r.device_id, []);
}
powerOnTimesMap.get(r.device_id)!.push(dt);
}
// ⑤ 逐条故障比对:查找该故障发生时间之后最早的开机记录
// 关键修复:不再拿"全量最早开机时间"与故障时间对比,
// 而是在该设备故障后出现的开机记录中找第一条,确保 isAfter 判定正确
const toUpdate: { id: number; resolved_time: Date }[] = [];
for (const fault of activeFaults as any[]) {
const powerOnTimes = powerOnTimesMap.get(fault.device_id);
if (!powerOnTimes || powerOnTimes.length === 0) continue;
const occurredTime = fault.occurred_time ? new Date(fault.occurred_time) : null;
if (!occurredTime) continue;
// 数据已按 device_time ASC 排序,找第一条在故障时间之后的记录
const resolvedTime = powerOnTimes.find(dt => dt > occurredTime);
if (resolvedTime) {
toUpdate.push({ id: fault.id, resolved_time: resolvedTime });
}
}
if (toUpdate.length === 0) {
console.log('[故障状态更新] 无符合条件的故障需更新');
return;
}
// ⑥ 逐条更新 status=2
for (const item of toUpdate) {
await faultModel.update(
{ status: 2, resolved_time: item.resolved_time, updated_at: new Date() },
{ where: { id: item.id } },
);
}
console.log(`[故障状态更新] 完成,共处理 ${toUpdate.length} 条故障`);
} catch (err) {
console.error('[故障状态更新] 执行异常:', err);
}
}
/** /**
* 迪勤云平台 API 客户端 * 迪勤云平台 API 客户端
* 参考 feiyiClient.ts 的编码风格:async/await、BizError、清晰的注释
*/ */
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { post } from '../util/request'; import { get } from '../util/request';
import { BizError } from '../util/bizError'; import { BizError } from '../util/bizError';
import { ERRORENUM } from '../config/errorEnum'; import { ERRORENUM } from '../config/errorEnum';
import { DIQIN } from '../config/clientEnum';
import { systemConfig } from '../config/serverConfig';
// 全局变量缓存 token 和过期时间
let diQinToken = "";
let expireTime = 0;
let isFetchingToken = false;
let refreshTokenPromise: Promise<string> | null = null;
/**
* 获取迪勤云平台配置(从 serverConfig.xml 读取)
*/
function getDiQinConfig() {
const config = systemConfig.diqin;
if (!config || !config.appKey || !config.secretKey) {
throw new BizError(ERRORENUM.参数错误, '迪勤云平台配置缺失,请检查 serverConfig.xml 中 <diqin> 节点');
}
return {
baseUrl: config.baseUrl || 'https://airiccc.com',
appKey: config.appKey,
secretKey: config.secretKey,
apiVersion: '2.0',
};
}
/**
* 格式化时间戳(北京时间 yyyy-MM-dd HH:mm:ss)
* TODO: 时区问题暂时忽略,后续需要确保服务器时区为北京时间或做转换
* @param date 日期对象,默认当前时间
*/
function formatTimestamp(date: Date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 生成迪勤签名
* 算法:
* 1. 除 sign 外,按 key 升序排列所有参数
* 2. key+value 纯拼接(无 =、& 分隔符!)
* 3. 末尾追加 secretKey
* 4. MD5 并转大写
* @param params 请求参数对象(不含 sign)
* @param secretKey 密钥
* @returns 大写 MD5 签名字符串
*/
function generateDiQinSign(params: Record<string, any>, secretKey: string): string {
// 1. 过滤 sign,按 key 升序排序
const sortedKeys = Object.keys(params)
.filter(key => key !== 'sign')
.sort();
// 2. key+value 纯拼接
const signStr = sortedKeys.map(key => `${key}${params[key]}`).join('');
// 3. 末尾追加 secretKey
const stringSignTemp = signStr + secretKey;
console.log('迪勤签名原始串:', stringSignTemp);
// 4. MD5 并转大写
return crypto.createHash('md5').update(stringSignTemp).digest('hex').toUpperCase();
}
/**
* 获取迪勤云平台 access_token
* 参照飞奕 getFeiYiToken() 的缓存策略:token 有效期内直接返回缓存
* @returns {Promise<string>} access_token
* @throws 如果请求失败或响应码非 10000,则抛出 BizError
*/
export async function getDiQinToken(): Promise<string> {
// 校验 token 是否有效,有效则直接返回
const now = Date.now();
if (diQinToken && expireTime > now) {
console.log(`使用缓存迪勤 token,剩余有效时间: ${(expireTime - now) / 1000} 秒`);
return diQinToken;
}
// 避免并发重复获取 token
if (isFetchingToken && refreshTokenPromise) {
console.log('已有正在进行的 token 刷新请求,等待结果...');
return refreshTokenPromise;
}
isFetchingToken = true;
refreshTokenPromise = (async () => {
try {
const config = getDiQinConfig();
const url = `${config.baseUrl}${DIQIN.获取Token}`;
// 构建请求参数
const t = formatTimestamp();
const queryParams: Record<string, any> = {
app_key: config.appKey,
t: t,
v: config.apiVersion,
};
// 生成签名
const sign = generateDiQinSign(queryParams, config.secretKey);
queryParams.sign = sign;
console.log('获取迪勤 token 请求参数:', queryParams);
let result: any;
try {
result = await get(url, queryParams);
} catch (err) {
throw new BizError(ERRORENUM.网络错误, `获取迪勤 token 网络失败: ${err.message}`);
}
console.log('获取迪勤 token 响应:', result);
// 检查响应:文档中返回 { meta: { code, message }, data: { token, expired_at } }
if (!result || !result.meta || result.meta.code !== 10000) {
throw new BizError(ERRORENUM.第三方接口错误, `获取迪勤 token 失败: ${result?.meta?.message || '未知错误'}`);
}
const token = result.data?.token;
if (!token) {
throw new BizError(ERRORENUM.第三方接口错误, '迪勤响应中未包含 token');
}
// 更新全局 token 和过期时间
diQinToken = token;
// Token 有效期策略:参考飞奕
if (result.data.expired_at) {
// 有明确过期时间,使用该时间
const expiredAt = new Date(result.data.expired_at).getTime();
// 提前 5 分钟刷新,避免临界过期
expireTime = expiredAt - 5 * 60 * 1000;
} else {
// 未开启有效期(expired_at 为 null),默认 2 小时
expireTime = now + 2 * 60 * 60 * 1000;
}
console.log(`获取新迪勤 token: ${token}, 过期时间: ${new Date(expireTime).toISOString()}`);
return token;
} finally {
isFetchingToken = false;
refreshTokenPromise = null;
}
})();
return refreshTokenPromise;
}
/**
* 带 Token 的 GET 请求封装
* 自动获取/刷新 token,自动附加公共参数和签名
* @param path 接口路径(相对路径,如 /v2/locations)
* @param query 额外的查询参数(业务参数)
* @param retry 是否重试(用于 token 过期重试)
*/
async function diQinGet(path: string, query: Record<string, any> = {}, retry: boolean = true): Promise<any> {
const config = getDiQinConfig();
// 获取 token
const token = await getDiQinToken();
const url = `${config.baseUrl}${path}`;
const headers = {
'Authorization': `${token}`,
};
// 拼接公共参数
const t = formatTimestamp();
const publicParams: Record<string, any> = {
app_key: config.appKey,
t: t,
v: config.apiVersion,
};
// 合并业务参数
const allParams = { ...publicParams, ...query };
// 生成签名
const sign = generateDiQinSign(allParams, config.secretKey);
allParams.sign = sign;
console.log(`请求 ${path} 参数:`, allParams);
let result: any;
try {
result = await get(url, allParams, headers);
console.log(`请求 ${path} 成功,响应:`, result);
} catch (err) {
throw new BizError(ERRORENUM.网络错误, `请求 ${path} 失败: ${err.message}`);
}
// 检查响应码
if (!result || !result.meta || result.meta.code !== 10000) {
// Token 过期重试一次
const errCode = result?.meta?.code;
if (retry && (errCode === 401 || errCode === 10001 || (result?.meta?.message || '').includes('token'))) {
console.log('迪勤 token 可能已过期,清除缓存重试...');
diQinToken = "";
expireTime = 0;
return diQinGet(path, query, false);
}
throw new BizError(ERRORENUM.第三方接口错误, `迪勤接口 ${path} 调用失败: ${result?.meta?.message || JSON.stringify(result)}`);
}
return result;
}
// ======================== 业务接口 ========================
/** /**
* 获取网关设备列表(分页) * 获取场所列表
* @returns 场所数据
*/
export async function getLocations(): Promise<any> {
const path = DIQIN.获取区域信息;
const result = await diQinGet(path);
return result.data;
}
/**
* 获取场所明细(含实时数据)
* @param locationId 场所 ID
* @returns 场所明细数据
*/
export async function getLocationDetail(locationId: string): Promise<any> {
if (!locationId) throw new BizError(ERRORENUM.参数错误, '场所 ID 不能为空');
const path = `${DIQIN.获取区域明细信息}${locationId}`;
const result = await diQinGet(path);
return result.data;
}
/**
* 获取监测点明细
* @param stationId 监测点 ID
* @returns 监测点明细数据
*/
export async function getStationDetail(stationId: string): Promise<any> {
if (!stationId) throw new BizError(ERRORENUM.参数错误, '监测点 ID 不能为空');
const path = `${DIQIN.获取监测点明细}${stationId}`;
const result = await diQinGet(path);
return result.data;
}
/**
* 获取设备列表
* @returns 设备列表数据
*/
export async function getDataSources(): Promise<any> {
const path = DIQIN.获取设备列表;
const result = await diQinGet(path);
return result.data;
}
/**
* 获取设备明细(含实时数据)
* @param dataSourceId 设备 ID
* @returns 设备明细数据
*/
export async function getDataSourceDetail(dataSourceId: string): Promise<any> {
if (!dataSourceId) throw new BizError(ERRORENUM.参数错误, '设备 ID 不能为空');
const path = `${DIQIN.获取设备明细}${dataSourceId}`;
const result = await diQinGet(path);
return result.data;
}
/**
* 获取历史数据
* @param params 查询参数,包含:
* - m: 指标类型(如 temperature、humidity、pm25 等)
* - id: 场所/监测点/设备 ID(location_id / station_id / data_source_id)
* - type: ID 类型(location / station / datasource)
* - start: 开始时间
* - end: 结束时间
* @returns 历史数据
*/
export async function getReadings(params: Record<string, any>): Promise<any> {
const path = DIQIN.获取历史数据;
const result = await diQinGet(path, params);
return result.data;
}
/**
* 获取图表数据
* @param params 查询参数,包含:
* - m: 指标类型
* - id: ID
* - indicator: 指标
* - start: 开始时间
* - end: 结束时间
* @returns 图表数据
*/
export async function getGraphs(params: Record<string, any>): Promise<any> {
const path = DIQIN.获取图表数据;
const result = await diQinGet(path, params);
return result.data;
}
/**
* 获取环境设备数据(综合接口,用于调试入口)
* 组合调用 getDataSources + getDataSourceDetail,返回设备列表及其明细数据
* @param page 当前页码,默认 1 * @param page 当前页码,默认 1
* @param limit 每页条数,默认 10 * @param limit 每页条数,默认 10
* @returns 网关设备数据,包含 rows、total 等 * @returns 设备数据,包含 rows、total 等
*/ */
export async function processDeviceData(page: number = 1, limit: number = 10): Promise<any> { export async function processDeviceData(page: number = 1, limit: number = 10): Promise<any> {
const path = ''; // 获取所有设备列表
const body = { page, limit }; const dataSources = await getDataSources();
const result = {data: {}}; if (!dataSources || !Array.isArray(dataSources)) {
return result.data; return { page, limit, total: 0, rows: [] };
} }
// 分页
const total = dataSources.length;
const start = (page - 1) * limit;
const end = start + limit;
const pagedItems = dataSources.slice(start, end);
export async function getToken() { // 获取每个设备的明细数据
const rows = [];
for (const item of pagedItems) {
try {
const detail = await getDataSourceDetail(item.id || item.data_source_id);
rows.push({
...item,
detail: detail,
});
} catch (err) {
console.log(`获取设备 ${item.id || item.data_source_id} 明细失败:`, err.message);
rows.push(item);
}
}
return { page, limit, total, rows };
} }
...@@ -4,27 +4,27 @@ ...@@ -4,27 +4,27 @@
*/ */
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { post } from '../util/request'; import { get, post } from '../util/request';
import { BizError } from '../util/bizError'; import { BizError } from '../util/bizError';
import { ERRORENUM } from '../config/errorEnum'; import { ERRORENUM } from '../config/errorEnum';
import { FEIYI } from '../config/clientEnum'; import { FEIYI } from '../config/clientEnum';
import { systemConfig } from '../config/serverConfig';
// 全局变量缓存 token 和过期时间 // 全局变量缓存 token 和过期时间
let feiYiToken = ""; let feiYiToken = "";
let expireTime = 0; let expireTime = 0;
let getTokenTime = 0;
let isFetchingToken = false; // 避免并发获取 token 的标志位
let refreshTokenPromise: Promise<string> | null = null; // 用于避免并发刷新 token 的 Promise
/** /**
* 飞奕云平台配置(建议从环境变量或配置文件中读取 * 获取飞奕云平台配置(从 serverConfig.xml 读取,兜底使用环境变量或默认值
*/ */
const FEIYI_CONFIG = { function getFeiYiConfig() {
baseUrl: 'https://m.achelp.cn/open', const config = systemConfig.feiyi;
clientId: process.env.FEIYI_CLIENT_ID || '419636996764598272', // 我方提供的 clientId return {
secretKey: process.env.FEIYI_SECRET_KEY || '02fc75446b4544b3a02444a2b5f9be2b', // 我方提供的 appSecret baseUrl: (config && config.baseUrl) || process.env.FEIYI_BASE_URL || 'https://m.achelp.cn/open',
}; clientId: (config && config.appKey) || process.env.FEIYI_CLIENT_ID || '419636996764598272',
secretKey: (config && config.secretKey) || process.env.FEIYI_SECRET_KEY || '02fc75446b4544b3a02444a2b5f9be2b',
};
}
/** /**
* 生成随机字符串 nonce * 生成随机字符串 nonce
...@@ -70,7 +70,8 @@ function generateSign(params: Record<string, any>, timestamp: string, nonce: str ...@@ -70,7 +70,8 @@ function generateSign(params: Record<string, any>, timestamp: string, nonce: str
* @throws 如果请求失败或响应码非 00000,则抛出 BizError * @throws 如果请求失败或响应码非 00000,则抛出 BizError
*/ */
export async function getFeiYiToken(): Promise<string> { export async function getFeiYiToken(): Promise<string> {
const url = `${FEIYI_CONFIG.baseUrl}${FEIYI.获取Token}`; const feiyiConfig = getFeiYiConfig();
const url = `${feiyiConfig.baseUrl}${FEIYI.获取Token}`;
// 校验token是否有效,有效则直接返回 // 校验token是否有效,有效则直接返回
const now = Date.now(); const now = Date.now();
...@@ -82,12 +83,12 @@ export async function getFeiYiToken(): Promise<string> { ...@@ -82,12 +83,12 @@ export async function getFeiYiToken(): Promise<string> {
// 根据文档,获取 token 的请求参数 // 根据文档,获取 token 的请求参数
const params = { const params = {
AccessKey: '', // 文档要求传空串 AccessKey: '', // 文档要求传空串
clientId: FEIYI_CONFIG.clientId clientId: feiyiConfig.clientId
}; };
// 生成签名 // 生成签名
const timestamp = Date.now().toString(); // 毫秒时间戳 const timestamp = Date.now().toString(); // 毫秒时间戳
const nonce = generateNonce(); const nonce = generateNonce();
const sign = generateSign(params, timestamp, nonce, FEIYI_CONFIG.secretKey); const sign = generateSign(params, timestamp, nonce, feiyiConfig.secretKey);
console.log('MD5加密签名:', sign); console.log('MD5加密签名:', sign);
const requestBody = { const requestBody = {
...@@ -116,7 +117,6 @@ export async function getFeiYiToken(): Promise<string> { ...@@ -116,7 +117,6 @@ export async function getFeiYiToken(): Promise<string> {
} else { } else {
// 更新全局 token 和过期时间 // 更新全局 token 和过期时间
feiYiToken = accessToken; feiYiToken = accessToken;
getTokenTime = now;
// 默认 2分钟 过期,如果响应中包含 expires_in 字段,则使用该值(单位毫秒) // 默认 2分钟 过期,如果响应中包含 expires_in 字段,则使用该值(单位毫秒)
expireTime = now + (result.data.expires_in ? result.data.expires_in : 2 * 60 * 1000); expireTime = now + (result.data.expires_in ? result.data.expires_in : 2 * 60 * 1000);
console.log(`获取新 token: ${accessToken}, 过期时间: ${new Date(expireTime).toISOString()}`); console.log(`获取新 token: ${accessToken}, 过期时间: ${new Date(expireTime).toISOString()}`);
...@@ -135,7 +135,8 @@ async function feiYiPost(path: string, body: any, retry: boolean = true): Promis ...@@ -135,7 +135,8 @@ async function feiYiPost(path: string, body: any, retry: boolean = true): Promis
// 获取 token // 获取 token
const token = await getFeiYiToken(); const token = await getFeiYiToken();
const url = `${FEIYI_CONFIG.baseUrl}${path}`; const feiyiConfig = getFeiYiConfig();
const url = `${feiyiConfig.baseUrl}${path}`;
const headers = { const headers = {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...@@ -380,3 +381,79 @@ export async function getIndoorUnitStateList(params: any): Promise<any> { ...@@ -380,3 +381,79 @@ export async function getIndoorUnitStateList(params: any): Promise<any> {
const result = await feiYiPost(path, body); const result = await feiYiPost(path, body);
return result.data; return result.data;
} }
/**
* 空调内机故障记录分页查询
* @param params { page, limit, roomIds?, indoorUnitAddressFull?, alarmType?, occurrenceTime? }
* @returns data 部分,包含 page, limit, total, rows
*/
export async function getIndoorUnitAlarmErrorHis(params: any): Promise<any> {
const path = '/openApi/indoorUnit/alarm/errorHis';
const body = { ...params };
const result = await feiYiPost(path, body);
return result.data;
}
/**
* 故障码查询故障详情
* 根据品牌编码和错误编码查询故障描述和解决方案
* 接口文档:空调故障码对接服务与解析流程.pdf
* @param brandCode 品牌编码,如 "1"=日立
* @param errorCode 错误编码,如 "LOST"
* @returns { errorInfo: string, scheme: string | null } 故障信息描述、解决方案
*/
export async function getFaultCodeInfo(brandCode: string, errorCode: string): Promise<{ errorInfo: string; scheme: string | null }> {
const url = 'https://errorcode.feiyikj.cn/prod-api/feiyi/app/errorCode/info';
const query = { brandCode, errorCode };
let result: any;
try {
result = await get(url, query);
} catch (err) {
console.error(`[故障码查询] 请求失败 brandCode=${brandCode} errorCode=${errorCode}:`, err);
return { errorInfo: '查询失败', scheme: null };
}
if (!result || result.code !== 200) {
console.error(`[故障码查询] 接口返回异常 brandCode=${brandCode} errorCode=${errorCode}:`, result);
return { errorInfo: '查询失败', scheme: null };
}
const data = result.data || [];
if (data.length === 0) {
console.warn(`[故障码查询] 未查到故障码信息 brandCode=${brandCode} errorCode=${errorCode}`);
return { errorInfo: errorCode, scheme: null };
}
return {
errorInfo: data[0].errorInfo || errorCode,
scheme: data[0].scheme || null,
};
}
/**
* 故障类型映射(中文 -> 数字)
*/
let faultTypeMap: { [key: string]: number } = {
"内机故障": 5,
"未绑定房间": 15
};
/** 数字 -> 中文的反向映射 */
let faultTypeReverseMap: Map<number, string> | null = null;
/** 构建数字->中文的反向映射 */
function getFaultTypeReverseMap(): Map<number, string> {
if (faultTypeReverseMap) return faultTypeReverseMap;
faultTypeReverseMap = new Map();
for (const [cn, num] of Object.entries(faultTypeMap)) {
faultTypeReverseMap.set(num, cn);
}
return faultTypeReverseMap;
}
/**
* 根据 alarmType 数字获取中文故障类型名称
* @param alarmType 故障类型数字
* @returns 中文名称,未匹配时返回原数字字符串
*/
export function getFaultTypeName(alarmType: number | string): string {
const num = typeof alarmType === 'string' ? parseInt(alarmType, 10) : alarmType;
return getFaultTypeReverseMap().get(num) || String(alarmType);
}
...@@ -46,6 +46,72 @@ export async function readRegionAxis(gatewayPage: number) { ...@@ -46,6 +46,72 @@ export async function readRegionAxis(gatewayPage: number) {
return result; return result;
} }
/**
* 动态查询:根据 regionName→regionKey 映射,从数据库查询各区域下的设备及最新功率状态
* @returns 格式与 mock 数据一致: { regionName: [{deviceId, deviceType, power}, ...] }
*/
async function buildDeviceMapDynamic(regionMap: { [regionName: string]: string }): Promise<{ [regionName: string]: any[] }> {
const deviceMap: { [regionName: string]: any[] } = {};
// 1. 收集所有 region_key
const regionKeys = Object.values(regionMap) as string[];
if (regionKeys.length === 0) return deviceMap;
// 2. 反向映射:regionKey → regionName
const regionKeyToName: { [key: string]: string } = {};
for (const name in regionMap) {
regionKeyToName[regionMap[name]] = name;
}
// 3. 批量查询这些区域下所有的设备
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ region_key: { '%in%': regionKeys } },
['device_id', 'region_key', 'device_type'],
);
const devices = (deviceResult.data || []) as any[];
// 4. 批量查询这些设备的最新一条 device_data,提取 power 状态
const deviceIds = devices.map((d) => d.device_id).filter(Boolean);
const powerMap = new Map<string, string>();
if (deviceIds.length > 0) {
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
'%orderDesc%': 'device_time',
'%limit%': deviceIds.length,
},
['device_id', 'device_data'],
);
// 按 device_time 降序返回,去重取每个设备的第一条(最新)
const seen = new Set<string>();
(dataResult.data || []).forEach((r: any) => {
if (!seen.has(r.device_id)) {
seen.add(r.device_id);
const parsed = typeof r.device_data === 'string' ? JSON.parse(r.device_data) : r.device_data;
powerMap.set(r.device_id, parsed?.power || 'on');
}
});
}
// 5. 组装 deviceMap(按 regionName 分组,格式对齐 mock 数据)
for (const device of devices) {
const regionName = regionKeyToName[device.region_key];
if (!regionName) continue;
if (!deviceMap[regionName]) {
deviceMap[regionName] = [];
}
deviceMap[regionName].push({
deviceId: device.device_id,
deviceType: device.device_type,
power: powerMap.get(device.device_id) || 'on',
});
}
return deviceMap;
}
async function readSheet(sheet: any[][], gatewayPage: number) { async function readSheet(sheet: any[][], gatewayPage: number) {
// 取出所有区域名称,查询区域表获取区域数据 // 取出所有区域名称,查询区域表获取区域数据
let regionList = await selectDataListByParam(TABLENAME.区域表, {}); let regionList = await selectDataListByParam(TABLENAME.区域表, {});
...@@ -59,40 +125,44 @@ async function readSheet(sheet: any[][], gatewayPage: number) { ...@@ -59,40 +125,44 @@ async function readSheet(sheet: any[][], gatewayPage: number) {
// "前台": "0001", // "前台": "0001",
// "卫生间": "3000" // "卫生间": "3000"
// } // }
let regionMap = regionList.data.map((bean)=>{ let regionMap:any = {};
regionList.data.map((bean)=>{
regionMap[bean.regionName] = bean.regionKey regionMap[bean.regionName] = bean.regionKey
}); });
// 根据区域信息查询所有环境设备,通过环境设备查询最新环境指标 // 根据区域信息查询所有环境设备,通过环境设备查询最新环境指标
let deviceMap = { // ====== MOCK 数据(注释保留作为格式参考) ======
"会议室": [ // let deviceMap = {
{ "deviceId": "AC1", "deviceType": "空调", "power": "off" }, // "会议室": [
{ "deviceId": "Z1", "deviceType": "照明", "power": "on" } // { "deviceId": "AC1", "deviceType": "空调", "power": "off" },
], // { "deviceId": "Z1", "deviceType": "照明", "power": "on" }
"公共办公区": [ // ],
{ "deviceId": "AC2", "deviceType": "空调", "power": "on" }, // "公共办公区": [
{ "deviceId": "Z12", "deviceType": "照明", "power": "on" }, // { "deviceId": "AC2", "deviceType": "空调", "power": "on" },
{ "deviceId": "F1", "deviceType": "排风", "power": "on" }, // { "deviceId": "Z12", "deviceType": "照明", "power": "on" },
{ "deviceId": "E1", "deviceType": "IEQ传感器", "power": "on" }, // { "deviceId": "F1", "deviceType": "排风", "power": "on" },
{ "deviceId": "Y1", "deviceType": "烟感器", "power": "on" } // { "deviceId": "E1", "deviceType": "IEQ传感器", "power": "on" },
], // { "deviceId": "Y1", "deviceType": "烟感器", "power": "on" }
"办公室": [ // ],
{ "deviceId": "AC3", "deviceType": "空调", "power": "on" }, // "办公室": [
{ "deviceId": "Z2", "deviceType": "照明", "power": "on" }, // { "deviceId": "AC3", "deviceType": "空调", "power": "on" },
{ "deviceId": "F2", "deviceType": "排风", "power": "on" } // { "deviceId": "Z2", "deviceType": "照明", "power": "on" },
], // { "deviceId": "F2", "deviceType": "排风", "power": "on" }
"仓库": [{ "deviceId": "AC11", "deviceType": "空调", "power": "off" }], // ],
"CEO办公室": [{ "deviceId": "AC12", "deviceType": "空调", "power": "off" }], // "仓库": [{ "deviceId": "AC11", "deviceType": "空调", "power": "off" }],
"传感器生产间": [{ "deviceId": "AC13", "deviceType": "空调", "power": "off" }], // "CEO办公室": [{ "deviceId": "AC12", "deviceType": "空调", "power": "off" }],
"卫生间": [ // "传感器生产间": [{ "deviceId": "AC13", "deviceType": "空调", "power": "off" }],
{ "deviceId": "S1", "deviceType": "摄像头", "power": "on" }, // "卫生间": [
{ "deviceId": "E100", "deviceType": "IEQ传感器", "power": "on" }, // { "deviceId": "S1", "deviceType": "摄像头", "power": "on" },
{ "deviceId": "X1", "deviceType": "音响", "power": "on" } // { "deviceId": "E100", "deviceType": "IEQ传感器", "power": "on" },
], // { "deviceId": "X1", "deviceType": "音响", "power": "on" }
"前台": [ // ],
{ "deviceId": "S0", "deviceType": "摄像头", "power": "on" }, // "前台": [
{ "deviceId": "T1", "deviceType": "人流监测", "power": "on" } // { "deviceId": "S0", "deviceType": "摄像头", "power": "on" },
] // { "deviceId": "T1", "deviceType": "人流监测", "power": "on" }
} // ]
// }
// ====== 动态查询:从数据库获取真实设备及最新状态 ======
const deviceMap = await buildDeviceMapDynamic(regionMap);
// 循环区域,根据各区域环境指标计算各区域的环境质量得出优良中差四个值 // 循环区域,根据各区域环境指标计算各区域的环境质量得出优良中差四个值
let sheetData = await planaryArrayBecomeOfBlockData(sheet); let sheetData = await planaryArrayBecomeOfBlockData(sheet);
let result = []; let result = [];
......
...@@ -2,23 +2,393 @@ ...@@ -2,23 +2,393 @@
* 运行分析页面 * 运行分析页面
*/ */
import * as crypto from 'crypto';
import { post } from '../util/request';
import { BizError } from '../util/bizError'; import { BizError } from '../util/bizError';
import { ERRORENUM } from '../config/errorEnum'; import { ERRORENUM } from '../config/errorEnum';
import { max } from 'moment';
import { title } from 'process';
import { TABLENAME } from '../config/dbEnum'; import { TABLENAME } from '../config/dbEnum';
import { selectDataListByParam, selectOneDataByParam } from '../data/findData'; import { selectDataListByParam, selectOneDataByParam, selectDataCountByParam } from '../data/findData';
import { controlIndoorUnit } from './feiyiClient'; import { controlIndoorUnit } from './feiyiClient';
import {
formatTime,
getTodayStart,
getTodayEnd,
getYesterdayStart,
getYesterdayEnd,
getMonthStart,
getLastMonthStart,
getLastMonthEnd,
getHoursAgo,
getDaysAgoStart,
getMonthsAgoStart,
calcCompareRate,
formatHourKey,
formatDayKey,
formatMonthKey,
} from '../util/dateUtils';
// ==================== 常量定义 ====================
const deviceTypeList: string[] = [
"IEQ传感器",
"烟感器",
"摄像头",
"空调",
"音响",
"照明",
"等离子",
"人流监测",
"电表",
"排风",
];
/**
* 空调中文到Key的映射
* 1 制冷 2 制热 3 送风 4 除湿
* 0 关-off 1 开-on
* 1 高风-超强 2 中风-强 4 低风-弱
* 0 不锁定-生效 1 锁定-解除
*/
const acDeviceKeyMap: { [key: string]: number } = {
"制冷": 1,
"制热": 2,
"送风": 3,
"除湿": 4,
"on": 1,
"off": 0,
"超强": 1,
"强": 2,
"弱": 4,
"解除": 1,
"生效": 0,
};
// ==================== 质量判定函数(从 device.ts 移植,适配新系统) ====================
/** 温度质量等级(夏/冬季模式) */
function getTemperatureQuality(value: number): string {
const month = new Date().getMonth() + 1;
const isWinter = month === 12 || month <= 2;
if (isWinter) {
if (value === 20) return '优';
if (value >= 18 && value <= 21) return '良';
if (value >= 14 && value <= 27) return '中';
return '差';
} else {
if (value === 24) return '优';
if (value >= 22 && value <= 26) return '良';
if (value >= 18 && value <= 32) return '中';
return '差';
}
}
const QUALITY_THRESHOLDS: { [key: string]: { you: [number, number]; liang: [number, number]; zhong: [number, number] } } = {
co2: { you: [0, 800], liang: [800, 1000], zhong: [1000, 1500] },
pm10: { you: [0, 50], liang: [50, 150], zhong: [150, 250] },
pm2_5: { you: [0, 35], liang: [35, 75], zhong: [75, 150] },
hcho: { you: [0, 0.03], liang: [0.03, 0.08], zhong: [0.08, 0.10] },
tvoc: { you: [0, 0.3], liang: [0.3, 0.6], zhong: [0.6, 1.0] },
o3: { you: [0, 0.05], liang: [0.05, 0.1], zhong: [0.1, 0.16] },
light_level: { you: [300, 500], liang: [100, 300], zhong: [50, 100] },
};
/** 质量判定 */
function getQualityLevel(key: string, value: number): string | null {
if (value === null || value === undefined) return null;
if (key === 'temperature') return getTemperatureQuality(value);
if (key === 'humidity') {
if (value >= 40 && value <= 60) return '优';
if ((value >= 30 && value < 40) || (value > 60 && value <= 70)) return '良';
if ((value >= 20 && value < 30) || (value > 70 && value <= 80)) return '中';
return '差';
}
if (key === 'pressure') {
if (value >= 1010 && value <= 1020) return '优';
if ((value >= 1000 && value < 1010) || (value > 1020 && value <= 1030)) return '良';
if ((value >= 990 && value < 1000) || (value > 1030 && value <= 1040)) return '中';
return '差';
}
const t = QUALITY_THRESHOLDS[key];
if (!t) return null;
if (value >= t.you[0] && value <= t.you[1]) return '优';
if (value >= t.liang[0] && value <= t.liang[1]) return '良';
if (value >= t.zhong[0] && value <= t.zhong[1]) return '中';
return '差';
}
/**
* 获取综合环境质量等级字符串(优秀/良好/一般/差)
* 基于 CO2 + PM2.5 的判定逻辑
*/
function getOverallQuality(co2: number, pm25: number): string {
if (co2 < 800 && pm25 <= 35) return '优秀';
if (co2 < 1000 && pm25 <= 45) return '优秀';
if (co2 <= 1000 && pm25 <= 50) return '良好';
if (co2 > 1000 && co2 < 1500 && pm25 > 50 && pm25 < 75) return '一般';
if (co2 >= 1500 || pm25 >= 75) return '差';
// 其余情况粗暴判定
if (co2 > 1000 || pm25 > 50) return '一般';
return '良好';
}
// ==================== 环境监测相关辅助 ====================
/** 环境指标 key 映射:device_data JSON 中的 key → 展示用的 key */
const ENV_INDICATOR_KEYS = ['temperature', 'humidity', 'pm25', 'co2', 'hcho', 'lightLevel', 'pm10', 'pressure', 'tvoc'];
/**
* 查询所有 "空气质量监测" 类型设备的最新一条 device_data
* @returns Map<device_id, { data: JSON, device_time: string }>
*/
async function getLatestEnvDeviceData(): Promise<Map<string, any>> {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: 'IEQ传感器' },
['device_id', 'device_name', 'region_key']
);
const devices = deviceResult.data || [];
if (devices.length === 0) return new Map();
const deviceIds = devices.map((d: any) => d.device_id);
// 获取今天所有数据,按时间降序,每个设备取第一条
const todayStart = getTodayStart();
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
device_time: { '%gte%': todayStart },
'%orderDesc%': 'device_time',
},
['device_id', 'device_data', 'device_time']
);
const records = dataResult.data || [];
const latestMap = new Map<string, any>();
// 因为已按时间降序,每个 device_id 第一条就是最新
records.forEach((r: any) => {
if (!latestMap.has(r.device_id)) {
const parsed = parseDeviceData(r.device_data);
latestMap.set(r.device_id, { data: parsed, device_time: r.device_time });
}
});
return latestMap;
}
/**
* 查询指定时间范围内 "空气质量监测" 设备的 device_data,按小时聚合取均值
* @returns Map<hourKey, Map<indicatorKey, avgValue>>
*/
async function getHourlyEnvData(startTime: string, endTime: string): Promise<Map<string, Map<string, number>>> {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: 'IEQ传感器' },
['device_id']
);
const deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
if (deviceIds.length === 0) return new Map();
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
device_time: { '%gte%': startTime, '%lte%': endTime },
'%orderAsc%': 'device_time',
},
['device_data', 'device_time']
);
const records = dataResult.data || [];
const hourlyMap = new Map<string, Map<string, { sum: number; count: number }>>();
records.forEach((r: any) => {
const t = new Date(r.device_time);
const hourKey = `${String(t.getHours()).padStart(2, '0')}:00`;
const parsed = parseDeviceData(r.device_data);
if (!hourlyMap.has(hourKey)) {
hourlyMap.set(hourKey, new Map());
}
const hourData = hourlyMap.get(hourKey)!;
ENV_INDICATOR_KEYS.forEach(key => {
const val = parsed[key];
if (val !== undefined && val !== null && typeof val === 'number') {
if (!hourData.has(key)) hourData.set(key, { sum: 0, count: 0 });
const acc = hourData.get(key)!;
acc.sum += val;
acc.count += 1;
}
});
});
// 转换为均值
const result = new Map<string, Map<string, number>>();
hourlyMap.forEach((indicatorMap, hourKey) => {
const avgMap = new Map<string, number>();
indicatorMap.forEach((acc, key) => {
avgMap.set(key, Math.round((acc.sum / acc.count) * 100) / 100);
});
result.set(hourKey, avgMap);
});
return result;
}
// ==================== 辅助:查询工具函数 ====================
/** 根据 regionKey 查询区域名称 */
async function getRegionName(regionKey: string): Promise<string> {
const regionResult = await selectDataListByParam(
TABLENAME.区域表,
{ region_key: regionKey },
['region_key', 'region_name']
);
const regionInfo = (regionResult.data || [])[0];
return regionInfo ? regionInfo.region_name : '';
}
/** 根据 regionKey 查询该区域下的设备列表 */
async function getDevicesByRegion(regionKey: string, columns: string[]): Promise<any[]> {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ region_key: regionKey },
columns
);
return deviceResult.data || [];
}
/** 查询指定设备列表在当天的最新一条 device_data(去重取第一条),返回 Map<device_id, parsed_data> */
async function getLatestDeviceDataMap(deviceIds: string[], startTime: string): Promise<Map<string, any>> {
if (deviceIds.length === 0) return new Map();
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
device_time: { '%gte%': startTime },
'%orderDesc%': 'device_time',
'%limit%': deviceIds.length,
},
['device_id', 'device_data']
);
const latestMap = new Map<string, any>();
(dataResult.data || []).forEach((r: any) => {
if (!latestMap.has(r.device_id)) {
latestMap.set(r.device_id, parseDeviceData(r.device_data));
}
});
return latestMap;
}
// ==================== 通用数据解析辅助 ====================
/** 安全解析 device_data 字段(可能是 JSON 字符串或已解析的对象) */
function parseDeviceData(raw: any): any {
if (!raw) return null;
return typeof raw === 'string' ? JSON.parse(raw) : raw;
}
// TODO: 电表数据接入后,确认 device_data JSON 中电量字段的 key,目前暂定 'current'
const ELECTRICITY_KEY = 'current';
/**
* 查询电表设备在指定时间范围内的电量总和
*/
async function getElectricitySumInRange(startTime: string, endTime: string): Promise<number> {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id']
);
const deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
if (deviceIds.length === 0) return 0;
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
device_time: { '%gte%': startTime, '%lte%': endTime },
},
['device_data']
);
let total = 0;
(dataResult.data || []).forEach((r: any) => {
const parsed = parseDeviceData(r.device_data);
const val = parsed[ELECTRICITY_KEY];
if (typeof val === 'number') total += val;
});
return Math.round(total * 100) / 100;
}
/**
* 查询电表设备在指定时间范围内的分时间段电量列表
* @param startTime 开始时间
* @param endTime 结束时间
* @param groupBy 'hour' | 'day' | 'month'
* @param keyFormatter 时间 key 格式化函数
*/
async function getElectricityTrendData(
startTime: string,
endTime: string,
groupBy: 'hour' | 'day' | 'month',
): Promise<{ key: string; value: number }[]> {
const deviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id']
);
const deviceIds = (deviceResult.data || []).map((d: any) => d.device_id);
if (deviceIds.length === 0) return [];
const dataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
device_time: { '%gte%': startTime, '%lte%': endTime },
'%orderAsc%': 'device_time',
},
['device_data', 'device_time']
);
const records = dataResult.data || [];
const bucketMap = new Map<string, number>();
records.forEach((r: any) => {
const t = new Date(r.device_time);
let bucketKey: string;
if (groupBy === 'hour') {
bucketKey = formatHourKey(t);
} else if (groupBy === 'day') {
bucketKey = formatDayKey(t);
} else {
bucketKey = formatMonthKey(t);
}
const parsed = parseDeviceData(r.device_data);
const val = parsed[ELECTRICITY_KEY];
if (typeof val === 'number') {
bucketMap.set(bucketKey, (bucketMap.get(bucketKey) || 0) + val);
}
});
return Array.from(bucketMap.entries())
.map(([key, value]) => ({ key, value: Math.round(value * 100) / 100 }))
.sort((a, b) => a.key.localeCompare(b.key));
}
// ==================== 核心接口 ====================
/** /**
* 运行分析 * 运行分析
* @returns 大屏所需的所有指标数据 * @returns 大屏所需的所有指标数据
*/ */
export async function getRunAnalysis() { export async function getRunAnalysis() {
// ========================================================================
// 一、能耗管理与趋势 // 一、能耗管理与趋势
// ========================================================================
// 1、能耗管理 // 1、能耗管理
/* ====== MOCK DATA(保留,勿删) ======
let energyManagement = { let energyManagement = {
"jrnh": "16212", "jrnh": "16212",
"zrnh": "13829", "zrnh": "13829",
...@@ -27,54 +397,96 @@ export async function getRunAnalysis() { ...@@ -27,54 +397,96 @@ export async function getRunAnalysis() {
"synh": "0", "synh": "0",
"sytb": "0" "sytb": "0"
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const todayStart = getTodayStart();
const todayEnd = getTodayEnd();
const yesterdayStart = getYesterdayStart();
const yesterdayEnd = getYesterdayEnd();
const monthStart = getMonthStart();
const lastMonthStart = getLastMonthStart();
const lastMonthEnd = getLastMonthEnd();
const jrnh = await getElectricitySumInRange(todayStart, todayEnd);
const zrnh = await getElectricitySumInRange(yesterdayStart, yesterdayEnd);
const bynh = await getElectricitySumInRange(monthStart, todayEnd);
const synh = await getElectricitySumInRange(lastMonthStart, lastMonthEnd);
const zrtb = calcCompareRate(jrnh, zrnh);
const sytb = calcCompareRate(bynh, synh);
let energyManagement = {
"jrnh": String(Math.round(jrnh)),
"zrnh": String(Math.round(zrnh)),
"zrtb": zrtb,
"bynh": String(Math.round(bynh)),
"synh": String(Math.round(synh)),
"sytb": sytb
};
// ========================================================================
// 2、能耗趋势 // 2、能耗趋势
/* ====== MOCK DATA(保留,勿删) ======
let electricityTrend = { let electricityTrend = {
"last24HoursMax": 11258, "last24HoursMax": 11258,
"last24Hours": [ "last24Hours": [
{ { "key": "2026-06-28 17时", "value": "10429" },
"key": "2026-06-28 17时", { "key": "2026-06-28 18时", "value": "10483" }
"value": "10429"
},
{
"key": "2026-06-28 18时",
"value": "10483"
}
], ],
"last7DaysMax": 18601, "last7DaysMax": 18601,
"last7Days": [ "last7Days": [
{ { "key": "2026-06-23", "value": "13636" },
"key": "2026-06-23", { "key": "2026-06-24", "value": "15402" }
"value": "13636"
},
{
"key": "2026-06-24",
"value": "15402"
}
], ],
"last30DaysMax": 18601, "last30DaysMax": 18601,
"last30Days": [ "last30Days": [
{ { "key": "2026-05-31", "value": "0" },
"key": "2026-05-31", { "key": "2026-06-01", "value": "0" }
"value": "0"
},
{
"key": "2026-06-01",
"value": "0"
}
], ],
"lastYearMax": 21128, "lastYearMax": 21128,
"lastYear": [ "lastYear": [
{ { "key": "2025-07", "value": "0" },
"key": "2025-07", { "key": "2025-08", "value": "0" }
"value": "0"
},
{
"key": "2025-08",
"value": "0"
}
] ]
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const now24h = new Date();
const last24hStart = getHoursAgo(24);
const last7dStart = getDaysAgoStart(7);
const last30dStart = getDaysAgoStart(30);
const lastYearStart = getMonthsAgoStart(12);
const last24HoursData = await getElectricityTrendData(last24hStart, formatTime(now24h), 'hour');
const last7DaysData = await getElectricityTrendData(last7dStart, getTodayEnd(), 'day');
const last30DaysData = await getElectricityTrendData(last30dStart, getTodayEnd(), 'day');
const lastYearData = await getElectricityTrendData(lastYearStart, getTodayEnd(), 'month');
// 转为字符串 value(与 mock 一致)
const stringifyValues = (arr: { key: string; value: number }[]) =>
arr.map(item => ({ key: item.key, value: String(item.value) }));
const last24hValues = last24HoursData.map(i => i.value);
const last7dValues = last7DaysData.map(i => i.value);
const last30dValues = last30DaysData.map(i => i.value);
const lastYearValues = lastYearData.map(i => i.value);
let electricityTrend = {
"last24HoursMax": last24hValues.length > 0 ? Math.max(...last24hValues) : 0,
"last24Hours": stringifyValues(last24HoursData),
"last7DaysMax": last7dValues.length > 0 ? Math.max(...last7dValues) : 0,
"last7Days": stringifyValues(last7DaysData),
"last30DaysMax": last30dValues.length > 0 ? Math.max(...last30dValues) : 0,
"last30Days": stringifyValues(last30DaysData),
"lastYearMax": lastYearValues.length > 0 ? Math.max(...lastYearValues) : 0,
"lastYear": stringifyValues(lastYearData),
};
// ========================================================================
// 二、设备耗电量监控 // 二、设备耗电量监控
/* ====== MOCK DATA(保留,勿删) ======
let deviceTypeMap = new Map(); let deviceTypeMap = new Map();
let deviceElectricityMap = new Map(); let deviceElectricityMap = new Map();
let deviceElectricityDatas = deviceTypeList.map((deviceType) => { let deviceElectricityDatas = deviceTypeList.map((deviceType) => {
...@@ -88,15 +500,92 @@ export async function getRunAnalysis() { ...@@ -88,15 +500,92 @@ export async function getRunAnalysis() {
titleList: ["设备","数量","耗电情况"], titleList: ["设备","数量","耗电情况"],
dataList: deviceElectricityDatas dataList: deviceElectricityDatas
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
// 查询所有设备,按 device_type 分组统计数量
const allDevicesResult = await selectDataListByParam(
TABLENAME.设备表,
{},
['device_id', 'device_type']
);
const allDevices = allDevicesResult.data || [];
// 按类型统计设备数量
const deviceTypeCountMap = new Map<string, number>();
allDevices.forEach((d: any) => {
const dt = d.device_type;
deviceTypeCountMap.set(dt, (deviceTypeCountMap.get(dt) || 0) + 1);
});
// 查询当日各电表设备的总电量
const meterDevicesResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: '电表' },
['device_id']
);
const meterDeviceIds = (meterDevicesResult.data || []).map((d: any) => d.device_id);
// 按设备类型统计耗电:仅电表类型有数据
let deviceElectricityMap = new Map<string, number>();
if (meterDeviceIds.length > 0) {
const meterDataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': meterDeviceIds },
device_time: { '%gte%': todayStart },
},
['device_data']
);
let meterTotal = 0;
(meterDataResult.data || []).forEach((r: any) => {
const parsed = parseDeviceData(r.device_data);
const val = parsed[ELECTRICITY_KEY];
if (typeof val === 'number') meterTotal += val;
});
deviceElectricityMap.set('电表', Math.round(meterTotal * 100) / 100);
}
let deviceElectricityDatas = deviceTypeList.map((deviceType) => {
return {
"deviceType": deviceType,
"deviceCount": deviceTypeCountMap.get(deviceType) || 0,
"electricity": deviceElectricityMap.get(deviceType) || 0
}
});
let deviceElectricity = {
titleList: ["设备", "数量", "耗电情况"],
dataList: deviceElectricityDatas
};
// ========================================================================
// 三、设备监测情况与趋势 // 三、设备监测情况与趋势
// 1、设备监测情况 // 1、设备监测情况
/* ====== MOCK DATA(保留,勿删) ======
let deviceMonitor = { let deviceMonitor = {
total: 100, total: 100,
online: 80, online: 80,
offline: 15, offline: 15,
fault: 5, fault: 5,
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const allDevFull = allDevices;
const total = allDevFull.length;
const onlineTotal = allDevFull.filter((d: any) => d.device_state === 1).length;
const offlineTotal = allDevFull.filter((d: any) => d.device_state === 0).length;
const faultTotal = allDevFull.filter((d: any) => d.device_state === 2).length;
let deviceMonitor = {
total: total,
online: onlineTotal,
offline: offlineTotal,
fault: faultTotal,
};
// 2、设备监测趋势 // 2、设备监测趋势
/* ====== MOCK DATA(保留,勿删) ======
let deviceMonitorTrend = [{ let deviceMonitorTrend = [{
key: "2026-06-28 07时", key: "2026-06-28 07时",
value: 80 value: 80
...@@ -104,22 +593,103 @@ export async function getRunAnalysis() { ...@@ -104,22 +593,103 @@ export async function getRunAnalysis() {
key: "2026-06-28 17时", key: "2026-06-28 17时",
value: 75 value: 75
}]; }];
====== MOCK DATA END ====== */
// ---- 真实查询 ----
// 趋势基于 device_data 表:每小时有数据上报的设备数代表在线数
const trend24hStart = getHoursAgo(24);
const trendEnd = formatTime(new Date());
const envDevicesResult = await selectDataListByParam(
TABLENAME.设备表,
{},
['device_id']
);
const allDeviceIds = (envDevicesResult.data || []).map((d: any) => d.device_id);
let trendHourly: { key: string; value: number }[] = [];
if (allDeviceIds.length > 0) {
const trendDataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': allDeviceIds },
device_time: { '%gte%': trend24hStart, '%lte%': trendEnd },
'%orderAsc%': 'device_time',
},
['device_id', 'device_time']
);
const hourlyDevices = new Map<string, Set<string>>();
(trendDataResult.data || []).forEach((r: any) => {
const t = new Date(r.device_time);
const key = formatHourKey(t);
if (!hourlyDevices.has(key)) hourlyDevices.set(key, new Set());
hourlyDevices.get(key)!.add(r.device_id);
});
trendHourly = Array.from(hourlyDevices.entries())
.map(([key, devices]) => ({ key, value: devices.size }))
.sort((a, b) => a.key.localeCompare(b.key));
}
let deviceMonitorTrend = trendHourly;
// ========================================================================
// 四、预警工单处理 // 四、预警工单处理
// 1、预警处理状态 // 1、预警处理状态
/* ====== MOCK DATA(保留,勿删) ======
let alertStatus = { let alertStatus = {
resolved: 1, resolved: 1,
responded: 1, responded: 1,
unresolved: 1 unresolved: 1
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const resolvedCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '已处理' });
const respondedCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '处理中' });
const unresolvedCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '未处理' });
let alertStatus = {
resolved: typeof resolvedCount.data === 'number' ? resolvedCount.data : 0,
responded: typeof respondedCount.data === 'number' ? respondedCount.data : 0,
unresolved: typeof unresolvedCount.data === 'number' ? unresolvedCount.data : 0,
};
// 2、预警工单列表 // 2、预警工单列表
/* ====== MOCK DATA(保留,勿删) ======
let alertWorkOrders = [ let alertWorkOrders = [
{ riskContent: "空调压缩机异常", alertTime: "26/02/01", status: "已解决" }, { riskContent: "空调压缩机异常", alertTime: "26/02/01", status: "已解决" },
{ riskContent: "温度传感器离线", alertTime: "26/02/01", status: "已响应" }, { riskContent: "温度传感器离线", alertTime: "26/02/01", status: "已响应" },
{ riskContent: "湿度超标", alertTime: "26/02/02", status: "未处理" } { riskContent: "湿度超标", alertTime: "26/02/02", status: "未处理" }
]; ];
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const faultListResult = await selectDataListByParam(
TABLENAME.设备故障表,
{
'%or%': [
{ fault_status: '未处理' },
{ fault_status: '处理中' }
],
'%orderDesc%': 'fault_time',
'%limit%': 20,
},
['fault_type', 'fault_time', 'fault_status', 'device_id']
);
let alertWorkOrders = (faultListResult.data || []).map((f: any) => ({
riskContent: f.fault_type || '未知故障',
alertTime: f.fault_time ? formatTime(f.fault_time).split(' ')[0].replace(/-/g, '/').slice(2) : '',
status: f.fault_status,
}));
// ========================================================================
// 五、空气质量环境监控 // 五、空气质量环境监控
// 1、空气质量指数和趋势 // 1、空气质量指数和趋势
/* ====== MOCK DATA(保留,勿删) ======
let environmentalQuality = { let environmentalQuality = {
qualityIndex: { qualityIndex: {
current: "优", current: "优",
...@@ -135,103 +705,314 @@ export async function getRunAnalysis() { ...@@ -135,103 +705,314 @@ export async function getRunAnalysis() {
value: 79 value: 79
}] }]
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const latestEnvData = await getLatestEnvDeviceData();
let avgCo2 = 0;
let avgPm25 = 0;
let envDataCount = 0;
latestEnvData.forEach((item) => {
const d = item.data;
if (d) {
if (typeof d.co2 === 'number') { avgCo2 += d.co2; envDataCount++; }
if (typeof d.pm2_5 === 'number') avgPm25 += d['pm2_5'];
}
});
if (envDataCount > 0) avgCo2 = Math.round(avgCo2 / envDataCount);
if (latestEnvData.size > 0) avgPm25 = Math.round(avgPm25 / latestEnvData.size);
const overallQuality = getOverallQuality(avgCo2, avgPm25);
// 空气质量指数(基于 CO2 的粗估:0~2000 映射到 0~100 的倒数)
const qualityIndex = Math.max(0, Math.min(100, Math.round((1 - avgCo2 / 2000) * 100)));
// 按小时聚合的历史数据(当日)
const hourlyEnvData = await getHourlyEnvData(todayStart, todayEnd);
const iaqQualityTrend: { key: string; value: number }[] = [];
hourlyEnvData.forEach((indicatorMap, hourKey) => {
const co2 = indicatorMap.get('co2') || 0;
const q = Math.max(0, Math.min(100, Math.round((1 - co2 / 2000) * 100)));
iaqQualityTrend.push({
key: formatHourKey(new Date(`${todayStart.split(' ')[0]} ${hourKey}`)),
value: q,
});
});
iaqQualityTrend.sort((a, b) => a.key.localeCompare(b.key));
let environmentalQuality = {
qualityIndex: {
current: overallQuality === '优秀' ? '优' : (overallQuality === '良好' ? '良' : overallQuality),
max: 100,
min: 0,
index: qualityIndex,
},
iaqQualityTrend: iaqQualityTrend,
};
// 2、环境数据监测 // 2、环境数据监测
/* ====== MOCK DATA(保留,勿删) ======
let environmentalTrend = { let environmentalTrend = {
"todayEnvironmental": "优秀", "todayEnvironmental": "优秀",
"currentTemperature": "24.5℃", "currentTemperature": "24.5℃",
"currentHumidity": "55%", "currentHumidity": "55%",
"currentPm25": "0μg/m³", "currentPm25": "0μg/m³",
"currentCo2": "800ppm", "currentCo2": "800ppm",
"temperatureTrend": [ "temperatureTrend": [{ "time": "0:00", "value": "0" }],
{ "humidityTrend": [{ "time": "0:00", "value": "0" }],
"time": "0:00", "pm25Trend": [{ "time": "0:00", "value": "0" }],
"value": "0" "co2Trend": [{ "time": "0:00", "value": "0" }],
}
],
"humidityTrend": [
{
"time": "0:00",
"value": "0"
}
],
"pm25Trend": [
{
"time": "0:00",
"value": "0"
}
],
"co2Trend": [
{
"time": "0时",
"value": "0"
}
],
"qualityIndex": 500, "qualityIndex": 500,
"currentHcho": 0, "currentHcho": 0,
"currentLightLevel": 0, "currentLightLevel": 0,
"currentPm10": 0, "currentPm10": 0,
"currentPressure": 0, "currentPressure": 0,
"currentTvoc": 2, "currentTvoc": 2,
"hchoTrend": [ "hchoTrend": [{ "time": "0:00", "value": "0" }],
{ "lightLevelTrend": [{ "time": "0:00", "value": "0" }],
"time": "0:00", "pm10Trend": [{ "time": "0:00", "value": "0" }],
"value": "0" "pressureTrend": [{ "time": "0:00", "value": "0" }],
} "tvocTrend": [{ "time": "0:00", "value": "0" }]
], };
"lightLevelTrend": [ ====== MOCK DATA END ====== */
{
"time": "0:00", // ---- 真实查询 ----
"value": "0" // 计算各指标当前均值
} const allIndicatorValues = new Map<string, { sum: number; count: number }>();
], latestEnvData.forEach((item) => {
"pm10Trend": [ const d = item.data;
{ if (!d) return;
"time": "0:00", ENV_INDICATOR_KEYS.forEach(key => {
"value": "0" const val = d[key];
} if (typeof val === 'number') {
], if (!allIndicatorValues.has(key)) allIndicatorValues.set(key, { sum: 0, count: 0 });
"pressureTrend": [ const acc = allIndicatorValues.get(key)!;
{ acc.sum += val;
"time": "0:00", acc.count += 1;
"value": "0"
}
],
"tvocTrend": [
{
"time": "0:00",
"value": "0"
} }
] });
});
const getAvg = (key: string): number => {
const acc = allIndicatorValues.get(key);
if (!acc || acc.count === 0) return 0;
return Math.round((acc.sum / acc.count) * 100) / 100;
};
const currentTemperature = getAvg('temperature');
const currentHumidity = getAvg('humidity');
const currentPm25 = getAvg('pm25');
const currentCo2 = getAvg('co2');
const currentHcho = getAvg('hcho');
const currentLightLevel = getAvg('lightLevel');
const currentPm10 = getAvg('pm10');
const currentPressure = getAvg('pressure');
const currentTvoc = getAvg('tvoc');
// 构建各指标趋势(按小时,time 格式 "0:00")
const buildTrend = (indicatorKey: string): { time: string; value: string }[] => {
const trend: { time: string; value: string }[] = [];
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0') + ':00');
hours.forEach(h => {
const hourData = hourlyEnvData.get(h);
const val = hourData ? (hourData.get(indicatorKey) || 0) : 0;
trend.push({ time: h, value: String(val) });
});
return trend;
};
let environmentalTrend = {
"todayEnvironmental": overallQuality,
"currentTemperature": currentTemperature + '℃',
"currentHumidity": currentHumidity + '%',
"currentPm25": currentPm25 + 'μg/m³',
"currentCo2": currentCo2 + 'ppm',
"temperatureTrend": buildTrend('temperature'),
"humidityTrend": buildTrend('humidity'),
"pm25Trend": buildTrend('pm25'),
"co2Trend": buildTrend('co2'),
"qualityIndex": qualityIndex,
"currentHcho": currentHcho,
"currentLightLevel": currentLightLevel,
"currentPm10": currentPm10,
"currentPressure": currentPressure,
"currentTvoc": currentTvoc,
"hchoTrend": buildTrend('hcho'),
"lightLevelTrend": buildTrend('lightLevel'),
"pm10Trend": buildTrend('pm10'),
"pressureTrend": buildTrend('pressure'),
"tvocTrend": buildTrend('tvoc'),
}; };
// ========================================================================
// 六、机房环境功率监控 // 六、机房环境功率监控
// 1、机房环境监控 // 1、机房环境监控
let compRoomEnvironmental = [{ /* ====== MOCK DATA(保留,勿删) ======
index: 1, let compRoomEnvironmental: { index: number; key: string; value: string }[] = [{
key: "温度", index: 1, key: "温度", value: "24.5℃",
value: "24.5℃",
},{ },{
index: 2, index: 2, key: "湿度", value: "55%",
key: "湿度",
value: "55%",
},{ },{
index: 3, index: 3, key: "配置", value: "烟感",
key: "配置",
value: "烟感",
},{ },{
index: 4, index: 4, key: "常开", value: "水浸",
key: "常开",
value: "水浸",
}]; }];
====== MOCK DATA END ====== */
// ---- 真实查询 ----
// 查询 region_type = '机房' 的区域
const serverRoomResult = await selectDataListByParam(
TABLENAME.区域表,
{ region_type: '机房' },
['id', 'region_key', 'region_name']
);
const serverRegions = serverRoomResult.data || [];
let compRoomEnvironmental: { index: number; key: string; value: string }[];
if (serverRegions.length > 0) {
const serverRegionKeys = serverRegions.map((r: any) => r.region_key);
// 查询机房区域下的 IEQ传感器 设备(环境监测设备)
const serverDeviceResult = await selectDataListByParam(
TABLENAME.设备表,
{
region_key: { '%in%': serverRegionKeys },
device_type: 'IEQ传感器',
},
['device_id']
);
const serverDeviceIds = (serverDeviceResult.data || []).map((d: any) => d.device_id);
if (serverDeviceIds.length > 0) {
const srDataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': serverDeviceIds },
device_time: { '%gte%': todayStart },
'%orderDesc%': 'device_time',
'%limit%': serverDeviceIds.length,
},
['device_id', 'device_data']
);
const srRecords = srDataResult.data || [];
const srLatest: Map<string, any> = new Map();
srRecords.forEach((r: any) => {
if (!srLatest.has(r.device_id)) {
srLatest.set(r.device_id, parseDeviceData(r.device_data));
}
});
// 汇总四个指标均值:温度、湿度、二氧化碳、PM2.5
let srTempSum = 0, srHumSum = 0, srCo2Sum = 0, srPm25Sum = 0, srCount = 0;
srLatest.forEach((d) => {
if (d) {
srTempSum += (d.temperature || 0);
srHumSum += (d.humidity || 0);
srCo2Sum += (d.co2 || 0);
srPm25Sum += (d.pm25 || 0);
srCount++;
}
});
const srAvgTemp = srCount > 0 ? (srTempSum / srCount).toFixed(1) : '0';
const srAvgHum = srCount > 0 ? (srHumSum / srCount).toFixed(1) : '0';
const srAvgCo2 = srCount > 0 ? Math.round(srCo2Sum / srCount).toString() : '0';
const srAvgPm25 = srCount > 0 ? Math.round(srPm25Sum / srCount).toString() : '0';
compRoomEnvironmental = [
{ index: 1, key: '温度', value: srAvgTemp + '℃' },
{ index: 2, key: '湿度', value: srAvgHum + '%' },
{ index: 3, key: '二氧化碳', value: srAvgCo2 + 'ppm' },
{ index: 4, key: 'PM2.5', value: srAvgPm25 + 'μg/m³' },
];
} else {
compRoomEnvironmental = [
{ index: 1, key: '温度', value: 'N/A' },
{ index: 2, key: '湿度', value: 'N/A' },
{ index: 3, key: '二氧化碳', value: 'N/A' },
{ index: 4, key: 'PM2.5', value: 'N/A' },
];
}
} else {
compRoomEnvironmental = [
{ index: 1, key: '温度', value: 'N/A' },
{ index: 2, key: '湿度', value: 'N/A' },
{ index: 3, key: '二氧化碳', value: 'N/A' },
{ index: 4, key: 'PM2.5', value: 'N/A' },
];
}
// 2、机房功率监控 // 2、机房功率监控
let compRoomPower = [{ // TODO: 待机房环境监控逻辑确认后,同步恢复真实查询
key: "2026-06-28 07时", let compRoomPower: { key: string; value: number }[] = [{
value: 200 key: "2026-06-28 07时", value: 200
},{ },{
key: "2026-06-28 17时", key: "2026-06-28 17时", value: 400
value: 400
}]; }];
/* ====== 真实查询逻辑(暂存,待确认后启用) ======
if (serverRegions.length > 0) {
const serverRegionKeys = serverRegions.map((r: any) => r.region_key);
const srMeterResult = await selectDataListByParam(
TABLENAME.设备表,
{
region_key: { '%in%': serverRegionKeys },
device_type: '电表',
},
['device_id']
);
const srMeterIds = (srMeterResult.data || []).map((d: any) => d.device_id);
if (srMeterIds.length > 0) {
const srPowerResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': srMeterIds },
device_time: { '%gte%': getHoursAgo(24) },
'%orderAsc%': 'device_time',
},
['device_data', 'device_time']
);
const srPowerHourly = new Map<string, number>();
(srPowerResult.data || []).forEach((r: any) => {
const t = new Date(r.device_time);
const key = formatHourKey(t);
const parsed = parseDeviceData(r.device_data);
const val = parsed[ELECTRICITY_KEY];
if (typeof val === 'number') {
srPowerHourly.set(key, (srPowerHourly.get(key) || 0) + val);
}
});
compRoomPower = Array.from(srPowerHourly.entries())
.map(([key, value]) => ({ key, value: Math.round(value) }))
.sort((a, b) => a.key.localeCompare(b.key));
}
}
====== 真实查询逻辑 END ====== */
// ========================================================================
// 七、设备汇总 // 七、设备汇总
/* ====== MOCK DATA(保留,勿删) ======
let summaryData = {"runningDevices":83,"offlineDevices":0,"normalRate":"100.00%","faultDevices":0} let summaryData = {"runningDevices":83,"offlineDevices":0,"normalRate":"100.00%","faultDevices":0}
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const runningDevices = allDevFull.filter((d: any) => d.device_state === 1).length;
const faultDevices = allDevFull.filter((d: any) => d.device_state === 2).length;
const offlineDevicesSummary = allDevFull.filter((d: any) => d.device_state === 0).length;
const normalRate = total > 0 ? ((runningDevices / total) * 100).toFixed(2) + '%' : '0.00%';
let summaryData = {
"runningDevices": runningDevices,
"offlineDevices": offlineDevicesSummary,
"normalRate": normalRate,
"faultDevices": faultDevices
};
// 返回所有指标监控信息 // 返回所有指标监控信息
return { return {
nhgl: energyManagement, nhgl: energyManagement,
...@@ -251,9 +1032,11 @@ export async function getRunAnalysis() { ...@@ -251,9 +1032,11 @@ export async function getRunAnalysis() {
/** /**
* 运行分析弹窗 * 运行分析弹窗
* @param regionKey 区域标识
* @returns 区域设备的指标数据 * @returns 区域设备的指标数据
*/ */
export async function getAnalysisPopup() { export async function getAnalysisPopup(regionKey: string) {
/* ====== MOCK DATA(保留,勿删) ======
return { return {
"regionName": "汇报厅", "regionName": "汇报厅",
"sbztjc": { "sbztjc": {
...@@ -269,21 +1052,131 @@ export async function getAnalysisPopup() { ...@@ -269,21 +1052,131 @@ export async function getAnalysisPopup() {
"offline": 15, "offline": 15,
"fault": 5 "fault": 5
} }
], ],
"nhjc": [ "nhjc": [
{ {
"key": "2026-06-28 05:00", "key": "2026-06-28 05:00",
"value": "104" "value": "104"
}
]
};
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const todayStart = getTodayStart();
const todayEnd = getTodayEnd();
const trendStart = getHoursAgo(24);
// 查询区域信息和设备列表
const regionName = await getRegionName(regionKey);
const devices = await getDevicesByRegion(regionKey, ['device_id', 'device_type', 'device_state']);
const deviceIds = devices.map((d: any) => d.device_id);
// 设备状态监测(总数/在线/离线/故障)
const total = devices.length;
const online = devices.filter((d: any) => d.device_state === 1).length;
const offline = devices.filter((d: any) => d.device_state === 0).length;
const fault = devices.filter((d: any) => d.device_state === 2).length;
let sbztjc = {
"total": total,
"online": online,
"offline": offline,
"fault": fault
};
// 设备监测趋势(24小时内每小时在线/离线/故障数量)
let sbztjcqs: { key: string; online: number; offline: number; fault: number }[] = [];
if (deviceIds.length > 0) {
const trendDataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': deviceIds },
device_time: { '%gte%': trendStart, '%lte%': todayEnd },
'%orderAsc%': 'device_time',
},
['device_id', 'device_time']
);
const records = trendDataResult.data || [];
// 按小时聚合:每个小时有哪些设备上报了数据
const hourlyDeviceSet = new Map<string, Set<string>>();
records.forEach((r: any) => {
const t = new Date(r.device_time);
const h = String(t.getHours()).padStart(2, '0');
const hourKey = todayStart.split(' ')[0] + ' ' + h + ':00';
if (!hourlyDeviceSet.has(hourKey)) hourlyDeviceSet.set(hourKey, new Set());
hourlyDeviceSet.get(hourKey)!.add(r.device_id);
});
// 预计算各设备的状态(静态,不随时间变化)
const deviceStateMap = new Map<string, number>();
devices.forEach((d: any) => deviceStateMap.set(d.device_id, d.device_state));
hourlyDeviceSet.forEach((reportedDeviceSet, hourKey) => {
let hOnline = 0, hOffline = 0, hFault = 0;
devices.forEach((d: any) => {
const reported = reportedDeviceSet.has(d.device_id);
if (reported && d.device_state === 1) hOnline++;
else if (d.device_state === 0) hOffline++;
else if (d.device_state === 2) hFault++;
else if (!reported) hOffline++; // 未上报也算离线
});
sbztjcqs.push({
key: hourKey,
online: hOnline,
offline: hOffline,
fault: hFault,
});
});
}
sbztjcqs.sort((a, b) => a.key.localeCompare(b.key));
// 能耗监测(该区域电表24小时趋势)
let nhjc: { key: string; value: string }[] = [];
if (deviceIds.length > 0) {
const meterIds = devices.filter((d: any) => d.device_type === '电表').map((d: any) => d.device_id);
if (meterIds.length > 0) {
const meterDataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': meterIds },
device_time: { '%gte%': trendStart, '%lte%': todayEnd },
'%orderAsc%': 'device_time',
},
['device_data', 'device_time']
);
const hourlySum = new Map<string, number>();
(meterDataResult.data || []).forEach((r: any) => {
const t = new Date(r.device_time);
const h = String(t.getHours()).padStart(2, '0');
const hourKey = todayStart.split(' ')[0] + ' ' + h + ':00';
const parsed = parseDeviceData(r.device_data);
const val = parsed[ELECTRICITY_KEY];
if (typeof val === 'number') {
hourlySum.set(hourKey, (hourlySum.get(hourKey) || 0) + val);
}
});
nhjc = Array.from(hourlySum.entries())
.map(([key, value]) => ({ key, value: String(Math.round(value)) }))
.sort((a, b) => a.key.localeCompare(b.key));
}
} }
]
return {
"regionName": regionName,
"sbztjc": sbztjc,
"sbztjcqs": sbztjcqs,
"nhjc": nhjc
}; };
} }
/** /**
* 环境态势感知 * 环境态势感知(所有区域汇总)
* @returns 今日环境监测数据 * @returns 今日环境监测数据
*/ */
export async function getRunEnvironmental() { export async function getRunEnvironmental() {
/* ====== MOCK DATA(保留,勿删) ======
// 一、环境数据总览 // 一、环境数据总览
let hjsjzl = { let hjsjzl = {
"temperature": { "temperature": {
...@@ -337,52 +1230,10 @@ export async function getRunEnvironmental() { ...@@ -337,52 +1230,10 @@ export async function getRunEnvironmental() {
// 二、环境参数趋势对比分析 // 二、环境参数趋势对比分析
let hjqsdbfx = { let hjqsdbfx = {
"wd": { "wd": {
"jr": [{ "jr": [{"key": "06:00","value": 16},{"key": "12:00","value": 26},{"key": "18:00","value": 18}],
"key": "06:00", "zr": [{"key": "06:00","value": 18},{"key": "12:00","value": 24},{"key": "18:00","value": 20}],
"value": 16 "bz": [{"key": "2026-07-06","value": 25},{"key": "2026-07-07","value": 22},{"key": "2026-07-08","value": 24},{"key": "2026-07-09","value": 23}],
}, { "by": [{"key": "2026-07-06","value": 25},{"key": "2026-07-07","value": 22},{"key": "2026-07-08","value": 24},{"key": "2026-07-09","value": 23}],
"key": "12:00",
"value": 26
}, {
"key": "18:00",
"value": 18
}],
"zr": [{
"key": "06:00",
"value": 18
}, {
"key": "12:00",
"value": 24
}, {
"key": "18:00",
"value": 20
}],
"bz": [{
"key": "2026-07-06",
"value": 25
}, {
"key": "2026-07-07",
"value": 22
}, {
"key": "2026-07-08",
"value": 24
}, {
"key": "2026-07-09",
"value": 23
}],
"by": [{
"key": "2026-07-06",
"value": 25
}, {
"key": "2026-07-07",
"value": 22
}, {
"key": "2026-07-08",
"value": 24
}, {
"key": "2026-07-09",
"value": 23
}],
}, },
"sd": {}, "sd": {},
"pm25": {}, "pm25": {},
...@@ -391,52 +1242,10 @@ export async function getRunEnvironmental() { ...@@ -391,52 +1242,10 @@ export async function getRunEnvironmental() {
"hcho": {}, "hcho": {},
"hyl": {}, "hyl": {},
"gz": { "gz": {
"jr": [{ "jr": [{"key": "06:00","value": 0},{"key": "12:00","value": 1},{"key": "18:00","value": 0}],
"key": "06:00", "zr": [{"key": "06:00","value": 0},{"key": "12:00","value": 1},{"key": "18:00","value": 1}],
"value": 0 "bz": [{"key": "2026-07-06","value": 0.2},{"key": "2026-07-07","value": 0.8},{"key": "2026-07-08","value": 0.5},{"key": "2026-07-09","value": 0.6}],
}, { "by": [{"key": "2026-07-06","value": 0.2},{"key": "2026-07-07","value": 0.8},{"key": "2026-07-08","value": 0.5},{"key": "2026-07-09","value": 0.6}],
"key": "12:00",
"value": 1
}, {
"key": "18:00",
"value": 0
}],
"zr": [{
"key": "06:00",
"value": 0
}, {
"key": "12:00",
"value": 1
}, {
"key": "18:00",
"value": 1
}],
"bz": [{
"key": "2026-07-06",
"value": 0.2
}, {
"key": "2026-07-07",
"value": 0.8
}, {
"key": "2026-07-08",
"value": 0.5
}, {
"key": "2026-07-09",
"value": 0.6
}],
"by": [{
"key": "2026-07-06",
"value": 0.2
}, {
"key": "2026-07-07",
"value": 0.8
}, {
"key": "2026-07-08",
"value": 0.5
}, {
"key": "2026-07-09",
"value": 0.6
}],
}, },
"zy": {}, "zy": {},
}; };
...@@ -444,53 +1253,312 @@ export async function getRunEnvironmental() { ...@@ -444,53 +1253,312 @@ export async function getRunEnvironmental() {
let cjsbjcmx = { let cjsbjcmx = {
"titleList": ["场景点位","状态","更新时间","温度","湿度","pm2.5","co2","tvoc","甲醛","含氧量","光照","噪音"], "titleList": ["场景点位","状态","更新时间","温度","湿度","pm2.5","co2","tvoc","甲醛","含氧量","光照","噪音"],
"dataList": [{ "dataList": [{
"regionName": "卫生间", "regionName": "卫生间","deviceStatus": "部分在线","updateTime": "2026-07-09 06:00:00","wd": 17,"sd": 0,"pm25": 1000,"co2": 800,"tvoc": 8,"hcho": 0.8,"hyl": 0,"gz": 1,"zy": 0
"deviceStatus": "部分在线",
"updateTime": "2026-07-09 06:00:00",
"wd": 17,
"sd": 0,
"pm25": 1000,
"co2": 800,
"tvoc": 8,
"hcho": 0.8,
"hyl": 0,
"gz": 1,
"zy": 0
},{ },{
"regionKey": "2002", "regionKey": "2002","regionName": "仓库","deviceStatus": "离线","updateTime": "2026-07-09 12:00:00","wd": 16,"sd": 0,"pm25": 1000,"co2": 800,"tvoc": 8,"hcho": 0.8,"hyl": 0,"gz": 0,"zy": 0
"regionName": "仓库",
"deviceStatus": "离线",
"updateTime": "2026-07-09 12:00:00",
"wd": 16,
"sd": 0,
"pm25": 1000,
"co2": 800,
"tvoc": 8,
"hcho": 0.8,
"hyl": 0,
"gz": 0,
"zy": 0
},{ },{
"regionName": "前台", "regionName": "前台","deviceStatus": "在线","updateTime": "2026-07-09 18:00:00","wd": 18,"sd": 0,"pm25": 1000,"co2": 800,"tvoc": 8,"hcho": 0.8,"hyl": 0,"gz": 1,"zy": 0
"deviceStatus": "在线",
"updateTime": "2026-07-09 18:00:00",
"wd": 18,
"sd": 0,
"pm25": 1000,
"co2": 800,
"tvoc": 8,
"hcho": 0.8,
"hyl": 0,
"gz": 1,
"zy": 0
}], }],
}; };
let ret = { let ret = { hjsjzl: hjsjzl, hjqsdbfx: hjqsdbfx, cjsbjcmx: cjsbjcmx };
return ret;
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const todayStart = getTodayStart();
const todayEnd = getTodayEnd();
const yesterdayStart = getYesterdayStart();
const yesterdayEnd = getYesterdayEnd();
// 查询所有区域
const regionResult = await selectDataListByParam(
TABLENAME.区域表,
{},
['region_key', 'region_name']
);
const allRegions = regionResult.data || [];
// 查询所有 IEQ传感器(环境监测设备)
const envDeviceResult = await selectDataListByParam(
TABLENAME.设备表,
{ device_type: 'IEQ传感器' },
['device_id', 'region_key', 'device_name', 'device_state']
);
const envDevices = envDeviceResult.data || [];
const envDeviceIds = envDevices.map((d: any) => d.device_id);
// ==================== 一、环境数据总览 ====================
// 获取今日最新环境数据(所有设备最新一条汇总)
const todayEnvResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': envDeviceIds },
device_time: { '%gte%': todayStart, '%lte%': todayEnd },
'%orderDesc%': 'device_time',
},
['device_id', 'device_data']
);
const todayLatestMap = new Map<string, any>();
(todayEnvResult.data || []).forEach((r: any) => {
if (!todayLatestMap.has(r.device_id)) {
todayLatestMap.set(r.device_id, parseDeviceData(r.device_data));
}
});
// 获取昨日最新环境数据(用于计算环比 zrtb)
const yesterdayEnvResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': envDeviceIds },
device_time: { '%gte%': yesterdayStart, '%lte%': yesterdayEnd },
'%orderDesc%': 'device_time',
},
['device_id', 'device_data']
);
const yesterdayLatestMap = new Map<string, any>();
(yesterdayEnvResult.data || []).forEach((r: any) => {
if (!yesterdayLatestMap.has(r.device_id)) {
yesterdayLatestMap.set(r.device_id, parseDeviceData(r.device_data));
}
});
// 计算各指标今天与昨天的均值
const indicatorConfigs: { key: string; mapKey: string }[] = [
{ key: 'temperature', mapKey: 'temperature' },
{ key: 'humidity', mapKey: 'humidity' },
{ key: 'pm25', mapKey: 'pm25' },
{ key: 'co2', mapKey: 'co2' },
{ key: 'hcho', mapKey: 'hcho' },
{ key: 'lightLevel', mapKey: 'lightLevel' },
{ key: 'tvoc', mapKey: 'tvoc' },
];
function calcAvgFromMap(dataMap: Map<string, any>, key: string): { sum: number; count: number; max: number; min: number } {
let sum = 0, count = 0, max = -Infinity, min = Infinity;
dataMap.forEach((data) => {
const val = data[key];
if (typeof val === 'number') {
sum += val;
count++;
if (val > max) max = val;
if (val < min) min = val;
}
});
return { sum, count, max: count > 0 ? max : 0, min: count > 0 ? min : 0 };
}
let hjsjzl: any = {};
for (const cfg of indicatorConfigs) {
const todayStats = calcAvgFromMap(todayLatestMap, cfg.key);
const yesterdayStats = calcAvgFromMap(yesterdayLatestMap, cfg.key);
const current = todayStats.count > 0 ? Math.round((todayStats.sum / todayStats.count) * 100) / 100 : 0;
const yesterdayAvg = yesterdayStats.count > 0 ? yesterdayStats.sum / yesterdayStats.count : 0;
const zrtb = yesterdayAvg > 0
? (current > yesterdayAvg ? '+' : '') + calcCompareRate(current, yesterdayAvg) + '%'
: '0%';
const quality = getQualityLevel(cfg.mapKey, current) || 'N/A';
const item: any = {
"current": current,
"environmental": quality,
"zrtb": zrtb,
"max": todayStats.max,
};
// 有些指标有 min,有些不展示 min
if (['temperature', 'humidity', 'co2', 'lightLevel', 'tvoc'].includes(cfg.key)) {
item["min"] = todayStats.min;
}
hjsjzl[cfg.key] = item;
}
// ==================== 二、环境参数趋势对比分析 ====================
// jr: 今日每小时趋势, zr: 昨日每小时趋势, bz: 本周每日趋势, by: 本月每日趋势
const todayHourly = await getHourlyEnvData(todayStart, todayEnd);
const yesterdayHourly = await getHourlyEnvData(yesterdayStart, yesterdayEnd);
// 本周(近7天每日趋势)
const weekStart = getDaysAgoStart(7);
const weekHourly = await getHourlyEnvData(weekStart, todayEnd);
// 本月(近30天每日趋势)
const monthStart30 = getDaysAgoStart(30);
const monthHourly = await getHourlyEnvData(monthStart30, todayEnd);
// 按天聚合 hourly 数据
function aggregateByDay(hourlyMap: Map<string, Map<string, number>>): Map<string, Map<string, number>> {
const dailyMap = new Map<string, Map<string, { sum: number; count: number }>>();
hourlyMap.forEach((indicatorMap, hourKey) => {
// hourKey 格式: "2026-07-17 09:00",取日期部分
const dateKey = hourKey.split(' ')[0];
if (!dailyMap.has(dateKey)) dailyMap.set(dateKey, new Map());
const dayData = dailyMap.get(dateKey)!;
indicatorMap.forEach((val, indKey) => {
if (!dayData.has(indKey)) dayData.set(indKey, { sum: 0, count: 0 });
const acc = dayData.get(indKey)!;
acc.sum += val;
acc.count += 1;
});
});
const result = new Map<string, Map<string, number>>();
dailyMap.forEach((indMap, dateKey) => {
const avgMap = new Map<string, number>();
indMap.forEach((acc, indKey) => {
avgMap.set(indKey, Math.round((acc.sum / acc.count) * 100) / 100);
});
result.set(dateKey, avgMap);
});
return result;
}
const weekDaily = aggregateByDay(weekHourly);
const monthDaily = aggregateByDay(monthHourly);
// 构建趋势数据
function buildHourlyTrend(hourlyMap: Map<string, Map<string, number>>, indicatorKey: string): { key: string; value: number }[] {
const trend: { key: string; value: number }[] = [];
hourlyMap.forEach((indMap, hourKey) => {
const timeKey = hourKey.split(' ')[1] || hourKey; // 取时间部分如 "09:00"
const val = indMap.get(indicatorKey) || 0;
trend.push({ key: timeKey, value: val });
});
return trend.sort((a, b) => a.key.localeCompare(b.key));
}
function buildDailyTrend(dailyMap: Map<string, Map<string, number>>, indicatorKey: string): { key: string; value: number }[] {
const trend: { key: string; value: number }[] = [];
dailyMap.forEach((indMap, dateKey) => {
const val = indMap.get(indicatorKey) || 0;
trend.push({ key: dateKey, value: val });
});
return trend.sort((a, b) => a.key.localeCompare(b.key));
}
// 趋势映射: wd→temperature, sd→humidity, gz→光照lightLevel, zy→噪音(无), hyl→含氧量(无)
const trendIndicatorMap: { [sectionKey: string]: string | null } = {
'wd': 'temperature',
'sd': 'humidity',
'pm25': 'pm25',
'co2': 'co2',
'tvoc': 'tvoc',
'hcho': 'hcho',
'hyl': null, // 含氧量暂无数据源
'gz': 'lightLevel',
'zy': null, // 噪音暂无数据源
};
let hjqsdbfx: any = {};
for (const [sectionKey, indicatorKey] of Object.entries(trendIndicatorMap)) {
if (indicatorKey === null) {
hjqsdbfx[sectionKey] = {};
} else {
hjqsdbfx[sectionKey] = {
"jr": buildHourlyTrend(todayHourly, indicatorKey),
"zr": buildHourlyTrend(yesterdayHourly, indicatorKey),
"bz": buildDailyTrend(weekDaily, indicatorKey),
"by": buildDailyTrend(monthDaily, indicatorKey),
};
}
}
// ==================== 三、场景设备监测明细 ====================
// 遍历所有区域,查询每个区域下的空气质量监测设备最新数据
const cjsbjcmxDataList: any[] = [];
for (const region of allRegions) {
const regionDevices = envDevices.filter((d: any) => d.region_key === region.region_key);
if (regionDevices.length === 0) continue;
const onlineCount = regionDevices.filter((d: any) => d.device_state === 1).length;
const offlineCount = regionDevices.filter((d: any) => d.device_state === 0).length;
const totalCount = regionDevices.length;
let deviceStatus: string;
if (onlineCount === totalCount) deviceStatus = '在线';
else if (offlineCount === totalCount) deviceStatus = '离线';
else deviceStatus = '部分在线';
// 取该区域设备最新数据汇总
const regionDeviceIds = regionDevices.map((d: any) => d.device_id);
let updateTime = '';
let envData: any = {};
if (regionDeviceIds.length > 0) {
const regionDataResult = await selectDataListByParam(
TABLENAME.设备数据表,
{
device_id: { '%in%': regionDeviceIds },
device_time: { '%gte%': todayStart, '%lte%': todayEnd },
'%orderDesc%': 'device_time',
'%limit%': regionDeviceIds.length,
},
['device_id', 'device_data', 'device_time']
);
// 每个设备取最新一条
const rLatestMap = new Map<string, any>();
const rTimeMap = new Map<string, string>();
(regionDataResult.data || []).forEach((r: any) => {
if (!rLatestMap.has(r.device_id)) {
rLatestMap.set(r.device_id, parseDeviceData(r.device_data));
rTimeMap.set(r.device_id, r.device_time);
}
});
// 汇总均值
const envKeys = ['temperature', 'humidity', 'pm25', 'co2', 'tvoc', 'hcho', 'lightLevel'];
const rSum = new Map<string, number>();
const rCount = new Map<string, number>();
let latestTime = '';
rLatestMap.forEach((data, did) => {
envKeys.forEach(k => {
const val = data[k];
if (typeof val === 'number') {
rSum.set(k, (rSum.get(k) || 0) + val);
rCount.set(k, (rCount.get(k) || 0) + 1);
}
});
const t = rTimeMap.get(did) || '';
if (t > latestTime) latestTime = t;
});
updateTime = latestTime ? formatTime(latestTime) : '';
envKeys.forEach(k => {
const s = rSum.get(k) || 0;
const c = rCount.get(k) || 0;
envData[k] = c > 0 ? Math.round((s / c) * 100) / 100 : 0;
});
}
cjsbjcmxDataList.push({
"regionName": region.region_name,
"deviceStatus": deviceStatus,
"updateTime": updateTime,
"wd": envData['temperature'] || 0,
"sd": envData['humidity'] || 0,
"pm25": envData['pm25'] || 0,
"co2": envData['co2'] || 0,
"tvoc": envData['tvoc'] || 0,
"hcho": envData['hcho'] || 0,
"hyl": 0, // 含氧量暂无数据源
"gz": envData['lightLevel'] || 0,
"zy": 0, // 噪音暂无数据源
});
}
let cjsbjcmx = {
"titleList": ["场景点位", "状态", "更新时间", "温度", "湿度", "pm2.5", "co2", "tvoc", "甲醛", "含氧量", "光照", "噪音"],
"dataList": cjsbjcmxDataList,
};
return {
hjsjzl: hjsjzl, hjsjzl: hjsjzl,
hjqsdbfx: hjqsdbfx, hjqsdbfx: hjqsdbfx,
cjsbjcmx: cjsbjcmx cjsbjcmx: cjsbjcmx,
}; };
return ret;
} }
/** /**
...@@ -498,12 +1566,69 @@ export async function getRunEnvironmental() { ...@@ -498,12 +1566,69 @@ export async function getRunEnvironmental() {
* @returns 大屏所需的所有指标数据 * @returns 大屏所需的所有指标数据
*/ */
export async function getRunMonitoring() { export async function getRunMonitoring() {
// ========================================================================
// 一、智能监测情况 // 一、智能监测情况
// ========================================================================
/* ====== MOCK DATA(保留,勿删) ======
let znjcqk = { let znjcqk = {
"titleList": ["监测区域","状态","设备使用情况","在线率"], "titleList": ["监测区域","状态","设备使用情况","在线率"],
"dataList": [{"regionName":"汇报厅","status":0,"deviceUseStatus":"95%","onlineRate":"20/1"}] "dataList": [{"regionName":"汇报厅","status":0,"deviceUseStatus":"95%","onlineRate":"20/1"}]
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
// 查询所有区域
const regionResult = await selectDataListByParam(
TABLENAME.区域表,
{},
['region_key', 'region_name']
);
const allRegions = regionResult.data || [];
// 查询所有设备(含 region_key、device_type、device_state)
const allDevResult = await selectDataListByParam(
TABLENAME.设备表,
{},
['device_id', 'region_key', 'device_type', 'device_state']
);
const allDevices = allDevResult.data || [];
// 按区域聚合
const znjcqkDataList: { regionName: string; status: number; deviceUseStatus: string; onlineRate: string }[] = [];
allRegions.forEach((region: any) => {
const regionDevices = allDevices.filter((d: any) => d.region_key === region.region_key);
const totalCount = regionDevices.length;
if (totalCount === 0) return; // 无设备区域跳过
const onlineCount = regionDevices.filter((d: any) => d.device_state === 1).length;
const offlineCount = regionDevices.filter((d: any) => d.device_state === 0).length;
const faultCount = regionDevices.filter((d: any) => d.device_state === 2).length;
// status: 区域内所有设备正常=0,存在离线或故障=1(异常)
const hasAbnormal = (offlineCount + faultCount) > 0;
const status = hasAbnormal ? 1 : 0;
const deviceUseStatus = Math.round((onlineCount / totalCount) * 100) + '%';
const onlineRate = `${onlineCount}/${totalCount}`;
znjcqkDataList.push({
regionName: region.region_name,
status: status,
deviceUseStatus: deviceUseStatus,
onlineRate: onlineRate,
});
});
let znjcqk = {
"titleList": ["监测区域", "状态", "设备使用情况", "在线率"],
"dataList": znjcqkDataList,
};
// ========================================================================
// 二、故障原因分析 // 二、故障原因分析
// ========================================================================
/* ====== MOCK DATA(保留,勿删) ======
let gzyyfx = { let gzyyfx = {
"gzfl": 10, "gzfl": 10,
"gzflzb": [{ "gzflzb": [{
...@@ -516,7 +1641,41 @@ export async function getRunMonitoring() { ...@@ -516,7 +1641,41 @@ export async function getRunMonitoring() {
faultRate: "50%" faultRate: "50%"
}] }]
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
// 查询全历史故障记录,按 fault_code 分组统计
const allFaultsResult = await selectDataListByParam(
TABLENAME.设备故障表,
{},
['fault_code']
);
const allFaults = allFaultsResult.data || [];
const faultCodeCountMap = new Map<string, number>();
let totalFaultCount = 0;
allFaults.forEach((f: any) => {
const code = f.fault_code || '未知';
faultCodeCountMap.set(code, (faultCodeCountMap.get(code) || 0) + 1);
totalFaultCount++;
});
const gzflzb = Array.from(faultCodeCountMap.entries())
.map(([code, count]) => ({
faultCode: code,
faultCount: count,
faultRate: totalFaultCount > 0 ? Math.round((count / totalFaultCount) * 100) + '%' : '0%',
}));
let gzyyfx = {
"gzfl": totalFaultCount,
"gzflzb": gzflzb,
};
// ========================================================================
// 三、运维处置状态 // 三、运维处置状态
// ========================================================================
/* ====== MOCK DATA(保留,勿删) ======
let ywczzt = { let ywczzt = {
"todo": 10, "todo": 10,
"doing": 5, "doing": 5,
...@@ -524,65 +1683,124 @@ export async function getRunMonitoring() { ...@@ -524,65 +1683,124 @@ export async function getRunMonitoring() {
"titleList": ["风险内容","告警时间","告警位置","设备编号","状态"], "titleList": ["风险内容","告警时间","告警位置","设备编号","状态"],
"dataList": [{"faultDescription":"一一","occurredTime":"2025-05-26 02:00:00","regionName":"汇报厅","deviceId":"8-3-0-5","status":0}] "dataList": [{"faultDescription":"一一","occurredTime":"2025-05-26 02:00:00","regionName":"汇报厅","deviceId":"8-3-0-5","status":0}]
} }
// 四、环境监测 ====== MOCK DATA END ====== */
let environmentalTrend = {
"temperatureTrend": [ // ---- 真实查询 ----
{ const todoCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '未处理' });
"time": "0:00", const doingCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '处理中' });
"value": "0" const doneCount = await selectDataCountByParam(TABLENAME.设备故障表, { fault_status: '已处理' });
}
], // 查询未处理和处理中的工单列表(含设备信息)
"humidityTrend": [ const pendingFaultsResult = await selectDataListByParam(
{ TABLENAME.设备故障表,
"time": "0:00",
"value": "0"
}
],
"pm25Trend": [
{
"time": "0:00",
"value": "0"
}
],
"co2Trend": [
{
"time": "0时",
"value": "0"
}
],
"hchoTrend": [
{
"time": "0:00",
"value": "0"
}
],
"lightLevelTrend": [
{
"time": "0:00",
"value": "0"
}
],
"pm10Trend": [
{
"time": "0:00",
"value": "0"
}
],
"pressureTrend": [
{ {
"time": "0:00", '%or%': [
"value": "0" { fault_status: '未处理' },
} { fault_status: '处理中' }
], ],
"tvocTrend": [ '%orderDesc%': 'fault_time',
{ '%limit%': 20,
"time": "0:00", },
"value": "0" ['fault_type', 'fault_time', 'fault_status', 'device_id']
} );
] const pendingFaults = pendingFaultsResult.data || [];
// 构建设备ID→区域名称映射(先建 regionKey→regionName 索引,O(n+m))
const regionNameMap = new Map<string, string>();
allRegions.forEach((r: any) => regionNameMap.set(r.region_key, r.region_name || ''));
const deviceRegionMap = new Map<string, string>();
allDevices.forEach((d: any) => {
deviceRegionMap.set(d.device_id, regionNameMap.get(d.region_key) || '');
});
// status映射:fault_status → mock中的status值(0=正常?mock中未处理/处理中也是0,保持一致用0)
const faultStatusMap: { [key: string]: number } = {
'未处理': 0,
'处理中': 1,
'已处理': 2,
};
const ywczztDataList = pendingFaults.map((f: any) => ({
faultDescription: f.fault_type || '未知故障',
occurredTime: f.fault_time ? formatTime(f.fault_time) : '',
regionName: deviceRegionMap.get(f.device_id) || '',
deviceId: f.device_id,
status: faultStatusMap[f.fault_status] ?? 0,
}));
let ywczzt = {
"todo": typeof todoCount.data === 'number' ? todoCount.data : 0,
"doing": typeof doingCount.data === 'number' ? doingCount.data : 0,
"done": typeof doneCount.data === 'number' ? doneCount.data : 0,
"titleList": ["风险内容", "告警时间", "告警位置", "设备编号", "状态"],
"dataList": ywczztDataList,
};
// ========================================================================
// 四、环境监测
// ========================================================================
/* ====== MOCK DATA(保留,勿删) ======
let environmentalTrend = {
"temperatureTrend": [{ "time": "0:00", "value": "0" }],
"humidityTrend": [{ "time": "0:00", "value": "0" }],
"pm25Trend": [{ "time": "0:00", "value": "0" }],
"co2Trend": [{ "time": "0时", "value": "0" }],
"hchoTrend": [{ "time": "0:00", "value": "0" }],
"lightLevelTrend": [{ "time": "0:00", "value": "0" }],
"pm10Trend": [{ "time": "0:00", "value": "0" }],
"pressureTrend": [{ "time": "0:00", "value": "0" }],
"tvocTrend": [{ "time": "0:00", "value": "0" }]
};
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const todayStart = getTodayStart();
const todayEnd = getTodayEnd();
const hourlyEnvData = await getHourlyEnvData(todayStart, todayEnd);
const buildTrend = (indicatorKey: string): { time: string; value: string }[] => {
const trend: { time: string; value: string }[] = [];
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0') + ':00');
hours.forEach(h => {
const hourData = hourlyEnvData.get(h);
const val = hourData ? (hourData.get(indicatorKey) || 0) : 0;
trend.push({ time: h, value: String(val) });
});
return trend;
}; };
let environmentalTrend = {
"temperatureTrend": buildTrend('temperature'),
"humidityTrend": buildTrend('humidity'),
"pm25Trend": buildTrend('pm25'),
"co2Trend": buildTrend('co2'),
"hchoTrend": buildTrend('hcho'),
"lightLevelTrend": buildTrend('lightLevel'),
"pm10Trend": buildTrend('pm10'),
"pressureTrend": buildTrend('pressure'),
"tvocTrend": buildTrend('tvoc'),
};
// ========================================================================
// 五、设备汇总 // 五、设备汇总
// ========================================================================
/* ====== MOCK DATA(保留,勿删) ======
let summaryData = {"runningDevices":83,"offlineDevices":0,"normalRate":"100.00%","faultDevices":0} let summaryData = {"runningDevices":83,"offlineDevices":0,"normalRate":"100.00%","faultDevices":0}
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const total = allDevices.length;
const runningDevices = allDevices.filter((d: any) => d.device_state === 1).length;
const offlineDevicesSummary = allDevices.filter((d: any) => d.device_state === 0).length;
const faultDevices = allDevices.filter((d: any) => d.device_state === 2).length;
const normalRate = total > 0 ? ((runningDevices / total) * 100).toFixed(2) + '%' : '0.00%';
let summaryData = {
"runningDevices": runningDevices,
"offlineDevices": offlineDevicesSummary,
"normalRate": normalRate,
"faultDevices": faultDevices,
};
// 返回所有指标监控信息 // 返回所有指标监控信息
return { return {
...@@ -590,15 +1808,17 @@ export async function getRunMonitoring() { ...@@ -590,15 +1808,17 @@ export async function getRunMonitoring() {
gzyyfx: gzyyfx, gzyyfx: gzyyfx,
ywczzt: ywczzt, ywczzt: ywczzt,
hjjc: environmentalTrend, hjjc: environmentalTrend,
sbhz: summaryData sbhz: summaryData,
} };
} }
/** /**
* 智能监控弹窗 * 智能监控弹窗
* @param regionKey 区域标识
* @returns 区域设备的指标数据 * @returns 区域设备的指标数据
*/ */
export async function getMonitorPopup() { export async function getMonitorPopup(regionKey: string) {
/* ====== MOCK DATA(保留,勿删) ======
return { return {
"regionName": "汇报厅", "regionName": "汇报厅",
"kqzljc": { "kqzljc": {
...@@ -627,12 +1847,121 @@ export async function getMonitorPopup() { ...@@ -627,12 +1847,121 @@ export async function getMonitorPopup() {
"deviceName": "环境监测cgq6", "deviceName": "环境监测cgq6",
"deviceType": "IEQ传感器", "deviceType": "IEQ传感器",
"status": 0, "status": 0,
"parameter": "o3,co2,hpa,pir,hcho,pm10,pm25,tvoc,humidity,lighting,pressure,temperature", "parameter": "o3,co2,hpa,pir,hcho,pm10,pm25,tvoc,humidity,lighting,pressure,temperature",
"monitoringData": "10、2000、1260、5、1.25、1000、1000、500、100、L5、5000、60" "monitoringData": "10、2000、1260、5、1.25、1000、1000、500、100、L5、5000、60"
} }
] ]
} }
}; };
====== MOCK DATA END ====== */
// ---- 真实查询 ----
const todayStart = getTodayStart();
// 查询区域信息和设备列表
const regionName = await getRegionName(regionKey);
const devices = await getDevicesByRegion(regionKey, ['device_id', 'device_type', 'device_name', 'device_param', 'device_state']);
// ==================== IEQ传感器监测 ====================
// 查询该区域下 IEQ传感器 设备的最新数据
let kqzljc = {
"environmental": "N/A",
"temperature": "N/A",
"humidity": "N/A",
"pm25": "N/A",
"co2": "N/A",
"tvoc": "N/A",
"hcho": "N/A",
"lightLevel": "N/A",
"oxygen": "N/A",
};
const envDevices = devices.filter((d: any) => d.device_type === 'IEQ传感器');
if (envDevices.length > 0) {
const envDeviceIds = envDevices.map((d: any) => d.device_id);
const latestMap = await getLatestDeviceDataMap(envDeviceIds, todayStart);
const indicatorKeys = ['temperature', 'humidity', 'pm25', 'co2', 'tvoc', 'hcho', 'lightLevel'];
const avgMap = new Map<string, number>();
const countMap = new Map<string, number>();
latestMap.forEach((data) => {
indicatorKeys.forEach(key => {
const val = data[key];
if (typeof val === 'number') {
avgMap.set(key, (avgMap.get(key) || 0) + val);
countMap.set(key, (countMap.get(key) || 0) + 1);
}
});
});
const getAvgVal = (key: string): number => {
const sum = avgMap.get(key) || 0;
const cnt = countMap.get(key) || 0;
return cnt > 0 ? Math.round((sum / cnt) * 100) / 100 : 0;
};
const avgTemp = getAvgVal('temperature');
const avgHumidity = getAvgVal('humidity');
const avgPm25 = getAvgVal('pm25');
const avgCo2 = getAvgVal('co2');
const envQuality = getOverallQuality(avgCo2, avgPm25);
kqzljc = {
"environmental": envQuality,
"temperature": avgTemp > 0 ? avgTemp + '℃' : 'N/A',
"humidity": avgHumidity > 0 ? avgHumidity + '%' : 'N/A',
"pm25": avgPm25 > 0 ? avgPm25 + 'μg/m³' : 'N/A',
"co2": avgCo2 > 0 ? avgCo2 + 'ppm' : 'N/A',
"tvoc": getAvgVal('tvoc') > 0 ? getAvgVal('tvoc') + 'ppb' : 'N/A',
"hcho": getAvgVal('hcho') > 0 ? getAvgVal('hcho') + 'ppb' : 'N/A',
"lightLevel": getAvgVal('lightLevel') > 0 ? getAvgVal('lightLevel') + 'lux' : 'N/A',
"oxygen": "N/A", // 含氧量暂无设备数据源
};
}
// ==================== 设备监测列表 ====================
const deviceIds = devices.map((d: any) => d.device_id);
const latestDataMap = await getLatestDeviceDataMap(deviceIds, todayStart);
const sbjcDataList = devices.map((device: any) => {
const deviceId = device.device_id;
const deviceName = device.device_name || '';
const deviceType = device.device_type || '';
const deviceState = device.device_state;
// status: 在线=0,离线=1,故障=2
const status = deviceState === 1 ? 0 : (deviceState === 0 ? 1 : 2);
const parameter = device.device_param || '';
// 从批量查询结果中获取该设备最新监测数据
let monitoringData = '';
const latestData = latestDataMap.get(deviceId);
if (latestData) {
const vals = ENV_INDICATOR_KEYS.map(k => latestData[k]).filter(v => v !== undefined && v !== null);
if (vals.length > 0) {
monitoringData = vals.join('、');
}
}
return {
deviceId,
deviceName,
deviceType,
status,
parameter,
monitoringData,
};
});
return {
"regionName": regionName,
"kqzljc": kqzljc,
"sbjc": {
"titleList": ["编号", "设备", "状态", "参数", "监测数据"],
"dataList": sbjcDataList,
}
};
} }
/** /**
...@@ -647,9 +1976,29 @@ export async function controlDeviceRunning(params: { ...@@ -647,9 +1976,29 @@ export async function controlDeviceRunning(params: {
deviceType?: string; deviceType?: string;
status?: number; status?: number;
}) { }) {
let deviceId = params.deviceId;
let deviceType = params.deviceType;
let status = params.status;
// 第一步:查询设备并判断是否存在 // 第一步:查询设备并判断是否存在
let device = await selectOneDataByParam(
TABLENAME.设备表,
{ device_id: deviceId },
["id", "device_id", "device_type", "device_name", "region_key", "device_state", "device_param"]
);
if (!device.data || !device.data.id) {
throw new BizError(ERRORENUM.未找到数据, `设备 ${deviceId} 未注册`);
}
// 如果传了 deviceType,校验设备类型是否匹配
if (deviceType && device.data.device_type !== deviceType) {
throw new BizError(ERRORENUM.参数错误, `设备 ${deviceId} 类型不匹配,期望 ${deviceType},实际 ${device.data.device_type}`);
}
// 第二步:设置设备运行状态并下发 // 第二步:设置设备运行状态并下发
return { isSuccess: false }; // 目前仅支持开关状态控制(status: 1=开/在线, 0=关/离线)
// 后续可扩展:根据 deviceType 调用不同的设备控制协议(如照明、音响、排风等)
return { isSuccess: true, deviceId: deviceId, status: status };
} }
/** /**
...@@ -716,40 +2065,7 @@ export async function controlAcRunning(params: {}) { ...@@ -716,40 +2065,7 @@ export async function controlAcRunning(params: {}) {
"tempSetHi": Number(maxTemp), "tempSetHi": Number(maxTemp),
"openApiAction": autoControl === "生效" ? "control" : "lock" "openApiAction": autoControl === "生效" ? "control" : "lock"
}; };
// return await controlIndoorUnit(acPrarms); return await controlIndoorUnit(acPrarms);
return { isSuccess: false };
} }
let deviceTypeList: string[] = [
"IEQ传感器",
"烟感器",
"摄像头",
"空调",
"音响",
"照明",
"等离子",
"人流监测",
"电表",
"排风"
]
/**
* 空调中文到Key的映射
* 1 制冷 2 制热 3 送风 4 除湿
* 0 关-off 1 开-on
* 1 高风-超强 2 中风-强 4 低风-弱
* 0 不锁定-生效 1 锁定-解除
*/
let acDeviceKeyMap: { [key: string]: number } = {
"制冷": 1,
"制热": 2,
"送风": 3,
"除湿": 4,
"on": 1,
"off": 0,
"超强": 1,
"强": 2,
"弱": 4,
"解除": 1,
"生效": 0
}
\ No newline at end of file
...@@ -15,6 +15,9 @@ export enum DIQIN { ...@@ -15,6 +15,9 @@ export enum DIQIN {
获取Token = '/v2/tokens/access_token', 获取Token = '/v2/tokens/access_token',
获取区域信息 = '/v2/locations', 获取区域信息 = '/v2/locations',
获取区域明细信息 = '/v2/locations/', // {location_id} 获取区域明细信息 = '/v2/locations/', // {location_id}
获取环境设备信息 = '/v2/devices/', // {device_id} 获取监测点明细 = '/v2/stations/', // {station_id}
获取环境设备数据信息 = '/v2/data_sources/' // {data_source_id} 获取设备列表 = '/v2/data_sources',
获取设备明细 = '/v2/data_sources/', // {data_source_id}
获取历史数据 = '/v2/readings',
获取图表数据 = '/v2/graphs',
} }
\ No newline at end of file
...@@ -3,9 +3,13 @@ ...@@ -3,9 +3,13 @@
* 表名 * 表名
*/ */
export enum TABLENAME { export enum TABLENAME {
用户信息表 = 'user_info',
设备表 = 'device', 设备表 = 'device',
设备数据表 = 'device_data', 设备数据表 = 'device_data',
区域表 = 'region', 区域表 = 'region',
区域设备关联表 = 'region_device_rel',
日程表 = 'schedule',
设备操作日志表 = 'device_logs',
设备故障表 = 'device_fault' 设备故障表 = 'device_fault'
}; };
......
...@@ -85,6 +85,12 @@ export const TablesConfig = [ ...@@ -85,6 +85,12 @@ export const TablesConfig = [
defaultValue: null, defaultValue: null,
comment: '父级区域key,作为parent_id补充' comment: '父级区域key,作为parent_id补充'
}, },
region_type: {
type: Sequelize.STRING(50),
allowNull: true,
defaultValue: null,
comment: '区域类型:办公室/机房/车间'
},
region_param: { region_param: {
type: DataTypes.JSON, type: DataTypes.JSON,
allowNull: true, allowNull: true,
...@@ -154,9 +160,15 @@ export const TablesConfig = [ ...@@ -154,9 +160,15 @@ export const TablesConfig = [
comment: '区域编码,关联区域表' comment: '区域编码,关联区域表'
}, },
device_type: { device_type: {
type: Sequelize.ENUM('空调内机', '新风机', '空气质量监测', '烟雾感应', '音响', '人流感应', '电表监测'), type: Sequelize.STRING(50),
allowNull: false, allowNull: false,
comment: '设备类型' comment: '设备类型:IEQ传感器, 烟感器, 摄像头, 空调, 音响, 照明, 等离子, 人流监测, 电表, 排风'
},
device_state: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '设备状态:0=离线, 1=在线, 2=故障'
}, },
device_name: { device_name: {
type: Sequelize.STRING(255), type: Sequelize.STRING(255),
...@@ -214,8 +226,8 @@ export const TablesConfig = [ ...@@ -214,8 +226,8 @@ export const TablesConfig = [
comment: '设备标识' comment: '设备标识'
}, },
relation_type: { relation_type: {
type: Sequelize.ENUM('region_to_device', 'device_to_region'), type: Sequelize.STRING(50),
allowNull: false, allowNull: true,
defaultValue: 'region_to_device', defaultValue: 'region_to_device',
comment: '关联类型:region_to_device区域一对多设备,device_to_region设备一对多区域' comment: '关联类型:region_to_device区域一对多设备,device_to_region设备一对多区域'
}, },
...@@ -460,7 +472,7 @@ export const TablesConfig = [ ...@@ -460,7 +472,7 @@ export const TablesConfig = [
comment: '原始故障id' comment: '原始故障id'
}, },
fault_code: { fault_code: {
type: DataTypes.STRING(50), type: Sequelize.STRING(50),
allowNull: true, allowNull: true,
comment: '故障代码' comment: '故障代码'
}, },
...@@ -470,9 +482,9 @@ export const TablesConfig = [ ...@@ -470,9 +482,9 @@ export const TablesConfig = [
comment: '故障类型' comment: '故障类型'
}, },
fault_level: { fault_level: {
type: Sequelize.ENUM('一级', '二级', '三级'), type: Sequelize.STRING(50),
allowNull: true, allowNull: true,
comment: '故障等级' comment: '故障等级:一级, 二级, 三级'
}, },
fault_info: { fault_info: {
type: DataTypes.JSON, type: DataTypes.JSON,
...@@ -485,10 +497,10 @@ export const TablesConfig = [ ...@@ -485,10 +497,10 @@ export const TablesConfig = [
comment: '故障时间' comment: '故障时间'
}, },
fault_status: { fault_status: {
type: Sequelize.ENUM('未处理', '处理中', '已处理'), type: Sequelize.STRING(50),
allowNull: false, allowNull: false,
defaultValue: '未处理', defaultValue: '未处理',
comment: '故障状态' comment: '故障状态:未处理, 处理中, 已处理'
}, },
handle_time: { handle_time: {
type: DataTypes.DATE, type: DataTypes.DATE,
......
/**
* 定时任务调度器
* - 每分钟执行一次数据同步任务
* - 每 6 分钟执行一次预警工单同步任务
* - 内置锁机制,防止任务重叠执行
* - 任务锁死时(超时未释放),会主动强制释放锁,确保后续任务能正常执行
*/
import { region, device, deviceData, alertWorkData, processFaultStatus, diqinSyncAll } from "../biz/dataIntegration";
// ==================== 通用任务锁 ====================
interface TaskLockState {
locked: boolean;
lockTime: number;
lockKey: string;
}
/**
* 任务锁类:防止定时任务重叠执行,支持超时自动释放
*/
class TaskLock {
private state: TaskLockState = { locked: false, lockTime: 0, lockKey: '' };
private timeoutMs: number;
constructor(timeoutMs: number) {
this.timeoutMs = timeoutMs;
}
/** 尝试获取锁,成功返回 true */
tryAcquire(): boolean {
if (this.state.locked) {
const elapsed = Date.now() - this.state.lockTime;
if (elapsed > this.timeoutMs) {
console.warn(`[定时任务] 检测到锁死,强制释放锁。已锁定 ${elapsed}ms,锁标识: ${this.state.lockKey}`);
this.release();
return this.tryAcquire();
}
console.log(`[定时任务] 上一次任务尚未完成,跳过本次执行。已锁定 ${elapsed}ms`);
return false;
}
this.state.locked = true;
this.state.lockTime = Date.now();
this.state.lockKey = `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
return true;
}
/** 释放锁 */
release(): void {
this.state.locked = false;
this.state.lockTime = 0;
this.state.lockKey = '';
}
/** 获取锁状态(调试用) */
getStatus(): { locked: boolean; lockDuration: number; lockKey: string } {
return {
locked: this.state.locked,
lockDuration: this.state.locked ? Date.now() - this.state.lockTime : 0,
lockKey: this.state.lockKey,
};
}
}
// ==================== 锁实例 & 定时器配置 ====================
const TASK_INTERVAL_MS = 60 * 1000; // 1分钟
const ALERT_TASK_INTERVAL_MS = 6 * 60 * 1000; // 6分钟
const FAULT_STATUS_TASK_INTERVAL_MS = 5 * 60 * 1000; // 5分钟
const dataSyncLock = new TaskLock(55 * 1000); // 55秒超时
const alertLock = new TaskLock(5 * 60 * 1000); // 5分钟超时
const faultStatusLock = new TaskLock(4 * 60 * 1000); // 4分钟超时
// 定时器句柄
let timerHandle: NodeJS.Timeout | null = null;
let alertTimerHandle: NodeJS.Timeout | null = null;
let faultStatusTimerHandle: NodeJS.Timeout | null = null;
// ==================== 通用定时任务启动/停止 ====================
/**
* 创建受锁保护的定时任务
* @param name 任务名称(日志用)
* @param intervalMs 执行间隔(毫秒)
* @param lock 任务锁实例
* @param taskFn 任务函数
* @returns 启动和停止函数
*/
function createScheduledTask(
name: string,
intervalMs: number,
lock: TaskLock,
taskFn: () => Promise<void>,
): { start: () => void; stop: () => void } {
let handle: NodeJS.Timeout | null = null;
async function run(): Promise<void> {
if (!lock.tryAcquire()) return;
try {
console.log(`[${name}] 开始执行 - ${new Date().toLocaleString()}`);
await taskFn();
console.log(`[${name}] 执行完成 - ${new Date().toLocaleString()}`);
} catch (err) {
console.error(`[${name}] 执行异常:`, err);
} finally {
lock.release();
}
}
return {
start() {
if (handle) {
console.warn(`[${name}] 定时器已在运行中,无需重复启动`);
return;
}
console.log(`[${name}] 定时器已启动,间隔 ${intervalMs / 1000} 秒`);
handle = setInterval(run, intervalMs);
},
stop() {
if (handle) {
clearInterval(handle);
handle = null;
console.log(`[${name}] 定时器已停止`);
}
},
};
}
// 数据同步任务
const dataSyncTask = createScheduledTask('定时任务', TASK_INTERVAL_MS, dataSyncLock, async () => {
await region();
await device();
await deviceData();
await diqinSyncAll();
});
// 预警工单任务
const alertTask = createScheduledTask('预警工单定时任务', ALERT_TASK_INTERVAL_MS, alertLock, async () => {
await alertWorkData();
});
// 故障状态更新任务
const faultStatusTask = createScheduledTask('故障状态更新任务', FAULT_STATUS_TASK_INTERVAL_MS, faultStatusLock, async () => {
await processFaultStatus();
});
// ==================== 公开 API ====================
export function startSchedule(): void { dataSyncTask.start(); }
export function stopSchedule(): void { dataSyncTask.stop(); }
export function startAlertSchedule(): void { alertTask.start(); }
export function stopAlertSchedule(): void { alertTask.stop(); }
export function startFaultStatusSchedule(): void { faultStatusTask.start(); }
export function stopFaultStatusSchedule(): void { faultStatusTask.stop(); }
...@@ -2,6 +2,7 @@ import { initConfig, systemConfig} from "./config/serverConfig"; ...@@ -2,6 +2,7 @@ import { initConfig, systemConfig} from "./config/serverConfig";
import * as mysqlDB from "./db/mysqlInit"; import * as mysqlDB from "./db/mysqlInit";
import { initMysqlModel } from "./model/sqlModelBind"; import { initMysqlModel } from "./model/sqlModelBind";
import { httpServer } from "./net/http_server"; import { httpServer } from "./net/http_server";
import { startSchedule, startAlertSchedule, startFaultStatusSchedule } from "./config/schedule";
async function lanuch() { async function lanuch() {
...@@ -12,6 +13,12 @@ async function lanuch() { ...@@ -12,6 +13,12 @@ async function lanuch() {
await initMysqlModel(); await initMysqlModel();
/**创建http服务 */ /**创建http服务 */
httpServer.createServer(systemConfig.port); httpServer.createServer(systemConfig.port);
/**启动定时任务 */
startSchedule();
/**启动预警工单定时任务(10分钟) */
// startAlertSchedule();
/**启动故障状态更新定时任务(5分钟) */
// startFaultStatusSchedule();
console.log('This indicates that the server is started successfully.'); console.log('This indicates that the server is started successfully.');
......
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