预警工单数据接入

parent 7a8453e5
......@@ -4,7 +4,7 @@
*/
import * as crypto from 'crypto';
import { post } from '../util/request';
import { post, get } from '../util/request';
import { BizError } from '../util/bizError';
import { ERRORENUM } from '../config/errorEnum';
......@@ -379,3 +379,79 @@ export async function getIndoorUnitStateList(params: any): Promise<any> {
const result = await feiYiPost(path, body);
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);
}
\ No newline at end of file
......@@ -96,6 +96,7 @@ function dayRange(day: Date): { from: string; to: string } {
/** 某馆的所有设备分组(用于批量查询) */
interface HallDeviceGroup {
acDevices: { device_id: string; device_name: string; region_name: string; region_key: number }[];
envDevices: { device_id: string; device_name: string; region_key: number }[];
customerDevices: { device_id: string; device_name: string; region_key: number }[];
meterDevices: { device_id: string; device_name: string; region_key: number }[];
allDeviceIds: string[];
......@@ -107,7 +108,7 @@ interface HallDeviceGroup {
*/
async function queryHallDeviceGroup(regionIds: number[]): Promise<HallDeviceGroup> {
if (regionIds.length === 0) {
return { acDevices: [], customerDevices: [], meterDevices: [], allDeviceIds: [], allDevices: [] };
return { acDevices: [], envDevices: [], customerDevices: [], meterDevices: [], allDeviceIds: [], allDevices: [] };
}
// 并行查询该馆设备和区域名称
......@@ -134,6 +135,7 @@ async function queryHallDeviceGroup(regionIds: number[]): Promise<HallDeviceGrou
return {
acDevices,
envDevices: allDevices.filter(d => d.device_type === "环境监测"),
customerDevices: allDevices.filter(d => d.device_type === "客流传感器"),
meterDevices: allDevices.filter(d => d.device_type === "电能监测"),
allDeviceIds: allDevices.map(d => d.device_id),
......@@ -142,20 +144,15 @@ async function queryHallDeviceGroup(regionIds: number[]): Promise<HallDeviceGrou
}
/**
* 从空调/新风设备数据中计算室温均值 + 运行时长
* 从空调/新风设备数据中计算运行时长
*/
function calcAcDeviceDaily(records: any[], dayEndTime: Date): { avgTemp: number; runHours: number; tempCount: number } {
function calcAcRunHours(records: any[], dayEndTime: Date): number {
const sorted = records.slice().sort(
(a, b) => new Date(a.device_time).getTime() - new Date(b.device_time).getTime(),
);
let tempSum = 0, tempCount = 0, runHours = 0;
let runHours = 0;
for (let i = 0; i < sorted.length; i++) {
const d = sorted[i].data || {};
if (d.roomTemp != null && Number(d.roomTemp) !== 0) {
tempSum += Number(d.roomTemp);
tempCount++;
}
if (d.power === "on") {
const t1 = new Date(sorted[i].device_time).getTime();
const t2 = i + 1 < sorted.length
......@@ -164,44 +161,52 @@ function calcAcDeviceDaily(records: any[], dayEndTime: Date): { avgTemp: number;
if (t2 > t1) runHours += (t2 - t1) / (1000 * 60 * 60);
}
}
return runHours;
}
const avgTemp = tempCount > 0 ? Math.round(tempSum / tempCount * 10) / 10 : 0;
return { avgTemp, runHours, tempCount };
/**
* 从环境监测设备数据中计算室温均值(使用 data.temperature 字段)
*/
function calcEnvDeviceDaily(records: any[]): { tempSum: number; tempCount: number } {
let tempSum = 0, tempCount = 0;
for (const rec of records) {
const d = rec.data || {};
if (d.temperature != null && Number(d.temperature) !== 0) {
tempSum += Number(d.temperature);
tempCount++;
}
}
return { tempSum, tempCount };
}
/**
* 从客流传感器数据中计算人流总值
* 从客流传感器数据中计算当日平均在馆人数
* current_total 为当时在场人数(瞬时值),取当日所有采集值的算术平均
*/
function calcCustomerDeviceDaily(records: any[]): number {
let visitorSum = 0;
function calcCustomerDeviceDaily(records: any[]): { visitorSum: number; visitorCount: number } {
let sum = 0, count = 0;
for (const rec of records) {
const d = rec.data || {};
if (d.current_total != null) visitorSum += Number(d.current_total);
if (d.current_total != null) {
sum += Number(d.current_total);
count++;
}
}
return visitorSum;
return { visitorSum: sum, visitorCount: count };
}
/**
* 从电表数据中计算24时能耗(末次累计 - 首次累计
* 从电表数据中计算24时总用电量(累加每次上报的 current 值
*/
function calcMeterDeviceDaily(records: any[]): number {
let firstEnergy: number | null = null;
let lastEnergy: number | null = null;
const sorted = records.slice().sort(
(a, b) => new Date(a.device_time).getTime() - new Date(b.device_time).getTime(),
);
for (const rec of sorted) {
let total = 0;
for (const rec of records) {
const d = rec.data || {};
if (d.energy != null) {
const val = Number(d.energy);
if (firstEnergy === null) firstEnergy = val;
lastEnergy = val;
if (d.current != null) {
total += Number(d.current);
}
}
return (lastEnergy !== null && firstEnergy !== null && lastEnergy > firstEnergy)
? lastEnergy - firstEnergy
: 0;
return total;
}
/**
......@@ -222,7 +227,7 @@ export async function getWeeklyReportData(weekOffset: number = 0): Promise<any>
const hallDeviceGroups = new Map<string, HallDeviceGroup>();
for (let i = 0; i < HALL_NAMES.length; i++) {
hallDeviceGroups.set(HALL_NAMES[i], groups[i]);
console.log(`[周报告JSON] ${HALL_NAMES[i]}: AC${groups[i].acDevices.length} 客流${groups[i].customerDevices.length} 电表${groups[i].meterDevices.length}`);
console.log(`[周报告JSON] ${HALL_NAMES[i]}: AC${groups[i].acDevices.length} 环境${groups[i].envDevices.length} 客流${groups[i].customerDevices.length} 电表${groups[i].meterDevices.length}`);
}
// ===== 预查询:一次性拉取所有设备未解决故障 =====
......@@ -273,21 +278,30 @@ export async function getWeeklyReportData(weekOffset: number = 0): Promise<any>
}
let dayRunHours = 0, dayTempSum = 0, dayTempCount = 0;
let dayEnergy = 0, dayVisitor = 0;
let dayEnergy = 0, dayVisitorSum = 0, dayVisitorCount = 0;
for (const dev of group.acDevices) {
const records = deviceDataMap.get(dev.device_id) || [];
const { avgTemp, runHours, tempCount } = calcAcDeviceDaily(records, dayEnd);
dayRunHours += runHours;
if (tempCount > 0) { dayTempSum += avgTemp * tempCount; dayTempCount += tempCount; }
dayRunHours += calcAcRunHours(records, dayEnd);
}
for (const dev of group.envDevices) {
const records = deviceDataMap.get(dev.device_id) || [];
const { tempSum, tempCount } = calcEnvDeviceDaily(records);
dayTempSum += tempSum;
dayTempCount += tempCount;
}
for (const dev of group.customerDevices) {
dayVisitor += calcCustomerDeviceDaily(deviceDataMap.get(dev.device_id) || []);
const { visitorSum, visitorCount } = calcCustomerDeviceDaily(deviceDataMap.get(dev.device_id) || []);
dayVisitorSum += visitorSum;
dayVisitorCount += visitorCount;
}
for (const dev of group.meterDevices) {
dayEnergy += calcMeterDeviceDaily(deviceDataMap.get(dev.device_id) || []);
}
// 当日在馆人数均值 = 所有客流传感器当天采集值的平均
const dayVisitor = dayVisitorCount > 0 ? dayVisitorSum : 0; // Math.round(dayVisitorSum / dayVisitorCount)
// 累加到周汇总
hallTotalRunHours += dayRunHours;
if (dayTempCount > 0) { hallTempSum += dayTempSum; hallTempCount += dayTempCount; }
......@@ -299,15 +313,15 @@ export async function getWeeklyReportData(weekOffset: number = 0): Promise<any>
dayRunHours > 0 ? fmtNum(dayRunHours) + "h" : "——",
dayTempCount > 0 ? fmtNum(dayTempSum / dayTempCount) : "——",
dayVisitor > 0 ? String(dayVisitor) : "——",
dayEnergy > 0 ? String(Math.round(dayEnergy * 100) / 100) : "——",
dayEnergy > 0 ? String(Math.round(dayEnergy / 24 * 100) / 100) : "——",
]);
}
// ===== 第四步:汇总各馆 =====
const hallAvgTemp = hallTempCount > 0 ? hallTempSum / hallTempCount : null;
const hallTempStr = hallAvgTemp != null ? fmtNum(hallAvgTemp) + "°C" : "——";
const hallEnergyStr = fmtEnergy(hallTotalEnergy);
const kllStr = String(hallTotalVisitor || "——");
const hallEnergyStr = fmtEnergy(hallTotalEnergy / 7);
const kllStr = hallTotalVisitor > 0 ? String(Math.round(hallTotalVisitor / 7)) : "——";
dataTotal[hallKey] = { nh: hallEnergyStr, kll: kllStr, jw: hallTempStr };
runDatas[hallName] = {
......@@ -336,8 +350,8 @@ export async function getWeeklyReportData(weekOffset: number = 0): Promise<any>
// ===== 第五步:整体汇总 + 评价 =====
dataTotal.ztyx = {
nh: fmtEnergy(overallEnergy),
kll: String(overallVisitor || "——"),
nh: fmtEnergy(overallEnergy / 7),
kll: overallVisitor > 0 ? String(Math.round(overallVisitor / 7)) : "——",
jw: overallTempCount > 0
? fmtNum(overallTempSum / overallTempCount) + "°C"
: "——",
......
......@@ -65,11 +65,22 @@ export const TablesConfig = [
allowNull: false,
comment: '故障类型,如“离线”“数据异常”“硬件故障”'
},
fault_code: {
type: DataTypes.STRING(50),
allowNull: true,
comment: '故障代码,如 LOST、E1 等'
},
fault_description: {
type: DataTypes.TEXT,
allowNull: true,
comment: '故障详细描述'
},
fault_origin_id: {
type: DataTypes.STRING(64),
allowNull: true,
unique: true,
comment: '第三方故障记录ID,用于去重'
},
occurred_time: {
type: DataTypes.DATE,
allowNull: false,
......
/**
* 定时任务调度器
* - 每分钟执行一次任务
* - 每分钟执行一次数据同步任务
* - 每 10 分钟执行一次预警工单同步任务
* - 内置锁机制,防止任务重叠执行
* - 任务锁死时(超时未释放),会主动强制释放锁,确保后续任务能正常执行
*/
import { dataintegration } from "tencentcloud-sdk-nodejs";
import { region, device, deviceData } from "../biz/dataIntegration";
import { region, device, deviceData, alertWorkData, processFaultStatus } from "../biz/dataIntegration";
// 锁超时时间(毫秒),任务执行超过此时间视为锁死
const LOCK_TIMEOUT_MS = 55 * 1000; // 55秒,留5秒余量给下一次执行
......@@ -111,6 +112,43 @@ async function scheduledTask(): Promise<void> {
// 定时器句柄
let timerHandle: NodeJS.Timeout | null = null;
let alertTimerHandle: NodeJS.Timeout | null = null;
let faultStatusTimerHandle: NodeJS.Timeout | null = null;
// 预警工单定时任务专用锁
const alertTaskLock: TaskLock = {
locked: false,
lockTime: 0,
lockKey: '',
};
/** 预警工单锁超时(毫秒) */
const ALERT_LOCK_TIMEOUT_MS = 9 * 60 * 1000; // 9 分钟
/** 预警工单执行间隔(毫秒) */
const ALERT_TASK_INTERVAL_MS = 10 * 60 * 1000; // 10 分钟
/**
* 尝试获取预警工单任务锁
*/
function tryAcquireAlertLock(): boolean {
if (alertTaskLock.locked) {
const elapsed = Date.now() - alertTaskLock.lockTime;
if (elapsed > ALERT_LOCK_TIMEOUT_MS) {
console.warn(`[预警工单定时任务] 检测到锁死,强制释放锁。已锁定 ${elapsed}ms`);
alertTaskLock.locked = false;
alertTaskLock.lockTime = 0;
alertTaskLock.lockKey = '';
return tryAcquireAlertLock();
}
console.log(`[预警工单定时任务] 上一次任务尚未完成,跳过本次执行`);
return false;
}
alertTaskLock.locked = true;
alertTaskLock.lockTime = Date.now();
alertTaskLock.lockKey = `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
return true;
}
/**
* 启动定时任务
......@@ -122,7 +160,7 @@ export function startSchedule(): void {
return;
}
console.log(`[定时任务] 定时器已启动,间隔 ${TASK_INTERVAL_MS / 1000} 秒`);
console.log(`[定时任务] 数据同步定时器已启动,间隔 ${TASK_INTERVAL_MS / 1000} 秒`);
// 使用 setInterval 实现每分钟执行一次
timerHandle = setInterval(() => {
......@@ -131,6 +169,129 @@ export function startSchedule(): void {
}
/**
* 启动预警工单定时任务
* 独立于数据同步定时任务,每 10 分钟执行一次
*/
export function startAlertSchedule(): void {
if (alertTimerHandle) {
console.warn('[预警工单定时任务] 定时器已在运行中,无需重复启动');
return;
}
console.log(`[预警工单定时任务] 定时器已启动,间隔 ${ALERT_TASK_INTERVAL_MS / 1000} 秒`);
alertTimerHandle = setInterval(() => {
scheduledAlertTask();
}, ALERT_TASK_INTERVAL_MS);
}
/**
* 预警工单定时任务的具体业务逻辑
*/
async function scheduledAlertTask(): Promise<void> {
if (!tryAcquireAlertLock()) {
return;
}
try {
console.log(`[预警工单定时任务] 开始执行 - ${new Date().toLocaleString()}`);
await alertWorkData();
console.log(`[预警工单定时任务] 执行完成 - ${new Date().toLocaleString()}`);
} catch (err) {
console.error('[预警工单定时任务] 执行异常:', err);
} finally {
alertTaskLock.locked = false;
alertTaskLock.lockTime = 0;
alertTaskLock.lockKey = '';
}
}
// ==================== 故障状态更新定时任务 ====================
/** 故障状态更新锁超时(毫秒) */
const FAULT_STATUS_LOCK_TIMEOUT_MS = 4 * 60 * 1000; // 4 分钟
/** 故障状态更新执行间隔(毫秒) */
const FAULT_STATUS_TASK_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟
/** 故障状态更新专用锁 */
const faultStatusTaskLock: TaskLock = {
locked: false,
lockTime: 0,
lockKey: '',
};
/**
* 尝试获取故障状态更新任务锁
*/
function tryAcquireFaultStatusLock(): boolean {
if (faultStatusTaskLock.locked) {
const elapsed = Date.now() - faultStatusTaskLock.lockTime;
if (elapsed > FAULT_STATUS_LOCK_TIMEOUT_MS) {
console.warn(`[故障状态更新任务] 检测到锁死,强制释放锁。已锁定 ${elapsed}ms`);
faultStatusTaskLock.locked = false;
faultStatusTaskLock.lockTime = 0;
faultStatusTaskLock.lockKey = '';
return tryAcquireFaultStatusLock();
}
console.log(`[故障状态更新任务] 上一次任务尚未完成,跳过本次执行`);
return false;
}
faultStatusTaskLock.locked = true;
faultStatusTaskLock.lockTime = Date.now();
faultStatusTaskLock.lockKey = `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
return true;
}
/**
* 故障状态更新定时任务
*/
async function scheduledFaultStatusTask(): Promise<void> {
if (!tryAcquireFaultStatusLock()) {
return;
}
try {
console.log(`[故障状态更新任务] 开始执行 - ${new Date().toLocaleString()}`);
await processFaultStatus();
console.log(`[故障状态更新任务] 执行完成 - ${new Date().toLocaleString()}`);
} catch (err) {
console.error('[故障状态更新任务] 执行异常:', err);
} finally {
faultStatusTaskLock.locked = false;
faultStatusTaskLock.lockTime = 0;
faultStatusTaskLock.lockKey = '';
}
}
/**
* 启动故障状态更新定时任务
*/
export function startFaultStatusSchedule(): void {
if (faultStatusTimerHandle) {
console.warn('[故障状态更新任务] 定时器已在运行中,无需重复启动');
return;
}
console.log(`[故障状态更新任务] 定时器已启动,间隔 ${FAULT_STATUS_TASK_INTERVAL_MS / 60000} 分钟`);
faultStatusTimerHandle = setInterval(() => {
scheduledFaultStatusTask();
}, FAULT_STATUS_TASK_INTERVAL_MS);
}
/**
* 停止故障状态更新定时任务
*/
export function stopFaultStatusSchedule(): void {
if (faultStatusTimerHandle) {
clearInterval(faultStatusTimerHandle);
faultStatusTimerHandle = null;
console.log('[故障状态更新任务] 定时器已停止');
}
}
/**
* 停止定时任务
*/
export function stopSchedule(): void {
......@@ -140,3 +301,14 @@ export function stopSchedule(): void {
console.log('[定时任务] 定时器已停止');
}
}
/**
* 停止预警工单定时任务
*/
export function stopAlertSchedule(): void {
if (alertTimerHandle) {
clearInterval(alertTimerHandle);
alertTimerHandle = null;
console.log('[预警工单定时任务] 定时器已停止');
}
}
......@@ -3,7 +3,7 @@ import * as mysqlDB from "./db/mysqlInit";
import * as mqttSrv from "./service/mqttInit";
import { initMysqlModel } from "./model/sqlModelBind";
import { httpServer } from "./net/http_server";
import { startSchedule } from "./config/schedule";
import { startSchedule, startAlertSchedule, startFaultStatusSchedule } from "./config/schedule";
async function lanuch() {
......@@ -18,6 +18,10 @@ async function lanuch() {
await mqttSrv.startMqttClient();
/**启动定时任务 */
startSchedule();
/**启动预警工单定时任务(10分钟) */
startAlertSchedule();
/**启动故障状态更新定时任务(5分钟) */
startFaultStatusSchedule();
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