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,15 @@
|
||||
# Checklist
|
||||
|
||||
- [x] 数据库迁移 20261024_icc_event_alarm.sql 可执行,device_alarm/alarm_dispatch 结构与 spec 一致(字段、索引、唯一键),db_init.sql 同步
|
||||
- [x] 设计文档(表结构新增及变更.md / db_design.html)中 device_alarm、alarm_dispatch 字段已更新
|
||||
- [x] device_alarm.entity.ts / alarm_dispatch.entity.ts 与迁移字段一一对应,后端 tsc 通过
|
||||
- [x] 回调接口 POST /api/icc-event/callback:token 校验失败返回 401;合法 alarm 事件按 alarmCode 幂等 upsert;匹配 electric_line 填充 line_id/line_name/customer_id;未匹配记录 line_id 为空;返回 {"success":true,"code":"0"}
|
||||
- [x] ICC 订阅注册(POST subscribe/mqinfo)可手动触发,配置了 monitor URL 与 name/magic;保活定时任务 icc_event_alarm_keepalive 已注册(cron */30 * * * *,sys_job 种子)
|
||||
- [x] RBAC PERMISSION_MAP 与菜单/角色种子就位:设备报警监控(alarm:list)、报警派单台账与创建(alarm-dispatch:list / create)
|
||||
- [x] GET /api/device-alarms 列表+统计+筛选只返回匹配线路的报警;POST /api/device-alarms/:id/dispatch 生成唯一派单号、同报警未完成派单拒绝重复创建、校验电工在岗审核通过
|
||||
- [x] 报警派单创建后向电工发送 sys_notice 站内通知(receiver_type=worker)
|
||||
- [x] GET /api/alarm-dispatches 台账分页列表正常(联出电工姓名/客户名称)
|
||||
- [x] 前端设备报警监控页面:统计卡、筛选、报警表格、行内「发起报警派单」按钮与电工选择弹窗齐全,样式对齐项目勘察页
|
||||
- [x] 前端 30 秒轮询刷新;新出现 grade=1 报警时报警灯闪烁 + Web Audio 提示音,且去重不重复响
|
||||
- [x] 前端报警派单台账页面与路由就位,vue-tsc 通过
|
||||
- [x] 后端 tsc + 前端 vue-tsc 全绿;模拟回调端到端验证通过(入库→页面展示→报警灯/声音→发起派单→站内通知→台账可见)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Checklist
|
||||
|
||||
* [x] 生产库 electricity\_data 现状已核对(有无表/字段/分区/数据),迁移方案据此确定
|
||||
|
||||
* [x] 迁移 `20261024_icc_analog_data.sql` 已执行到生产库:字段集(id/line\_id/line\_code/imei/line\_name/customer\_id/analog\_type/analog\_name/analog\_unit/analog\_value/collect\_time,**无 equip\_id**)正确,唯一键 (line\_code, analog\_type, collect\_time)、按月分区(2026\_10\~2027\_03)、索引(customer/line/time)就绪
|
||||
|
||||
* [x] db\_init.sql 中 electricity\_data 基线定义与迁移一致
|
||||
|
||||
* [x] 后端实体 `electricity_data.entity.ts` 字段与迁移一致,映射 `icc_analog_mapper.ts` 按实际返回字段正确映射(line\_code/imei/analog\_type/analog\_name/analog\_unit/analog\_value)
|
||||
|
||||
* [x] 同步服务 `icc_analog_sync.service.ts` 复用 OAuth2 鉴权,先读已同步线路 line\_code,分批 deviceCodes(≤50)POST 模拟量接口 pageSize=500 短页停止,collect\_time 用同步时间,按 (line\_code, analog\_type, collect\_time) 幂等 upsert,写 icc\_sync\_log(sync\_type=electricity\_data),失败仅记日志不抛
|
||||
|
||||
* [x] `POST /api/electricity-data/sync` 接口可调用并返回 {pulled, inserted, updated, skipped, synced},RBAC 路由 `/api/electricity-data` → device:list 生效
|
||||
|
||||
* [x] 定时任务 `icc_analog_sync`(每 5 分钟 `*/5 * * * *`)已注册:job\_registry BUILTIN\_JOBS + JobRuntime.syncIccAnalogs + db\_seed.sql sys\_job 种子幂等
|
||||
|
||||
* [x] 设计文档「表结构新增及变更.md」已补充 electricity\_data 新表结构及同步服务/定时任务说明
|
||||
|
||||
* [x] 验证通过:tsc 编译无错误;真实同步拉取到模拟量数据入库(74 行/快照,analog\_name/analog\_unit 填充正确);幂等重跑正常(二次快照 148 行);无前端改动
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: 数据库迁移与设计文档同步
|
||||
- [x] 1.1 新建 database/migrations/20261024_icc_event_alarm.sql:DROP 重建 device_alarm(按 ICC 事件结构:alarm_code UNIQUE、uuid、line_id/line_code/line_name/customer_id、node_code/device_code/device_name、org_code/org_name、alarm_type/alarm_grade/alarm_stat/is_event、alarm_time TIMESTAMPTZ、alarm_value NUMERIC(14,4) 可空、synced_at;索引 idx_alarm_time/idx_alarm_line/idx_alarm_customer/idx_alarm_grade)与 alarm_dispatch(dispatch_no UNIQUE、alarm_id→device_alarm、line_id/line_code/line_name、customer_id→customer、alarm_time/alarm_grade 快照、dispatcher、worker_id、status 0已派发 1已处理、fault_desc、created_at/completed_at;索引 idx_alarm_disp_status/idx_alarm_disp_worker/idx_alarm_disp_alarm)
|
||||
- [x] 1.2 同步 database/db_init.sql 中对应两表 DDL(与迁移保持一致)
|
||||
- [x] 1.3 更新 系统设计/表结构新增及变更.md(device_alarm/alarm_dispatch 小节)与 db_design.html 中相关表结构(字段级)
|
||||
- [x] 1.4 执行迁移到生产库验证(表为空,安全重建;用 server/node_modules/pg 脚本)
|
||||
|
||||
- [x] Task 2: 后端实体
|
||||
- [x] 2.1 server/src/entity/device_alarm.entity.ts(字段与迁移一致,line_id/customer_id 建 FK/BelongsTo 可选)
|
||||
- [x] 2.2 server/src/entity/alarm_dispatch.entity.ts(含 BelongsTo device_alarm、Customer;worker_id 仅存 ID)
|
||||
- [x] 2.3 `npx tsc --noEmit` 通过
|
||||
|
||||
- [x] Task 3: ICC 事件订阅服务与回调接收
|
||||
- [x] 3.1 server/src/service/icc_event_subscribe.service.ts:复用 getIccAccessToken/iccFetch(icc_customer_auth.ts),实现 registerSubscribe()(POST subscribe/mqinfo,category=alarm、订阅全部类型/权限、monitor=配置的回调 URL 带 token、subsystem name/magic=配置默认 `222.243.55.42_7001_alarm`)与 ensureSubscribe()
|
||||
- [x] 3.2 server/src/controller/icc_event.controller.ts:POST /api/icc-event/callback(无鉴权,校验 URL token 与配置一致,解析通用事件格式,仅处理 category=alarm;按 alarmCode upsert 到 device_alarm,匹配 electric_line.line_code 填充 line_id/line_name/customer_id;返回 {"success":true,"code":"0"});另提供受 RBAC 保护的 POST /api/icc-event/subscribe(手动触发注册)与 GET /api/icc-event/subscribe-status
|
||||
- [x] 3.3 幂等处理:同 uuid/alarmCode 重复推送不重复计数;alarm_stat 变化更新原记录
|
||||
- [x] 3.4 回调 token 与 monitor URL 从配置读取(config.default.ts 加 ICC_EVENT 配置项)
|
||||
|
||||
- [x] Task 4: 保活定时任务、RBAC 与种子
|
||||
- [x] 4.1 server/src/task/icc_event_keepalive.task.ts(defaultCron `*/30 * * * *`,调用 ensureSubscribe),注册到 job_registry.ts 与 job_scheduler.service.ts(syncIccEvents)
|
||||
- [x] 4.2 rbac.middleware.ts PERMISSION_MAP 新增:`/api/icc-event/subscribe: device:list`、`/api/device-alarms: alarm:list`、`/api/alarm-dispatches: alarm-dispatch:list`(create 权限 `alarm-dispatch:create`)
|
||||
- [x] 4.3 database/db_seed.sql:新增 sys_job(icc_event_alarm_keepalive,cron `*/30 * * * *`)、sys_menu(设备监控→设备报警监控 /api/device/alarm;运营派单→报警派单台账 /api/dispatch/alarm)与 sys_role_menu(ADMIN 角色)、sys_permission 相关种子(若权限为表驱动则同步)
|
||||
|
||||
- [x] Task 5: 报警查询与报警派单后端接口
|
||||
- [x] 5.1 server/src/controller/device_alarm.controller.ts:GET /api/device-alarms(分页列表+统计:今日/严重/未派单;筛选客户/等级/关键词;只展示 line_id 非空的记录)+ POST /api/device-alarms/:id/dispatch(创建 alarm_dispatch:生成唯一 dispatch_no、快照报警/线路/客户、dispatcher=当前用户 realName、worker_id 校验在岗+审核通过;同一报警已有未完成派单则拒绝)
|
||||
- [x] 5.2 派单后通过 NoticeService.deliverToReceivers(参照 survey_order.service 的派单通知埋点)向 worker 发站内通知(notice_type=alarm_dispatch,receiver_type=worker)
|
||||
- [x] 5.3 server/src/controller/alarm_dispatch.controller.ts:GET /api/alarm-dispatches(台账分页列表,筛选状态/关键词,联出电工姓名/客户名称)
|
||||
- [x] 5.4 `npx tsc --noEmit` 通过
|
||||
|
||||
- [x] Task 6: 前端-设备报警监控页面
|
||||
- [x] 6.1 client/src/api/device.ts 或新增 api/alarm.ts:deviceAlarmApi.getList/dispatch、alarmDispatchApi.getList、iccEventApi.subscribe/status 类型与封装
|
||||
- [x] 6.2 client/src/views/device/alarm/index.vue(样式对齐 views/dispatch/survey/index.vue):统计卡(今日/严重/未派单)+ 筛选区(客户/等级/关键词)+ 报警表格(报警时间/线路名称/线路编码/客户/类型/等级 tag/状态)+ 行内「发起报警派单」按钮 + 派单弹窗(共享电工下拉,取 worker 在岗审核通过列表)
|
||||
- [x] 6.3 严重报警提示:页面挂载轮询(30s 刷新列表);轮询发现新 grade=1 报警时右上角报警灯闪烁(全局按钮/角标)并播放提示音(Web Audio 蜂鸣 3 次);按「已提示的报警 id 集合」去重
|
||||
- [x] 6.4 路由注册(client/src/router)+ 菜单由后端 sys_menu 动态渲染(自动生效)
|
||||
|
||||
- [x] Task 7: 前端-报警派单台账页面
|
||||
- [x] 7.1 client/src/views/dispatch/alarm/index.vue:台账列表(派单号/报警时间/线路/客户/指派电工/状态 tag/创建时间)+ 筛选(状态/关键词)
|
||||
- [x] 7.2 路由注册(运营派单分组下)
|
||||
- [x] 7.3 `npm run check`(vue-tsc)通过
|
||||
|
||||
- [x] Task 8: 验证
|
||||
- [x] 8.1 后端 `npx tsc --noEmit`、前端 `npm run check` 全绿
|
||||
- [x] 8.2 接口自测:手工构造 ICC 通用事件 JSON POST 回调接口验证入库/幂等/token 校验/未匹配线路落空记录;subscribe 注册接口调用成功
|
||||
- [x] 8.3 浏览器端到端:登录进入「设备报警监控」,触发一条模拟回调入库后页面可见;严重报警出现报警灯+声音;发起报警派单→选择电工→生成派单→电工收到站内通知;「报警派单台账」显示该派单
|
||||
- [x] 8.4 若生产订阅真实生效受限(ICC 无法访问回调地址),以模拟回调验证全链路并在交付说明中注明
|
||||
|
||||
# Task Dependencies
|
||||
- Task 2 依赖 Task 1(表结构确定)
|
||||
- Task 3 依赖 Task 2(实体)
|
||||
- Task 4 依赖 Task 3(订阅服务)
|
||||
- Task 5 依赖 Task 2(实体)与 Task 3(回调入库可测)
|
||||
- Task 6 依赖 Task 5(接口)
|
||||
- Task 7 依赖 Task 5.3(台账接口)
|
||||
- Task 8 依赖全部
|
||||
@@ -0,0 +1,136 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// ==================== 设备报警 ====================
|
||||
|
||||
export interface DeviceAlarmRow {
|
||||
id: number;
|
||||
alarmCode?: string;
|
||||
uuid?: string;
|
||||
lineId?: number;
|
||||
lineCode?: string;
|
||||
lineName?: string;
|
||||
customerId?: number;
|
||||
nodeCode?: string;
|
||||
deviceCode?: string;
|
||||
deviceName?: string;
|
||||
orgCode?: string;
|
||||
orgName?: string;
|
||||
alarmType?: number | string;
|
||||
// 报警等级:1 严重 / 2 一般 / 3 提示
|
||||
alarmGrade?: number;
|
||||
alarmStat?: number;
|
||||
// 是否事件(1 事件 / 0 报警)
|
||||
isEvent?: number;
|
||||
alarmTime?: string;
|
||||
alarmValue?: string;
|
||||
syncedAt?: string;
|
||||
// 联表补全字段(存在性不确定,使用时防御性取用)
|
||||
customerName?: string;
|
||||
}
|
||||
|
||||
export interface DeviceAlarmSummary {
|
||||
todayCount: number;
|
||||
gradeOneCount: number;
|
||||
undispatchedCount: number;
|
||||
}
|
||||
|
||||
export interface DeviceAlarmListResult {
|
||||
list: DeviceAlarmRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary?: DeviceAlarmSummary;
|
||||
}
|
||||
|
||||
export interface DeviceAlarmQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
customerId?: number;
|
||||
alarmGrade?: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface DeviceAlarmDispatchParams {
|
||||
workerId: number;
|
||||
faultDesc?: string;
|
||||
}
|
||||
|
||||
export interface DeviceAlarmDispatchResult {
|
||||
id?: number;
|
||||
alarmId?: number;
|
||||
dispatchNo?: string;
|
||||
workerId?: number;
|
||||
workerName?: string;
|
||||
status?: number;
|
||||
faultDesc?: string;
|
||||
dispatchedAt?: string;
|
||||
}
|
||||
|
||||
export const deviceAlarmApi = {
|
||||
getList: (params: DeviceAlarmQuery) =>
|
||||
request.get<DeviceAlarmListResult>('/device-alarms', { params }),
|
||||
|
||||
dispatch: (id: number, body: DeviceAlarmDispatchParams) =>
|
||||
request.post<DeviceAlarmDispatchResult>(`/device-alarms/${id}/dispatch`, body),
|
||||
};
|
||||
|
||||
// ==================== 报警派单台账(Task7 使用) ====================
|
||||
|
||||
export interface AlarmDispatchRow {
|
||||
id: number;
|
||||
alarmId?: number;
|
||||
dispatchNo?: string;
|
||||
lineId?: number;
|
||||
lineCode?: string;
|
||||
lineName?: string;
|
||||
customerId?: number;
|
||||
customerName?: string;
|
||||
alarmTime?: string;
|
||||
// 报警等级:1 严重 / 2 一般 / 3 提示
|
||||
alarmGrade?: number;
|
||||
faultDesc?: string;
|
||||
dispatcher?: string;
|
||||
workerId?: number;
|
||||
workerName?: string;
|
||||
// 状态:0 已派发 / 1 已处理
|
||||
status?: number;
|
||||
dispatchedAt?: string;
|
||||
createdAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AlarmDispatchQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: number | string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface AlarmDispatchListResult {
|
||||
list: AlarmDispatchRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const alarmDispatchApi = {
|
||||
getList: (params: AlarmDispatchQuery) =>
|
||||
request.get<AlarmDispatchListResult>('/alarm-dispatches', { params }),
|
||||
};
|
||||
|
||||
// ==================== ICC 事件订阅(本页订阅状态提示,可选) ====================
|
||||
|
||||
export interface IccEventStatus {
|
||||
name?: string;
|
||||
monitorUrl?: string;
|
||||
magic?: string;
|
||||
status?: string;
|
||||
registerResult?: string;
|
||||
registeredAt?: string;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export const iccEventApi = {
|
||||
subscribe: () => request.post('/icc-event/subscribe'),
|
||||
status: () => request.get<IccEventStatus>('/icc-event/subscribe-status'),
|
||||
};
|
||||
@@ -175,6 +175,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/dispatch/maintenance/index.vue"),
|
||||
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
|
||||
},
|
||||
{
|
||||
path: "dispatch/alarm",
|
||||
name: "AlarmDispatchLedger",
|
||||
component: () => import("@/views/dispatch/alarm/index.vue"),
|
||||
meta: { requiresAuth: true, title: "报警派单台账", permission: "alarm-dispatch:list" }
|
||||
},
|
||||
{
|
||||
path: "device",
|
||||
name: "Device",
|
||||
@@ -199,6 +205,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/device/gateway/index.vue"),
|
||||
meta: { requiresAuth: true, title: "智电改造网关台账", permission: "device:list" }
|
||||
},
|
||||
{
|
||||
path: "device/alarm",
|
||||
name: "DeviceAlarm",
|
||||
component: () => import("@/views/device/alarm/index.vue"),
|
||||
meta: { requiresAuth: true, title: "设备报警监控", permission: "device:list" }
|
||||
},
|
||||
{
|
||||
path: "base",
|
||||
name: "Base",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div class="alarm-dispatch-page">
|
||||
<!-- 顶部标题 -->
|
||||
<div class="page-header">
|
||||
<div class="breadcrumb">
|
||||
<span>运营管控体系</span>
|
||||
<span class="sep">/</span>
|
||||
<span>运营派单</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="cur">报警派单台账</span>
|
||||
</div>
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="header-row">
|
||||
<span class="title-bar"></span>
|
||||
<span class="main-title">报警派单台账</span>
|
||||
<span class="sub-title">ALARM DISPATCH LEDGER</span>
|
||||
</div>
|
||||
<p class="desc">统一管理报警派单全过程台账,支持按派单状态检索,跟踪派发、处理与完成情况。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体卡片 -->
|
||||
<div class="content-card">
|
||||
<!-- 筛选区 -->
|
||||
<div class="filter-card">
|
||||
<div class="filter-grid">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">派单状态</label>
|
||||
<a-select
|
||||
v-model:value="searchForm.status"
|
||||
placeholder="全部状态"
|
||||
allow-clear
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">关键词检索</label>
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="派单号 / 线路名称 / 线路编码"
|
||||
allow-clear
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bottom">
|
||||
<div class="filter-actions">
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表区 -->
|
||||
<div class="list-section">
|
||||
<div class="list-header">
|
||||
<div class="list-title">
|
||||
<span class="dot"></span>
|
||||
<span class="t">报警派单数据台账</span>
|
||||
<span class="count">{{ total }} 条</span>
|
||||
</div>
|
||||
<div class="list-actions">
|
||||
<a-button type="text" size="small" title="刷新列表" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper" ref="wrapperRef">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content', y: tableHeight }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dispatchNo'">
|
||||
<span class="mono dispatch-no">{{ record.dispatchNo || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'alarmTime'">
|
||||
<span>{{ formatTime(record.alarmTime) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lineName'">
|
||||
<span class="line-name">{{ record.lineName || record.lineCode || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lineCode'">
|
||||
<span class="mono">{{ record.lineCode || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'customerId'">
|
||||
{{ record.customerName || record.customerId || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'workerName'">
|
||||
<span>{{ record.workerName || '未指派' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'alarmGrade'">
|
||||
<a-tag :color="gradeMeta(record.alarmGrade).color">
|
||||
{{ gradeMeta(record.alarmGrade).label }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusMeta(record.status).color">
|
||||
{{ statusMeta(record.status).label }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'dispatcher'">
|
||||
<span>{{ record.dispatcher || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
<span>{{ formatTime(record.createdAt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<a-empty description="暂无报警派单数据" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { alarmDispatchApi, type AlarmDispatchRow, type AlarmDispatchQuery } from '@/api/alarm';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
|
||||
// ==================== 列表数据 ====================
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<AlarmDispatchRow[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const searchForm = reactive<AlarmDispatchQuery>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
status: undefined as any,
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '已派发', value: 0 },
|
||||
{ label: '已处理', value: 1 },
|
||||
];
|
||||
|
||||
const STATUS_META: Record<number, { label: string; color: string }> = {
|
||||
0: { label: '已派发', color: 'processing' },
|
||||
1: { label: '已处理', color: 'success' },
|
||||
};
|
||||
|
||||
function statusMeta(v?: number) {
|
||||
return STATUS_META[v ?? -1] || { label: '未知', color: 'default' };
|
||||
}
|
||||
|
||||
const GRADE_META: Record<number, { label: string; color: string }> = {
|
||||
1: { label: '严重', color: 'error' },
|
||||
2: { label: '一般', color: 'warning' },
|
||||
3: { label: '提示', color: 'default' },
|
||||
};
|
||||
|
||||
function gradeMeta(v?: number) {
|
||||
return GRADE_META[v ?? 0] || { label: '未知', color: 'default' };
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '派单号', dataIndex: 'dispatchNo', key: 'dispatchNo', ellipsis: true, width: 160 },
|
||||
{ title: '报警时间', dataIndex: 'alarmTime', key: 'alarmTime', ellipsis: true, width: 170 },
|
||||
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
|
||||
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 150 },
|
||||
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||
{ title: '指派电工', dataIndex: 'workerName', key: 'workerName', ellipsis: true, width: 120 },
|
||||
{ title: '报警等级', dataIndex: 'alarmGrade', key: 'alarmGrade', align: 'center' as const, width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 100 },
|
||||
{ title: '派发人', dataIndex: 'dispatcher', key: 'dispatcher', ellipsis: true, width: 120 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 170 },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: searchForm.page,
|
||||
pageSize: searchForm.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||
}));
|
||||
|
||||
// ==================== 数据请求 ====================
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await alarmDispatchApi.getList(searchForm);
|
||||
dataSource.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
// ==================== 筛选与分页 ====================
|
||||
function handleSearch() {
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = '';
|
||||
searchForm.status = undefined as any;
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
searchForm.page = pag.current;
|
||||
searchForm.pageSize = pag.pageSize;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
// ==================== 展示辅助 ====================
|
||||
function formatTime(val?: string) {
|
||||
if (!val) return '-';
|
||||
const d = new Date(val);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alarm-dispatch-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #f4f7fb;
|
||||
padding: 20px 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.page-header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breadcrumb .sep {
|
||||
color: #c2c6d8;
|
||||
}
|
||||
|
||||
.breadcrumb .cur {
|
||||
color: #0054cd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 5px;
|
||||
height: 22px;
|
||||
background: #0054cd;
|
||||
border-radius: 3px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #0054cd;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
|
||||
/* 主体卡片 */
|
||||
.content-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
|
||||
padding: 16px 18px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 筛选区 */
|
||||
.filter-card {
|
||||
flex-shrink: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(220px, 2fr);
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
|
||||
.filter-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 列表区 */
|
||||
.list-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px 12px;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-title .dot {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: #0054cd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.list-title .t {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
.list-title .count {
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
background: #eef4ff;
|
||||
color: #0054cd;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 单元格 */
|
||||
.mono {
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
.dispatch-no {
|
||||
color: #0054cd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.line-name {
|
||||
font-weight: 600;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
/* AntD 覆盖 */
|
||||
:deep(.ant-table) {
|
||||
font-size: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #f8fafc;
|
||||
color: #5b6b7e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #0054cd;
|
||||
border-color: #0054cd;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:not(:disabled):hover),
|
||||
:deep(.ant-btn-primary:not(:disabled):focus) {
|
||||
background: #136bfb;
|
||||
border-color: #136bfb;
|
||||
}
|
||||
</style>
|
||||
+44
-87
@@ -921,101 +921,58 @@ COMMENT ON COLUMN fault_type_detail.remark IS '备注';
|
||||
COMMENT ON COLUMN fault_type_detail.created_at IS '创建时间';
|
||||
CREATE INDEX idx_ftd_type ON fault_type_detail (fault_type_id);
|
||||
|
||||
-- 27. 设备报警信息表(ICC 拉取)
|
||||
-- 27. 设备报警信息表(ICC 事件订阅)
|
||||
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
|
||||
DROP TABLE IF EXISTS device_alarm CASCADE;
|
||||
CREATE TABLE device_alarm (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
alarm_code VARCHAR(128) NOT NULL UNIQUE,
|
||||
line_id BIGINT,
|
||||
equip_id BIGINT,
|
||||
icc_device_code VARCHAR(128),
|
||||
line_name VARCHAR(128),
|
||||
customer_id BIGINT,
|
||||
building_id BIGINT,
|
||||
floor_no VARCHAR(16),
|
||||
location VARCHAR(256),
|
||||
alarm_date DATE,
|
||||
fault_type VARCHAR(128),
|
||||
alarm_value NUMERIC(14,2),
|
||||
history_count INT DEFAULT 0,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键)
|
||||
uuid VARCHAR(128), -- ICC 事件 uuid
|
||||
line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)
|
||||
line_code VARCHAR(64), -- 线路编码(electric_line.line_code)
|
||||
line_name VARCHAR(128), -- 线路名称
|
||||
customer_id BIGINT, -- 客户 ID(由 line_id 联出)
|
||||
node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode)
|
||||
device_code VARCHAR(64), -- 设备编码(info.deviceCode)
|
||||
device_name VARCHAR(128), -- 设备名称(info.deviceName)
|
||||
org_code VARCHAR(64), -- 组织编码(info.orgCode)
|
||||
org_name VARCHAR(128), -- 组织名称(info.orgName)
|
||||
alarm_type INT, -- 报警类型(info.alarmType)
|
||||
alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重)
|
||||
alarm_stat SMALLINT, -- 报警状态(info.alarmStat)
|
||||
is_event BOOLEAN DEFAULT FALSE, -- 是否事件(info.isEvent)
|
||||
alarm_time TIMESTAMPTZ, -- 报警发生时间(info.alarmDate 秒级时间戳)
|
||||
alarm_value NUMERIC(14,4), -- 报警数值(部分事件带值,可空)
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 入库时间
|
||||
);
|
||||
COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 拉取)';
|
||||
COMMENT ON COLUMN device_alarm.id IS '自增主键';
|
||||
COMMENT ON COLUMN device_alarm.alarm_code IS '报警编码(唯一)';
|
||||
COMMENT ON COLUMN device_alarm.line_id IS '线路 ID';
|
||||
COMMENT ON COLUMN device_alarm.equip_id IS '客户设备 ID';
|
||||
COMMENT ON COLUMN device_alarm.icc_device_code IS 'ICC 设备编号';
|
||||
COMMENT ON COLUMN device_alarm.line_name IS '线路名称';
|
||||
COMMENT ON COLUMN device_alarm.customer_id IS '客户 ID';
|
||||
COMMENT ON COLUMN device_alarm.building_id IS '所属建筑 ID';
|
||||
COMMENT ON COLUMN device_alarm.floor_no IS '所在楼层';
|
||||
COMMENT ON COLUMN device_alarm.location IS '所在位置';
|
||||
COMMENT ON COLUMN device_alarm.alarm_date IS '报警日期';
|
||||
COMMENT ON COLUMN device_alarm.fault_type IS 'ICC 预警类型';
|
||||
COMMENT ON COLUMN device_alarm.alarm_value IS '报警数值';
|
||||
COMMENT ON COLUMN device_alarm.history_count IS '历史出现次数';
|
||||
COMMENT ON COLUMN device_alarm.synced_at IS 'ICC 同步时间';
|
||||
CREATE INDEX idx_alarm_customer ON device_alarm (customer_id);
|
||||
CREATE INDEX idx_alarm_date ON device_alarm (alarm_date);
|
||||
COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)';
|
||||
CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC);
|
||||
CREATE INDEX idx_alarm_line ON device_alarm (line_id);
|
||||
CREATE INDEX idx_alarm_customer ON device_alarm (customer_id);
|
||||
CREATE INDEX idx_alarm_grade ON device_alarm (alarm_grade);
|
||||
|
||||
-- 28. 报警派单信息表
|
||||
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
|
||||
CREATE TABLE alarm_dispatch (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dispatch_no VARCHAR(64) NOT NULL UNIQUE,
|
||||
alarm_id BIGINT,
|
||||
line_id BIGINT,
|
||||
equip_id BIGINT,
|
||||
icc_device_code VARCHAR(128),
|
||||
line_name VARCHAR(128),
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
contact_person VARCHAR(64),
|
||||
contact_phone VARCHAR(20),
|
||||
building_id BIGINT,
|
||||
floor_no VARCHAR(16),
|
||||
location VARCHAR(256),
|
||||
fault_type_id BIGINT REFERENCES fault_type(id),
|
||||
fault_desc TEXT,
|
||||
dispatcher VARCHAR(64),
|
||||
fault_reason TEXT,
|
||||
process_flow TEXT,
|
||||
worker_id BIGINT,
|
||||
status SMALLINT NOT NULL DEFAULT 0,
|
||||
process_photos JSONB,
|
||||
rating SMALLINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现)
|
||||
alarm_id BIGINT, -- 报警 ID(→ device_alarm)
|
||||
line_id BIGINT, -- 线路 ID
|
||||
line_code VARCHAR(64), -- 线路编码
|
||||
line_name VARCHAR(128), -- 线路名称
|
||||
customer_id BIGINT REFERENCES customer(id), -- 客户 ID
|
||||
alarm_time TIMESTAMPTZ, -- 报警时间(快照)
|
||||
alarm_grade SMALLINT, -- 报警等级(快照)
|
||||
fault_desc TEXT, -- 故障事项(报警类型描述)
|
||||
dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName)
|
||||
worker_id BIGINT, -- 共享电工 ID(→ worker)
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
|
||||
COMMENT ON COLUMN alarm_dispatch.id IS '自增主键';
|
||||
COMMENT ON COLUMN alarm_dispatch.dispatch_no IS '报警派单号(唯一)';
|
||||
COMMENT ON COLUMN alarm_dispatch.alarm_id IS '报警 ID(→ device_alarm)';
|
||||
COMMENT ON COLUMN alarm_dispatch.line_id IS '线路 ID';
|
||||
COMMENT ON COLUMN alarm_dispatch.equip_id IS '客户设备 ID';
|
||||
COMMENT ON COLUMN alarm_dispatch.icc_device_code IS 'ICC 设备编号';
|
||||
COMMENT ON COLUMN alarm_dispatch.line_name IS '线路名称';
|
||||
COMMENT ON COLUMN alarm_dispatch.customer_id IS '客户 ID(→ customer)';
|
||||
COMMENT ON COLUMN alarm_dispatch.contact_person IS '联系人';
|
||||
COMMENT ON COLUMN alarm_dispatch.contact_phone IS '电话';
|
||||
COMMENT ON COLUMN alarm_dispatch.building_id IS '建筑 ID';
|
||||
COMMENT ON COLUMN alarm_dispatch.floor_no IS '所在楼层';
|
||||
COMMENT ON COLUMN alarm_dispatch.location IS '所在位置';
|
||||
COMMENT ON COLUMN alarm_dispatch.fault_type_id IS '故障类型 ID(→ fault_type)';
|
||||
COMMENT ON COLUMN alarm_dispatch.fault_desc IS '故障事项(类型+数值)';
|
||||
COMMENT ON COLUMN alarm_dispatch.dispatcher IS '派发人';
|
||||
COMMENT ON COLUMN alarm_dispatch.fault_reason IS '故障原因(来自明细)';
|
||||
COMMENT ON COLUMN alarm_dispatch.process_flow IS '处理流程(来自明细)';
|
||||
COMMENT ON COLUMN alarm_dispatch.worker_id IS '共享电工 ID';
|
||||
COMMENT ON COLUMN alarm_dispatch.status IS '状态:0已派发 1已处理';
|
||||
COMMENT ON COLUMN alarm_dispatch.process_photos IS '报警处理照片(JSONB 多张)';
|
||||
COMMENT ON COLUMN alarm_dispatch.rating IS '派单处理评分';
|
||||
COMMENT ON COLUMN alarm_dispatch.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN alarm_dispatch.completed_at IS '完成时间';
|
||||
CREATE INDEX idx_alarm_disp_customer ON alarm_dispatch (customer_id);
|
||||
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
|
||||
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
|
||||
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
|
||||
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
|
||||
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
|
||||
CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 八、报修域(2 张表)
|
||||
|
||||
@@ -538,6 +538,44 @@ ON CONFLICT (job_key) DO UPDATE SET
|
||||
job_name = EXCLUDED.job_name,
|
||||
remark = EXCLUDED.remark;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 12. ICC 事件订阅保活任务种子(sys_job,按 job_key 幂等;默认每 30 分钟)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||
('icc_event_alarm_keepalive', 'ICC事件订阅保活', '*/30 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||
'向 ICC 平台重新注册报警事件回调订阅(同名覆盖保活,默认每 30 分钟)')
|
||||
ON CONFLICT (job_key) DO UPDATE SET
|
||||
cron_expression = EXCLUDED.cron_expression,
|
||||
job_name = EXCLUDED.job_name,
|
||||
remark = EXCLUDED.remark;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 12a. 报警域菜单(设备监控下「设备报警监控」/ 运营派单下「报警派单台账」,id:63-64)
|
||||
-- 63 挂设备监控 parent_id=4(sort 4,排在网关台账之后)
|
||||
-- 64 挂运营派单 parent_id=3(sort 6,排在维保派单之后)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(63, 4, '设备报警监控', '/device/alarm', 'device/alarm/index', 'AlertOutlined', 4, 2, 'alarm:list', 1),
|
||||
(64, 3, '报警派单台账', '/dispatch/alarm', 'dispatch/alarm/index', 'ScheduleOutlined', 6, 2, 'alarm-dispatch:list', 1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
menu_path = EXCLUDED.menu_path,
|
||||
component = EXCLUDED.component,
|
||||
icon = EXCLUDED.icon,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
menu_type = EXCLUDED.menu_type,
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 63-64
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 63), (1, 64)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列(覆盖到 64)
|
||||
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 64), true);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- ICC 报警事件订阅:按 ICC 平台事件订阅推送的通用事件格式重建 device_alarm / alarm_dispatch
|
||||
-- 两表原为旧设计(equip_id/building_id/floor_no/location/history_count/fault_type/alarm_date 等与 ICC 事件结构不符),现为空表,DROP 重建
|
||||
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
|
||||
DROP TABLE IF EXISTS device_alarm CASCADE;
|
||||
CREATE TABLE device_alarm (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键)
|
||||
uuid VARCHAR(128), -- ICC 事件 uuid
|
||||
line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)
|
||||
line_code VARCHAR(64), -- 线路编码(electric_line.line_code)
|
||||
line_name VARCHAR(128), -- 线路名称
|
||||
customer_id BIGINT, -- 客户 ID(由 line_id 联出)
|
||||
node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode)
|
||||
device_code VARCHAR(64), -- 设备编码(info.deviceCode)
|
||||
device_name VARCHAR(128), -- 设备名称(info.deviceName)
|
||||
org_code VARCHAR(64), -- 组织编码(info.orgCode)
|
||||
org_name VARCHAR(128), -- 组织名称(info.orgName)
|
||||
alarm_type INT, -- 报警类型(info.alarmType)
|
||||
alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重)
|
||||
alarm_stat SMALLINT, -- 报警状态(info.alarmStat)
|
||||
is_event BOOLEAN DEFAULT FALSE, -- 是否事件(info.isEvent)
|
||||
alarm_time TIMESTAMPTZ, -- 报警发生时间(info.alarmDate 秒级时间戳)
|
||||
alarm_value NUMERIC(14,4), -- 报警数值(部分事件带值,可空)
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 入库时间
|
||||
);
|
||||
COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)';
|
||||
CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC);
|
||||
CREATE INDEX idx_alarm_line ON device_alarm (line_id);
|
||||
CREATE INDEX idx_alarm_customer ON device_alarm (customer_id);
|
||||
CREATE INDEX idx_alarm_grade ON device_alarm (alarm_grade);
|
||||
|
||||
CREATE TABLE alarm_dispatch (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现)
|
||||
alarm_id BIGINT, -- 报警 ID(→ device_alarm)
|
||||
line_id BIGINT, -- 线路 ID
|
||||
line_code VARCHAR(64), -- 线路编码
|
||||
line_name VARCHAR(128), -- 线路名称
|
||||
customer_id BIGINT REFERENCES customer(id), -- 客户 ID
|
||||
alarm_time TIMESTAMPTZ, -- 报警时间(快照)
|
||||
alarm_grade SMALLINT, -- 报警等级(快照)
|
||||
fault_desc TEXT, -- 故障事项(报警类型描述)
|
||||
dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName)
|
||||
worker_id BIGINT, -- 共享电工 ID(→ worker)
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
|
||||
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
|
||||
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
|
||||
CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id);
|
||||
@@ -112,6 +112,14 @@ export default {
|
||||
keyPrefix: "customer-visit"
|
||||
},
|
||||
|
||||
// ICC 事件订阅配置(回调地址需 ICC 服务器可访问;订阅保活每 30 分钟向 ICC 重新注册)
|
||||
iccEvent: {
|
||||
monitorUrl: "http://222.243.55.42:7001/api/icc-event/callback", // 回调地址(ICC 服务器需可访问)
|
||||
callbackToken: "xtzd_icc_event_2026", // 回调 URL 固定 token
|
||||
subscriberName: "222.243.55.42_7001_alarm", // 订阅者唯一名
|
||||
subscriberMagic: "222.243.55.42_7001" // IP_端口
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: "*",
|
||||
allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Controller, Get, Inject, Query } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import AlarmDispatch from "../entity/alarm_dispatch.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
|
||||
@Controller("/api/alarm-dispatches")
|
||||
export class AlarmDispatchController {
|
||||
@Inject() ctx!: Context;
|
||||
|
||||
@InjectRepository(AlarmDispatch)
|
||||
dispatchRepo!: Repository<AlarmDispatch>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerRepo!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(Customer)
|
||||
customerRepo!: Repository<Customer>;
|
||||
|
||||
/** 报警派单台账分页列表(联出电工姓名/客户名称) */
|
||||
@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 = {};
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
if (query.keyword) {
|
||||
const kw = String(query.keyword).trim();
|
||||
const ors: any[] = [
|
||||
{ dispatchNo: { [Op.like]: `%${kw}%` } },
|
||||
{ lineName: { [Op.like]: `%${kw}%` } },
|
||||
{ lineCode: { [Op.like]: `%${kw}%` } }
|
||||
];
|
||||
// 关键字命中电工姓名时联出匹配的电工 ID 参与过滤
|
||||
const workers = await this.workerRepo.findAll({ where: { workerName: { [Op.like]: `%${kw}%` } }, attributes: ["id"] });
|
||||
if (workers.length) ors.push({ workerId: { [Op.in]: workers.map((w) => w.id) } });
|
||||
where[Op.or] = ors;
|
||||
}
|
||||
|
||||
const { count, rows } = await this.dispatchRepo.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["createdAt", "DESC"]]
|
||||
});
|
||||
|
||||
// 联出电工姓名与客户名称(二次查询组装)
|
||||
const workerIds = [...new Set(rows.map((r) => Number(r.workerId)).filter(Boolean))];
|
||||
const customerIds = [...new Set(rows.map((r) => Number(r.customerId)).filter(Boolean))];
|
||||
const [workers, customers] = await Promise.all([
|
||||
workerIds.length ? this.workerRepo.findAll({ where: { id: { [Op.in]: workerIds } }, attributes: ["id", "workerName"] }) : Promise.resolve<Worker[]>([]),
|
||||
customerIds.length ? this.customerRepo.findAll({ where: { id: { [Op.in]: customerIds } }, attributes: ["id", "customerName"] }) : Promise.resolve<Customer[]>([])
|
||||
]);
|
||||
const workerMap = new Map(workers.map((w) => [Number(w.id), w.workerName]));
|
||||
const customerMap = new Map(customers.map((c) => [Number(c.id), c.customerName]));
|
||||
const list = rows.map((r) => ({
|
||||
...r.toJSON(),
|
||||
workerName: workerMap.get(Number(r.workerId)) || "",
|
||||
customerName: customerMap.get(Number(r.customerId)) || ""
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取报警派单台账成功",
|
||||
data: { list, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Controller, Post, Get, Inject, Body, Query, Config } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { IccEventSubscribeService } from "../service/icc_event_subscribe.service";
|
||||
|
||||
/**
|
||||
* ICC 事件订阅相关接口:
|
||||
* - POST /api/icc-event/callback ICC 报警事件回调(不走登录鉴权/RBAC,靠 URL token 校验)
|
||||
* - POST /api/icc-event/subscribe 手动触发订阅注册/保活
|
||||
* - GET /api/icc-event/subscribe-status 查询订阅配置与最近一次注册结果
|
||||
*/
|
||||
@Controller("/api/icc-event")
|
||||
export class IccEventController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
iccEventSubscribeService!: IccEventSubscribeService;
|
||||
|
||||
@Config("iccEvent")
|
||||
iccEventConfig!: { monitorUrl: string; callbackToken: string; subscriberName: string; subscriberMagic: string };
|
||||
|
||||
/** ICC 回调入口:仅校验 URL token,校验通过后解析入库报警事件 */
|
||||
@Post("/callback")
|
||||
async callback(@Query() query: any, @Body() body: any) {
|
||||
if (query.token !== this.iccEventConfig.callbackToken) {
|
||||
this.ctx.status = 401;
|
||||
return { code: 401, message: "invalid token" };
|
||||
}
|
||||
await this.iccEventSubscribeService.handleCallback(body);
|
||||
// ICC 文档要求的返回体
|
||||
return { success: true, code: "0", errMsg: "" };
|
||||
}
|
||||
|
||||
/** 手动触发订阅注册(保活:ICC 同名覆盖) */
|
||||
@Post("/subscribe")
|
||||
async subscribe() {
|
||||
const result = await this.iccEventSubscribeService.ensureSubscribe();
|
||||
return {
|
||||
code: result.success ? 200 : 500,
|
||||
message: result.success ? "ICC 事件订阅成功" : `ICC 事件订阅失败:${result.errMsg ?? ""}`,
|
||||
data: result
|
||||
};
|
||||
}
|
||||
|
||||
/** 查询订阅配置与最近一次注册结果 */
|
||||
@Get("/subscribe-status")
|
||||
async status() {
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取 ICC 事件订阅状态成功",
|
||||
data: await this.iccEventSubscribeService.getSubscribeStatus()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Column, Model, Table, DataType, BelongsTo } from "sequelize-typescript";
|
||||
import DeviceAlarm from "./device_alarm.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "alarm_dispatch",
|
||||
comment: "报警派单信息表",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_alarm_disp_status", fields: ["status"] },
|
||||
{ name: "idx_alarm_disp_worker", fields: ["worker_id"] },
|
||||
{ name: "idx_alarm_disp_alarm", fields: ["alarm_id"] }
|
||||
]
|
||||
})
|
||||
export default class AlarmDispatch extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "dispatch_no", comment: "报警派单号(唯一)" })
|
||||
dispatchNo!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "alarm_id", comment: "报警 ID(→ device_alarm)" })
|
||||
alarmId!: number;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "line_id", comment: "线路 ID" })
|
||||
lineId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "line_code", comment: "线路编码" })
|
||||
lineCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称" })
|
||||
lineName!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "customer_id", comment: "客户 ID(→ customer)" })
|
||||
customerId!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "alarm_time", comment: "报警时间(快照)" })
|
||||
alarmTime!: Date;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "alarm_grade", comment: "报警等级(快照)" })
|
||||
alarmGrade!: number;
|
||||
|
||||
@Column({ type: DataType.TEXT, field: "fault_desc", comment: "故障事项(报警类型描述)" })
|
||||
faultDesc!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), comment: "派发人(当前登录用户 realName)" })
|
||||
dispatcher!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工 ID(→ worker)" })
|
||||
workerId!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已处理" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "created_at", defaultValue: DataType.NOW, comment: "创建时间" })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "completed_at", comment: "完成时间" })
|
||||
completedAt!: Date;
|
||||
|
||||
@BelongsTo(() => DeviceAlarm, { foreignKey: "alarmId", as: "alarm" })
|
||||
alarm!: DeviceAlarm;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Column, Model, Table, DataType } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "device_alarm",
|
||||
comment: "设备报警信息表(ICC 事件订阅)",
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_alarm_time", fields: [{ name: "alarm_time", order: "DESC" }] },
|
||||
{ name: "idx_alarm_line", fields: ["line_id"] },
|
||||
{ name: "idx_alarm_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_alarm_grade", fields: ["alarm_grade"] }
|
||||
]
|
||||
})
|
||||
export default class DeviceAlarm extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, unique: true, field: "alarm_code", comment: "ICC 报警编码(幂等键)" })
|
||||
alarmCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), comment: "ICC 事件 uuid" })
|
||||
uuid!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "line_id", comment: "智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)" })
|
||||
lineId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "line_code", comment: "线路编码(electric_line.line_code)" })
|
||||
lineCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称" })
|
||||
lineName!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "customer_id", comment: "客户 ID(由 line_id 联出)" })
|
||||
customerId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "node_code", comment: "探测器节点编码(info.nodeCode)" })
|
||||
nodeCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "device_code", comment: "设备编码(info.deviceCode)" })
|
||||
deviceCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称(info.deviceName)" })
|
||||
deviceName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "org_code", comment: "组织编码(info.orgCode)" })
|
||||
orgCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "org_name", comment: "组织名称(info.orgName)" })
|
||||
orgName!: string;
|
||||
|
||||
@Column({ type: DataType.INTEGER, field: "alarm_type", comment: "报警类型(info.alarmType)" })
|
||||
alarmType!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "alarm_grade", comment: "报警等级 1/2/3(1 最严重)" })
|
||||
alarmGrade!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "alarm_stat", comment: "报警状态(info.alarmStat)" })
|
||||
alarmStat!: number;
|
||||
|
||||
@Column({ type: DataType.BOOLEAN, field: "is_event", comment: "是否事件(info.isEvent)" })
|
||||
isEvent!: boolean;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "alarm_time", comment: "报警发生时间(info.alarmDate 秒级时间戳)" })
|
||||
alarmTime!: Date;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(14, 4), field: "alarm_value", comment: "报警数值(部分事件带值,可空)" })
|
||||
alarmValue!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false, field: "synced_at", comment: "入库时间" })
|
||||
syncedAt!: Date;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
|
||||
|
||||
@Provide()
|
||||
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权)
|
||||
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*'];
|
||||
// 中间件匹配范围:全局生效,但跳过公开接口、ICC 事件回调与电工 APP 接口(电工 APP 走独立鉴权)
|
||||
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/icc-event/callback'];
|
||||
|
||||
@Inject()
|
||||
tokenUtil!: TokenUtil;
|
||||
|
||||
@@ -31,6 +31,10 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
"/api/electric-lines": "device:list",
|
||||
"/api/electric-gateways": "device:list",
|
||||
"/api/electricity-data": "device:list",
|
||||
"/api/icc-event/subscribe": "device:list",
|
||||
"/api/icc-event/subscribe-status": "device:list",
|
||||
"/api/device-alarms": "alarm:list",
|
||||
"/api/alarm-dispatches": "alarm-dispatch:list",
|
||||
"/api/survey-orders": "project:survey",
|
||||
"/api/project-plans": "project:plan",
|
||||
"/api/contracts": "project:contract",
|
||||
@@ -50,10 +54,10 @@ const ADMIN_ROLE_CODE = "ADMIN";
|
||||
|
||||
@Provide()
|
||||
export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
|
||||
// 中间件匹配范围:全局生效,跳过公开接口、ICC 事件回调(URL token 校验)、仪表盘与电工 APP 接口
|
||||
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
|
||||
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
|
||||
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/workers/options", "/api/workers/recommend"];
|
||||
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/workers/options", "/api/workers/recommend", "/api/icc-event/callback"];
|
||||
|
||||
@Inject()
|
||||
permissionService!: PermissionService;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Provide, Inject, Config } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import DeviceAlarm from "../entity/device_alarm.entity";
|
||||
import ElectricLine from "../entity/electric_line.entity";
|
||||
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
|
||||
|
||||
/** 订阅注册结果 */
|
||||
export interface IccRegisterResult {
|
||||
success: boolean;
|
||||
code?: string;
|
||||
errMsg?: string;
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
/** 回调事件处理统计 */
|
||||
export interface IccEventHandleResult {
|
||||
/** 收到的通用事件总数 */
|
||||
received: number;
|
||||
/** 进入处理(category=alarm 且有 info)的事件数 */
|
||||
processed: number;
|
||||
/** 新增报警数 */
|
||||
inserted: number;
|
||||
/** 更新报警数 */
|
||||
updated: number;
|
||||
/** 未匹配到本系统线路的报警数 */
|
||||
unmatched: number;
|
||||
}
|
||||
|
||||
/** 线路预取信息(回填 lineId/lineCode/lineName/customerId,避免 N+1) */
|
||||
interface LineMatchInfo {
|
||||
lineId: number;
|
||||
lineName?: string;
|
||||
customerId: number;
|
||||
}
|
||||
|
||||
/** ICC 事件订阅接口路径(version 固定 1.0.0,与 ICC 事件订阅文档一致) */
|
||||
const ICC_EVENT_SUBSCRIBE_ENDPOINT = "/evo-apigw/evo-event/1.0.0/subscribe/mqinfo";
|
||||
/** 查询订阅列表接口路径(version 固定 1.0.0) */
|
||||
const ICC_EVENT_LIST_ENDPOINT = "/evo-apigw/evo-event/1.0.0/subscribe/subscribe-list";
|
||||
/** HTTP 超时 60s(项目约定) */
|
||||
const HTTP_TIMEOUT_MS = 60000;
|
||||
|
||||
/** 秒级时间戳转 Date(< 1e12 视为秒级 *1000;已毫秒级则原样);非法返回 null */
|
||||
function secondsToDate(value: unknown): Date | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return new Date(n < 1e12 ? n * 1000 : n);
|
||||
}
|
||||
|
||||
/** 数值解析:空 / null / 非法统一归一为 null */
|
||||
function toInt(value: unknown): number | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 字符串 trim;空串归一为 null */
|
||||
function toOptionalString(value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const s = String(value).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
/** 布尔解析:兼容 boolean / 0|1 / "true"|"false" / "1"|"0";空值归一为 null */
|
||||
function toBool(value: unknown): boolean | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") return null;
|
||||
if (typeof value === "boolean") return value;
|
||||
const s = String(value).trim().toLowerCase();
|
||||
if (s === "1" || s === "true" || s === "yes") return true;
|
||||
if (s === "0" || s === "false" || s === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ICC 事件订阅服务:
|
||||
* 1) registerSubscribe:向 ICC 事件订阅接口注册报警事件回调(monitor=monitorUrl?token=callbackToken,
|
||||
* 子系统 name/magic 取自配置 iccEvent),同名重复注册 ICC 会自动覆盖(保活语义)
|
||||
* 2) ensureSubscribe:保活入口,直接 registerSubscribe 同名覆盖(ICC 同名覆盖即保活),
|
||||
* 由每 30 分钟定时任务与手动接口调用
|
||||
* 3) handleCallback:处理 ICC 回调推送的通用事件,仅入库 category=alarm 的报警事件,
|
||||
* 按 alarm_code 幂等 upsert,uuid 相同重复推送跳过,按 nodeCode/deviceCode 匹配本系统线路
|
||||
*/
|
||||
@Provide()
|
||||
export class IccEventSubscribeService {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Config("iccEvent")
|
||||
iccEventConfig!: { monitorUrl: string; callbackToken: string; subscriberName: string; subscriberMagic: string };
|
||||
|
||||
@InjectRepository(DeviceAlarm)
|
||||
alarmModel!: Repository<DeviceAlarm>;
|
||||
|
||||
@InjectRepository(ElectricLine)
|
||||
lineModel!: Repository<ElectricLine>;
|
||||
|
||||
/** 最近一次订阅注册结果(内存,供 subscribe-status 接口查询) */
|
||||
private lastRegisterResult: IccRegisterResult | null = null;
|
||||
private lastRegisterAt: Date | null = null;
|
||||
|
||||
/**
|
||||
* 向 ICC 注册报警事件订阅(同名重复注册 ICC 覆盖,保活语义)。
|
||||
* 网络异常 / HTTP 错误 / ICC 业务失败均不抛错,返回失败状态并记录到内存。
|
||||
*/
|
||||
async registerSubscribe(): Promise<IccRegisterResult> {
|
||||
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||
if (!baseUrl) {
|
||||
return this.remember({ success: false, code: "500", errMsg: "未配置 ICC_BASE_URL,请检查 server/.env" });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getIccAccessToken();
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${ICC_EVENT_SUBSCRIBE_ENDPOINT}`;
|
||||
const body = {
|
||||
param: {
|
||||
monitors: [
|
||||
{
|
||||
monitor: `${this.iccEventConfig.monitorUrl}?token=${this.iccEventConfig.callbackToken}`,
|
||||
monitorType: "url",
|
||||
events: [{ category: "alarm", subscribeAll: 1, domainSubscribe: 2, eventType: 2 }]
|
||||
}
|
||||
],
|
||||
subsystem: {
|
||||
subsystemType: 0,
|
||||
name: this.iccEventConfig.subscriberName,
|
||||
magic: this.iccEventConfig.subscriberMagic
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const res = await iccFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
const payload: any = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
return this.remember({ success: false, code: String(res.status), errMsg: `ICC 订阅接口 HTTP ${res.status}`, raw: payload });
|
||||
}
|
||||
|
||||
const success = payload?.success === true || String(payload?.code ?? "") === "0";
|
||||
if (!success) {
|
||||
return this.remember({ success: false, code: String(payload?.code ?? ""), errMsg: String(payload?.errMsg ?? JSON.stringify(payload)), raw: payload });
|
||||
}
|
||||
return this.remember({ success: true, code: "0", errMsg: "", raw: payload });
|
||||
} catch (err: any) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return this.remember({ success: false, code: "500", errMsg: message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保活入口:ICC 同名订阅会覆盖,因此直接 registerSubscribe 即可(保活语义)。
|
||||
* 返回与 registerSubscribe 相同的注册结果。
|
||||
*/
|
||||
async ensureSubscribe(): Promise<IccRegisterResult> {
|
||||
return this.registerSubscribe();
|
||||
}
|
||||
|
||||
/** 查询订阅状态:配置 + 最近一次注册结果(内存) */
|
||||
async getSubscribeStatus(): Promise<{
|
||||
config: { monitorUrl: string; subscriberName: string; subscriberMagic: string };
|
||||
lastRegisterResult: IccRegisterResult | null;
|
||||
lastRegisterAt: Date | null;
|
||||
}> {
|
||||
return {
|
||||
config: {
|
||||
monitorUrl: this.iccEventConfig.monitorUrl,
|
||||
subscriberName: this.iccEventConfig.subscriberName,
|
||||
subscriberMagic: this.iccEventConfig.subscriberMagic
|
||||
},
|
||||
lastRegisterResult: this.lastRegisterResult,
|
||||
lastRegisterAt: this.lastRegisterAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 ICC 回调推送的通用事件:仅处理 category=alarm 且有 info 的事件。
|
||||
* - 按 info.nodeCode || info.deviceCode 匹配 electric_line.line_code,命中回填 lineId/lineCode/lineName/customerId
|
||||
* - uuid 相同(同一事件重复推送)跳过
|
||||
* - 按 alarm_code 幂等 upsert:存在则更新 alarmStat/isEvent/alarmTime/syncedAt 等,不存在则新增
|
||||
*/
|
||||
async handleCallback(body: any): Promise<IccEventHandleResult> {
|
||||
const result: IccEventHandleResult = { received: 0, processed: 0, inserted: 0, updated: 0, unmatched: 0 };
|
||||
|
||||
const events = this.extractEvents(body);
|
||||
result.received = events.length;
|
||||
if (events.length === 0) return result;
|
||||
|
||||
// 预取本系统已同步线路 → line_code → {lineId,lineName,customerId} 映射,避免 N+1
|
||||
const lines = await this.lineModel.findAll({
|
||||
attributes: ["id", "lineCode", "lineName", "customerId"]
|
||||
});
|
||||
const lineMap = new Map<string, LineMatchInfo>();
|
||||
for (const line of lines) {
|
||||
if (!line.lineCode) continue;
|
||||
lineMap.set(line.lineCode, {
|
||||
lineId: line.id,
|
||||
lineName: line.lineName || undefined,
|
||||
customerId: line.customerId
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
// 通用事件格式:仅处理 category=alarm 且有 info 对象的事件
|
||||
if (!event || event.category !== "alarm") continue;
|
||||
const info = event.info;
|
||||
if (!info || typeof info !== "object") continue;
|
||||
result.processed++;
|
||||
|
||||
const alarmCode = toOptionalString(info.alarmCode);
|
||||
if (!alarmCode) continue;
|
||||
|
||||
// uuid 去重:同一 uuid 重复推送直接跳过(uuid 非唯一列,按存在性判断)
|
||||
const uuid = toOptionalString(event.uuid ?? info.uuid);
|
||||
if (uuid) {
|
||||
const byUuid = await this.alarmModel.findOne({ where: { uuid } });
|
||||
if (byUuid) continue;
|
||||
}
|
||||
|
||||
// 按 nodeCode || deviceCode 匹配本系统线路(line_code)
|
||||
const lineFields: { lineId: number | null; lineCode: string | null; lineName: string | null; customerId: number | null } = {
|
||||
lineId: null,
|
||||
lineCode: null,
|
||||
lineName: null,
|
||||
customerId: null
|
||||
};
|
||||
const matchKey = toOptionalString(info.nodeCode) || toOptionalString(info.deviceCode);
|
||||
if (matchKey) {
|
||||
const lineInfo = lineMap.get(matchKey);
|
||||
if (lineInfo) {
|
||||
lineFields.lineId = lineInfo.lineId;
|
||||
lineFields.lineCode = matchKey;
|
||||
lineFields.lineName = lineInfo.lineName || null;
|
||||
lineFields.customerId = lineInfo.customerId;
|
||||
} else {
|
||||
result.unmatched++;
|
||||
}
|
||||
} else {
|
||||
result.unmatched++;
|
||||
}
|
||||
|
||||
const base = {
|
||||
uuid,
|
||||
nodeCode: toOptionalString(info.nodeCode),
|
||||
deviceCode: toOptionalString(info.deviceCode),
|
||||
deviceName: toOptionalString(info.deviceName),
|
||||
orgCode: toOptionalString(info.orgCode),
|
||||
orgName: toOptionalString(info.orgName),
|
||||
alarmType: toInt(info.alarmType),
|
||||
alarmGrade: toInt(info.alarmGrade),
|
||||
alarmStat: toInt(info.alarmStat),
|
||||
isEvent: toBool(info.isEvent),
|
||||
alarmValue: toInt(info.alarmValue),
|
||||
alarmTime: secondsToDate(info.alarmDate)
|
||||
};
|
||||
|
||||
// 按 alarm_code 幂等 upsert
|
||||
const existing = await this.alarmModel.findOne({ where: { alarmCode } });
|
||||
if (existing) {
|
||||
await existing.update({
|
||||
...base,
|
||||
...lineFields,
|
||||
syncedAt: new Date()
|
||||
} as any);
|
||||
result.updated++;
|
||||
} else {
|
||||
await this.alarmModel.create({
|
||||
alarmCode,
|
||||
...base,
|
||||
...lineFields,
|
||||
syncedAt: new Date()
|
||||
} as any);
|
||||
result.inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 从回调 body 提取通用事件数组(兼容 data.events / events / data 数组 / body 数组 / body 单条事件 五种形态) */
|
||||
private extractEvents(body: any): any[] {
|
||||
try {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
if (Array.isArray(body)) return body;
|
||||
if (Array.isArray(body.events)) return body.events;
|
||||
const data = body.data;
|
||||
if (data && Array.isArray(data.events)) return data.events;
|
||||
if (Array.isArray(data)) return data;
|
||||
// 兼容 ICC 直接推送单条通用事件对象(body 本身即事件)
|
||||
if (typeof body.category === "string" && body.info && typeof body.info === "object") return [body];
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录注册结果到内存并返回 */
|
||||
private remember(result: IccRegisterResult): IccRegisterResult {
|
||||
this.lastRegisterResult = result;
|
||||
this.lastRegisterAt = new Date();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { IccLineSyncService } from "./icc_line_sync.service";
|
||||
import { IccCustomerSyncService } from "./icc_customer_sync.service";
|
||||
import { IccGatewaySyncService } from "./icc_gateway_sync.service";
|
||||
import { IccAnalogSyncService } from "./icc_analog_sync.service";
|
||||
import { IccEventSubscribeService } from "./icc_event_subscribe.service";
|
||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
@@ -473,6 +474,12 @@ export class JobSchedulerService {
|
||||
const iccAnalogService = (await ctx.requestContext.getAsync(IccAnalogSyncService)) as IccAnalogSyncService;
|
||||
const result = await iccAnalogService.syncFromIcc();
|
||||
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
|
||||
},
|
||||
ensureIccEventSubscribe: async () => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const iccEventService = (await ctx.requestContext.getAsync(IccEventSubscribeService)) as IccEventSubscribeService;
|
||||
const result = await iccEventService.ensureSubscribe();
|
||||
return result.success ? `ICC 事件订阅成功(code=${result.code ?? "0"})` : `ICC 事件订阅失败:${result.errMsg ?? ""}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* ICC 事件订阅保活任务:每 30 分钟向 ICC 平台重新注册报警事件订阅(同名覆盖即保活)。
|
||||
* 失败仅记录日志返回摘要,不抛业务错误(参照 icc_analog_sync 容错约定)。
|
||||
*/
|
||||
export const iccEventAlarmKeepaliveJob: BuiltinJob = {
|
||||
jobKey: "icc_event_alarm_keepalive",
|
||||
jobName: "ICC事件订阅保活",
|
||||
description: "向 ICC 平台重新注册报警事件回调订阅(同名覆盖保活,默认每 30 分钟)",
|
||||
defaultCron: "*/30 * * * *",
|
||||
defaultParams: {},
|
||||
paramFields: [],
|
||||
handler: async (_params, ctx) => {
|
||||
return ctx.runtime.ensureIccEventSubscribe();
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { iccLineSyncJob } from "./icc_line_sync.task";
|
||||
import { iccCustomerSyncJob } from "./icc_customer_sync.task";
|
||||
import { iccGatewaySyncJob } from "./icc_gateway_sync.task";
|
||||
import { iccAnalogSyncJob } from "./icc_analog_sync.task";
|
||||
import { iccEventAlarmKeepaliveJob } from "./icc_event_keepalive.task";
|
||||
|
||||
export type JobParamType = "number" | "string";
|
||||
|
||||
@@ -41,6 +42,8 @@ export interface JobRuntime {
|
||||
syncIccGateways(): Promise<string>;
|
||||
/** 从 ICC 平台按 deviceCode 分批拉取探测器模拟量并按 (line_code,analog_type,collect_time) 幂等 upsert,返回执行摘要 */
|
||||
syncIccAnalogs(): Promise<string>;
|
||||
/** 向 ICC 平台注册报警事件回调订阅(同名覆盖保活),返回注册结果摘要 */
|
||||
ensureIccEventSubscribe(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface JobHandlerContext {
|
||||
@@ -67,7 +70,7 @@ export interface BuiltinJob {
|
||||
handler: JobHandler;
|
||||
}
|
||||
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob, iccGatewaySyncJob, iccAnalogSyncJob];
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob, iccGatewaySyncJob, iccAnalogSyncJob, iccEventAlarmKeepaliveJob];
|
||||
|
||||
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
|
||||
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
||||
|
||||
+33
-34
@@ -196,7 +196,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
|
||||
<div class="dom-body">
|
||||
<div class="tbl"><span class="tn">fault_type</span><span class="tc">报警故障类型</span></div>
|
||||
<div class="tbl"><span class="tn">fault_type_detail</span><span class="tc">故障类型明细</span></div>
|
||||
<div class="tbl"><span class="tn">device_alarm</span><span class="tc">设备报警(ICC 拉取)</span></div>
|
||||
<div class="tbl"><span class="tn">device_alarm</span><span class="tc">设备报警(ICC 事件订阅)</span></div>
|
||||
<div class="tbl"><span class="tn">alarm_dispatch</span><span class="tc">报警派单</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -732,49 +732,44 @@ erDiagram
|
||||
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
|
||||
</tbody></table></div>
|
||||
|
||||
<div class="tbl-title"><code>device_alarm</code><span class="td">— 设备报警信息表(ICC 拉取)</span></div>
|
||||
<div class="tbl-title"><code>device_alarm</code><span class="td">— 设备报警信息表(ICC 事件订阅)</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
|
||||
<tr><td><code>id</code></td><td>BIGSERIAL</td><td><span class="pill p-pk">PK</span></td><td>自增主键</td></tr>
|
||||
<tr><td><code>alarm_code</code></td><td>VARCHAR(128)</td><td>UNIQUE</td><td>报警编码</td></tr>
|
||||
<tr><td><code>line_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>线路 ID</td></tr>
|
||||
<tr><td><code>equip_id</code></td><td>BIGINT</td><td></td><td>客户设备 ID</td></tr>
|
||||
<tr><td><code>icc_device_code</code></td><td>VARCHAR(128)</td><td></td><td>ICC 设备编号</td></tr>
|
||||
<tr><td><code>alarm_code</code></td><td>VARCHAR(128)</td><td>UNIQUE</td><td>ICC 报警编码(幂等键)</td></tr>
|
||||
<tr><td><code>uuid</code></td><td>VARCHAR(128)</td><td></td><td>ICC 事件 uuid</td></tr>
|
||||
<tr><td><code>line_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>智电线路 ID(→ electric_line)</td></tr>
|
||||
<tr><td><code>line_code</code></td><td>VARCHAR(64)</td><td></td><td>线路编码(electric_line.line_code)</td></tr>
|
||||
<tr><td><code>line_name</code></td><td>VARCHAR(128)</td><td></td><td>线路名称</td></tr>
|
||||
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>客户 ID</td></tr>
|
||||
<tr><td><code>building_id</code></td><td>BIGINT</td><td></td><td>所属建筑 ID</td></tr>
|
||||
<tr><td><code>floor_no</code></td><td>VARCHAR(16)</td><td></td><td>所在楼层</td></tr>
|
||||
<tr><td><code>location</code></td><td>VARCHAR(256)</td><td></td><td>所在位置</td></tr>
|
||||
<tr><td><code>alarm_date</code></td><td>DATE</td><td><span class="pill p-pi">IDX</span></td><td>报警日期</td></tr>
|
||||
<tr><td><code>fault_type</code></td><td>VARCHAR(128)</td><td></td><td>ICC 预警类型</td></tr>
|
||||
<tr><td><code>alarm_value</code></td><td>NUMERIC(14,2)</td><td></td><td>报警数值</td></tr>
|
||||
<tr><td><code>history_count</code></td><td>INT</td><td></td><td>历史出现次数</td></tr>
|
||||
<tr><td><code>synced_at</code></td><td>TIMESTAMPTZ</td><td></td><td>ICC 同步时间</td></tr>
|
||||
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>客户 ID(由 line_id 联出)</td></tr>
|
||||
<tr><td><code>node_code</code></td><td>VARCHAR(64)</td><td></td><td>探测器节点编码(info.nodeCode)</td></tr>
|
||||
<tr><td><code>device_code</code></td><td>VARCHAR(64)</td><td></td><td>设备编码(info.deviceCode)</td></tr>
|
||||
<tr><td><code>device_name</code></td><td>VARCHAR(128)</td><td></td><td>设备名称(info.deviceName)</td></tr>
|
||||
<tr><td><code>org_code</code></td><td>VARCHAR(64)</td><td></td><td>组织编码(info.orgCode)</td></tr>
|
||||
<tr><td><code>org_name</code></td><td>VARCHAR(128)</td><td></td><td>组织名称(info.orgName)</td></tr>
|
||||
<tr><td><code>alarm_type</code></td><td>INT</td><td></td><td>报警类型(info.alarmType)</td></tr>
|
||||
<tr><td><code>alarm_grade</code></td><td>SMALLINT</td><td><span class="pill p-pi">IDX</span></td><td>报警等级 1/2/3(1 最严重)</td></tr>
|
||||
<tr><td><code>alarm_stat</code></td><td>SMALLINT</td><td></td><td>报警状态(info.alarmStat)</td></tr>
|
||||
<tr><td><code>is_event</code></td><td>BOOLEAN</td><td></td><td>是否事件(info.isEvent)</td></tr>
|
||||
<tr><td><code>alarm_time</code></td><td>TIMESTAMPTZ</td><td><span class="pill p-pi">IDX</span></td><td>报警发生时间(info.alarmDate 秒级时间戳)</td></tr>
|
||||
<tr><td><code>alarm_value</code></td><td>NUMERIC(14,4)</td><td></td><td>报警数值(部分事件带值,可空)</td></tr>
|
||||
<tr><td><code>synced_at</code></td><td>TIMESTAMPTZ</td><td></td><td>入库时间</td></tr>
|
||||
</tbody></table></div>
|
||||
|
||||
<div class="tbl-title"><code>alarm_dispatch</code><span class="td">— 报警派单信息表</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
|
||||
<tr><td><code>id</code></td><td>BIGSERIAL</td><td><span class="pill p-pk">PK</span></td><td>自增主键</td></tr>
|
||||
<tr><td><code>dispatch_no</code></td><td>VARCHAR(64)</td><td>UNIQUE</td><td>报警派单号</td></tr>
|
||||
<tr><td><code>alarm_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>报警 ID(→ device_alarm)</td></tr>
|
||||
<tr><td><code>dispatch_no</code></td><td>VARCHAR(64)</td><td>UNIQUE</td><td>报警派单号(如 AD20261024001)</td></tr>
|
||||
<tr><td><code>alarm_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>报警 ID(→ device_alarm)</td></tr>
|
||||
<tr><td><code>line_id</code></td><td>BIGINT</td><td></td><td>线路 ID</td></tr>
|
||||
<tr><td><code>equip_id</code></td><td>BIGINT</td><td></td><td>客户设备 ID</td></tr>
|
||||
<tr><td><code>icc_device_code</code></td><td>VARCHAR(128)</td><td></td><td>ICC 设备编号</td></tr>
|
||||
<tr><td><code>line_code</code></td><td>VARCHAR(64)</td><td></td><td>线路编码</td></tr>
|
||||
<tr><td><code>line_name</code></td><td>VARCHAR(128)</td><td></td><td>线路名称</td></tr>
|
||||
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>客户 ID</td></tr>
|
||||
<tr><td><code>contact_person</code></td><td>VARCHAR(64)</td><td></td><td>联系人</td></tr>
|
||||
<tr><td><code>contact_phone</code></td><td>VARCHAR(20)</td><td></td><td>电话</td></tr>
|
||||
<tr><td><code>building_id</code></td><td>BIGINT</td><td></td><td>建筑 ID</td></tr>
|
||||
<tr><td><code>floor_no</code></td><td>VARCHAR(16)</td><td></td><td>所在楼层</td></tr>
|
||||
<tr><td><code>location</code></td><td>VARCHAR(256)</td><td></td><td>所在位置</td></tr>
|
||||
<tr><td><code>fault_type_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>故障类型 ID(→ fault_type)</td></tr>
|
||||
<tr><td><code>fault_desc</code></td><td>TEXT</td><td></td><td>故障事项(类型+数值)</td></tr>
|
||||
<tr><td><code>dispatcher</code></td><td>VARCHAR(64)</td><td></td><td>派发人</td></tr>
|
||||
<tr><td><code>fault_reason</code></td><td>TEXT</td><td></td><td>故障原因(来自明细)</td></tr>
|
||||
<tr><td><code>process_flow</code></td><td>TEXT</td><td></td><td>处理流程(来自明细)</td></tr>
|
||||
<tr><td><code>worker_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>共享电工 ID</td></tr>
|
||||
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>客户 ID(→ customer)</td></tr>
|
||||
<tr><td><code>alarm_time</code></td><td>TIMESTAMPTZ</td><td></td><td>报警时间(快照)</td></tr>
|
||||
<tr><td><code>alarm_grade</code></td><td>SMALLINT</td><td></td><td>报警等级(快照)</td></tr>
|
||||
<tr><td><code>fault_desc</code></td><td>TEXT</td><td></td><td>故障事项(报警类型描述)</td></tr>
|
||||
<tr><td><code>dispatcher</code></td><td>VARCHAR(64)</td><td></td><td>派发人(当前登录用户 realName)</td></tr>
|
||||
<tr><td><code>worker_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>共享电工 ID(→ worker)</td></tr>
|
||||
<tr><td><code>status</code></td><td>SMALLINT</td><td><span class="pill p-pi">IDX</span></td><td>状态:0已派发 1已处理</td></tr>
|
||||
<tr><td><code>process_photos</code></td><td>JSONB</td><td></td><td>报警处理照片(多张)</td></tr>
|
||||
<tr><td><code>rating</code></td><td>SMALLINT</td><td></td><td>派单处理评分</td></tr>
|
||||
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
|
||||
<tr><td><code>completed_at</code></td><td>TIMESTAMPTZ</td><td></td><td>完成时间</td></tr>
|
||||
</tbody></table></div>
|
||||
@@ -971,9 +966,13 @@ erDiagram
|
||||
<tr><td>electric_line</td><td><code>idx_line_customer</code></td><td>B-Tree</td><td>按客户查线路(逻辑隔离)</td></tr>
|
||||
<tr><td>electric_line</td><td><code>idx_line_icc</code></td><td>B-Tree</td><td>ICC 设备码匹配同步</td></tr>
|
||||
<tr><td>electricity_data</td><td><code>idx_ed_customer_time</code></td><td>B-Tree 组合</td><td>按客户+时间查用电(月报核心查询)</td></tr>
|
||||
<tr><td>device_alarm</td><td><code>idx_alarm_date</code></td><td>B-Tree</td><td>按报警日期查询</td></tr>
|
||||
<tr><td>device_alarm</td><td><code>idx_alarm_time</code></td><td>B-Tree DESC</td><td>按报警时间倒序(列表默认排序)</td></tr>
|
||||
<tr><td>device_alarm</td><td><code>idx_alarm_line</code></td><td>B-Tree</td><td>按线路查报警</td></tr>
|
||||
<tr><td>device_alarm</td><td><code>idx_alarm_customer</code></td><td>B-Tree</td><td>按客户查报警</td></tr>
|
||||
<tr><td>device_alarm</td><td><code>idx_alarm_grade</code></td><td>B-Tree</td><td>按报警等级过滤(严重报警)</td></tr>
|
||||
<tr><td>alarm_dispatch</td><td><code>idx_alarm_disp_status</code></td><td>B-Tree</td><td>按派单状态过滤</td></tr>
|
||||
<tr><td>alarm_dispatch</td><td><code>idx_alarm_disp_worker</code></td><td>B-Tree</td><td>电工查自己的派单(接口数据权限)</td></tr>
|
||||
<tr><td>alarm_dispatch</td><td><code>idx_alarm_disp_alarm</code></td><td>B-Tree</td><td>按报警查派单</td></tr>
|
||||
<tr><td>repair_dispatch</td><td><code>idx_repair_disp_worker</code></td><td>B-Tree</td><td>电工查报修派单</td></tr>
|
||||
<tr><td>sys_dict</td><td><code>idx_sys_dict_type</code></td><td>B-Tree</td><td>按字典类型查询</td></tr>
|
||||
<tr><td>icc_sync_log</td><td><code>idx_icc_sync_time</code></td><td>B-Tree</td><td>按时间查同步日志</td></tr>
|
||||
|
||||
@@ -88,3 +88,58 @@ electricity_data 表结构重建(ICC 模拟量同步)
|
||||
- 手动同步接口:`POST /api/electricity-data/sync`(JWT+RBAC `device:list`,返回 pulled/inserted/updated/skipped/synced)
|
||||
- 不做前端页面/菜单
|
||||
|
||||
2026-10-24 ICC 报警事件订阅
|
||||
|
||||
`device_alarm`(设备报警信息表)与 `alarm_dispatch`(报警派单信息表)原为旧设计(含 equip_id/building_id/floor_no/location/history_count/fault_type/alarm_date 等与 ICC 事件结构不符的字段),本次按 ICC 平台事件订阅推送的通用事件格式重建。两表均为空表,已 DROP 重建。迁移文件:`database/migrations/20261024_icc_event_alarm.sql`(2026-10-24 已执行到生产库),`database/db_init.sql` 同步更新。
|
||||
|
||||
device_alarm 表(设备报警信息表,ICC 事件订阅)
|
||||
|
||||
| 字段 | 类型 | 键 | 说明 |
|
||||
| :---------- | :------------ | :----- | :-------------------------------------------------- |
|
||||
| id | BIGSERIAL | PK | 自增主键 |
|
||||
| alarm_code | VARCHAR(128) | UNIQUE | ICC 报警编码(幂等键) |
|
||||
| uuid | VARCHAR(128) | | ICC 事件 uuid |
|
||||
| line_id | BIGINT | IDX | 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code) |
|
||||
| line_code | VARCHAR(64) | | 线路编码(electric_line.line_code) |
|
||||
| line_name | VARCHAR(128) | | 线路名称 |
|
||||
| customer_id | BIGINT | IDX | 客户 ID(由 line_id 联出) |
|
||||
| node_code | VARCHAR(64) | | 探测器节点编码(info.nodeCode) |
|
||||
| device_code | VARCHAR(64) | | 设备编码(info.deviceCode) |
|
||||
| device_name | VARCHAR(128) | | 设备名称(info.deviceName) |
|
||||
| org_code | VARCHAR(64) | | 组织编码(info.orgCode) |
|
||||
| org_name | VARCHAR(128) | | 组织名称(info.orgName) |
|
||||
| alarm_type | INT | | 报警类型(info.alarmType) |
|
||||
| alarm_grade | SMALLINT | IDX | 报警等级 1/2/3(1 最严重) |
|
||||
| alarm_stat | SMALLINT | | 报警状态(info.alarmStat) |
|
||||
| is_event | BOOLEAN | | 是否事件(info.isEvent) |
|
||||
| alarm_time | TIMESTAMPTZ | IDX | 报警发生时间(info.alarmDate 秒级时间戳) |
|
||||
| alarm_value | NUMERIC(14,4) | | 报警数值(部分事件带值,可空) |
|
||||
| synced_at | TIMESTAMPTZ | | 入库时间 |
|
||||
|
||||
alarm_dispatch 表(报警派单信息表)
|
||||
|
||||
| 字段 | 类型 | 键 | 说明 |
|
||||
| :----------- | :------------ | :----- | :-------------------------------------- |
|
||||
| id | BIGSERIAL | PK | 自增主键 |
|
||||
| dispatch_no | VARCHAR(64) | UNIQUE | 报警派单号(如 AD20261024001,生成规则由后端实现) |
|
||||
| alarm_id | BIGINT | IDX | 报警 ID(→ device_alarm) |
|
||||
| line_id | BIGINT | | 线路 ID |
|
||||
| line_code | VARCHAR(64) | | 线路编码 |
|
||||
| line_name | VARCHAR(128) | | 线路名称 |
|
||||
| customer_id | BIGINT | FK | 客户 ID(→ customer) |
|
||||
| alarm_time | TIMESTAMPTZ | | 报警时间(快照) |
|
||||
| alarm_grade | SMALLINT | | 报警等级(快照) |
|
||||
| fault_desc | TEXT | | 故障事项(报警类型描述) |
|
||||
| dispatcher | VARCHAR(64) | | 派发人(当前登录用户 realName) |
|
||||
| worker_id | BIGINT | IDX | 共享电工 ID(→ worker) |
|
||||
| status | SMALLINT | IDX | 状态:0已派发 1已处理 |
|
||||
| created_at | TIMESTAMPTZ | | 创建时间 |
|
||||
| completed_at | TIMESTAMPTZ | | 完成时间 |
|
||||
|
||||
订阅机制:
|
||||
|
||||
- 回调接口:`POST /api/icc-event/callback`,接收 ICC 平台事件订阅推送的报警事件,按 `alarm_code` 幂等入库 device_alarm(重复事件覆盖更新)
|
||||
- 保活任务:每 30 分钟执行一次(向 ICC 平台上报回调地址,保活事件订阅)
|
||||
- 字段映射:info.nodeCode→node_code、info.deviceCode→device_code、info.deviceName→device_name、info.orgCode→org_code、info.orgName→org_name、info.alarmType→alarm_type、info.alarmGrade→alarm_grade、info.alarmStat→alarm_stat、info.isEvent→is_event、info.alarmDate(秒级时间戳)→alarm_time;line_id/line_code/customer_id 由 nodeCode/deviceCode 匹配 electric_line.line_code 联出
|
||||
- 报警派单:从 device_alarm 派单写入 alarm_dispatch,dispatch_no 生成规则由后端实现(如 AD20261024001)
|
||||
|
||||
Reference in new issue
Block a user