feat(alarm): ICC 报警事件订阅与设备报警监控/报警派单 - 重建 device_alarm 表(迁移20261024_icc_event_alarm.sql:alarm_code 唯一、uuid、line 关联、客户、等级、事件标记、alarm_time 等 19 字段与 4 索引)并新建 alarm_dispatch 派单表(15 字段);订阅服务 IccEventSubscribeService 复用 OAuth2 鉴权向 ICC 注册报警事件回调(POST subscribe/mqinfo,monitor 带 token、同名覆盖保活),每 30 分钟保活任务(sys_job 种子);回调 POST /api/icc-event/callback 无鉴权仅 URL token 校验,兼容单条/数组事件解析、按 alarmCode 幂等 upsert、匹配 electric_line.line_code 回填线路客户;新增 GET /api/device-alarms(列表+今日/严重/未派单统计+客户/等级/关键词筛选)与 POST /api/device-alarms/:id/dispatch(AD 派单号、同报警重复派单拦截、电工在岗审核校验、NoticeService 站内通知);GET /api/alarm-dispatches 台账(联电工/客户名);前端设备报警监控页(统计卡/筛选/表格/发起报警派单弹窗/30 秒轮询+严重报警红灯闪烁与 Web Audio 蜂鸣/订阅状态弹窗)与报警派单台账页+路由;菜单 63/64、role_menu、sys_job 种子;同步 db_init/db_seed 与设计文档;端到端验证通过
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
1 parent
c8a4330f3b
commit
44fc9a1d54
24 files changed
+2825
-126
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import { Controller, Get, Post, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import DeviceAlarm from "../entity/device_alarm.entity";
|
||||
import AlarmDispatch from "../entity/alarm_dispatch.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
|
||||
@Controller("/api/device-alarms")
|
||||
export class DeviceAlarmController {
|
||||
@Inject() ctx!: Context;
|
||||
@Inject() noticeService!: NoticeService;
|
||||
|
||||
@InjectRepository(DeviceAlarm)
|
||||
alarmRepo!: Repository<DeviceAlarm>;
|
||||
|
||||
@InjectRepository(AlarmDispatch)
|
||||
dispatchRepo!: Repository<AlarmDispatch>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerRepo!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerRepo!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userRepo!: Repository<SysUser>;
|
||||
|
||||
/** 报警分页列表(仅 line_id 非空的记录)+ 统计 */
|
||||
@Get("/") async list(@Query() query: any) {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const rawPageSize = Number(query.pageSize) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
|
||||
|
||||
const where: any = { lineId: { [Op.ne]: null } };
|
||||
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
|
||||
if (query.alarmGrade !== undefined && query.alarmGrade !== "") where.alarmGrade = Number(query.alarmGrade);
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ lineName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ lineCode: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ deviceName: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
const { count, rows } = await this.alarmRepo.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["alarmTime", "DESC"]]
|
||||
});
|
||||
|
||||
// 统计:今日报警数(alarm_time >= 当天0点)/ 严重报警数(grade=1)/ 未派单数(无 status=0 派单)
|
||||
const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000);
|
||||
const today = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const todayStart = new Date(`${today}T00:00:00+08:00`);
|
||||
const [todayCount, gradeOneCount] = await Promise.all([
|
||||
this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmTime: { [Op.gte]: todayStart } } }),
|
||||
this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmGrade: 1 } })
|
||||
]);
|
||||
const dispatchedAlarms = await this.dispatchRepo.findAll({ where: { status: 0 }, attributes: ["alarmId"], raw: true });
|
||||
const dispatchedIds = [...new Set(dispatchedAlarms.map((r: any) => Number(r.alarmId)))];
|
||||
const undispatchedCount = await this.alarmRepo.count({
|
||||
where: {
|
||||
lineId: { [Op.ne]: null },
|
||||
id: dispatchedIds.length ? { [Op.notIn]: dispatchedIds } : { [Op.ne]: null }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取报警列表成功",
|
||||
data: {
|
||||
list: rows.map((r) => r.toJSON()),
|
||||
total: count,
|
||||
page,
|
||||
pageSize,
|
||||
summary: { todayCount, gradeOneCount, undispatchedCount }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 发起派单:校验报警/未完成派单/电工,生成派单号,写入派单并给电工发站内通知 */
|
||||
@Post("/:id/dispatch") async dispatch(@Param("id") id: string, @Body() body: any) {
|
||||
const alarmId = Number(id);
|
||||
if (!alarmId || Number.isNaN(alarmId)) return { code: 400, message: "缺少有效的报警 ID", data: null };
|
||||
|
||||
const alarm = await this.alarmRepo.findByPk(alarmId);
|
||||
if (!alarm) return { code: 404, message: "报警记录不存在", data: null };
|
||||
if (!alarm.lineId) return { code: 400, message: "该报警未关联线路,无法派单", data: null };
|
||||
|
||||
const existing = await this.dispatchRepo.findOne({ where: { alarmId, status: 0 } });
|
||||
if (existing) return { code: 400, message: "该报警已有未完成的派单", data: null };
|
||||
|
||||
const workerId = Number(body.workerId);
|
||||
if (!workerId || Number.isNaN(workerId)) return { code: 400, message: "缺少有效的 workerId 参数", data: null };
|
||||
const worker = await this.workerRepo.findByPk(workerId);
|
||||
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
|
||||
if (Number(worker.status) !== 1) return { code: 400, message: "该电工不在岗,无法派单", data: null };
|
||||
if (Number(worker.auditStatus) !== 1) return { code: 400, message: "该电工审核未通过,无法派单", data: null };
|
||||
|
||||
// 派发人:当前登录用户 realName(缺失时回退 username)
|
||||
const loginUserId = (this.ctx.state.user as any)?.userId;
|
||||
const loginUser = loginUserId ? await this.userRepo.findByPk(loginUserId, { attributes: ["realName", "username"] }) : null;
|
||||
const dispatcher = loginUser?.realName || (loginUser as any)?.username || "未知";
|
||||
|
||||
// 客户名称(通知文案用,查不到时回退客户 ID)
|
||||
let customerName = `#${alarm.customerId}`;
|
||||
if (alarm.customerId) {
|
||||
const customer = await this.customerRepo.findByPk(alarm.customerId, { attributes: ["customerName"] });
|
||||
if (customer) customerName = customer.customerName;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
alarmId: alarm.id,
|
||||
lineId: alarm.lineId,
|
||||
lineCode: alarm.lineCode,
|
||||
lineName: alarm.lineName,
|
||||
customerId: alarm.customerId,
|
||||
alarmTime: alarm.alarmTime,
|
||||
alarmGrade: alarm.alarmGrade,
|
||||
faultDesc: body.faultDesc || "",
|
||||
dispatcher,
|
||||
workerId: worker.id,
|
||||
status: 0
|
||||
};
|
||||
|
||||
// 派单号生成 + 写入(唯一冲突时重试一次)
|
||||
let dispatch: AlarmDispatch;
|
||||
try {
|
||||
payload.dispatchNo = await this.generateDispatchNo();
|
||||
dispatch = await this.dispatchRepo.create(payload);
|
||||
} catch (err: any) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
payload.dispatchNo = await this.generateDispatchNo();
|
||||
dispatch = await this.dispatchRepo.create(payload);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 派单站内通知(通知失败不得阻断派单)
|
||||
try {
|
||||
await this.noticeService.deliverToReceivers(
|
||||
{
|
||||
noticeType: "alarm_dispatch",
|
||||
title: `新报警派单:${alarm.lineName}`,
|
||||
content: `线路:${alarm.lineName}\n客户:${customerName}\n报警时间:${this.formatTime(alarm.alarmTime)}\n报警等级:${alarm.alarmGrade}级`,
|
||||
bizType: "alarm_dispatch",
|
||||
bizId: Number(dispatch.id)
|
||||
},
|
||||
{ workers: { mode: "ids", ids: [worker.id] } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[notice] 报警派单通知失败:", err);
|
||||
}
|
||||
|
||||
return { code: 200, message: "报警派单成功", data: dispatch.toJSON() };
|
||||
}
|
||||
|
||||
// 派单号:AD + 东八区 yyyyMMdd + 3 位当日序号(当天已创建数量 + 1,冲突则递增重试)
|
||||
private async generateDispatchNo(): Promise<string> {
|
||||
const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000);
|
||||
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const prefix = `AD${yyyymmdd}`;
|
||||
const dayStart = new Date(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}T00:00:00+08:00`);
|
||||
const count = await this.dispatchRepo.count({ where: { createdAt: { [Op.gte]: dayStart } } });
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const no = `${prefix}${String(count + i).padStart(3, "0")}`;
|
||||
const exists = await this.dispatchRepo.findOne({ where: { dispatchNo: no }, attributes: ["id"] });
|
||||
if (!exists) return no;
|
||||
}
|
||||
throw new Error("派单号生成冲突,请重试");
|
||||
}
|
||||
|
||||
private formatTime(t?: Date): string {
|
||||
if (!t) return "-";
|
||||
return new Date(t).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user